From 3eafa79f39c06b67d18bd2c1fd5321d2d889ed90 Mon Sep 17 00:00:00 2001 From: Haozhe Date: Wed, 3 Jun 2026 21:11:30 +0800 Subject: [PATCH] feat(acp): implement ACP server with session lifecycle, tool streaming, and IDE integration (#368) This commit scaffolds the @moonshot-ai/acp-adapter package and introduces the full ACP (Agent Communication Protocol) server implementation for Kimi Code CLI, including: - Scaffold @moonshot-ai/acp-adapter workspace package with build skeleton - `kimi acp` CLI subcommand and stdout-safe logging - ACP version negotiation and AgentSideConnection wrapper - Auth gate for session creation - Session lifecycle: new, list, load with history replay - Prompt content conversion (text, image, embedded resources, resource links) - Assistant streaming with thinking support and end-turn handling - Tool call streaming (started, delta, progress) with result conversion (text / diff) - Approval handling with diff/text display blocks mapped to ACP options - Kaos read/write interface (AcpKaos) for unsaved buffer access - Session mode (yolo/auto) and model management - Config options builder with thinking toggle - MCP server forwarding from ACP to harness - Agent plan updates and available commands updates - AskUserQuestion bridged to session/request_permission - Plan review options surfaced through requestPermission - Error mapping, ext_method stubs, and graceful shutdown - IDE integration guide (Zed + JetBrains) - End-to-end tests against ACP TS SDK client --- .changeset/acp-adapter.md | 6 + apps/kimi-code/package.json | 1 + apps/kimi-code/src/cli/commands.ts | 4 + apps/kimi-code/src/cli/sub/acp.ts | 83 ++ apps/kimi-code/src/cli/sub/login-flow.ts | 59 + apps/kimi-code/src/cli/sub/login.ts | 20 + apps/kimi-code/src/tui/commands/info.ts | 2 +- .../tui/controllers/session-event-handler.ts | 2 +- apps/kimi-code/src/tui/kimi-tui.ts | 2 +- .../kimi-code/src/{tui => }/utils/open-url.ts | 0 apps/kimi-code/test/cli/acp.test.ts | 165 +++ apps/kimi-code/test/cli/login.test.ts | 130 ++ apps/kimi-code/test/cli/options.test.ts | 2 +- docs/.vitepress/config.ts | 4 + docs/en/guides/ides.md | 69 + docs/en/reference/kimi-acp.md | 151 ++ docs/en/reference/kimi-command.md | 10 + docs/zh/guides/ides.md | 69 + docs/zh/reference/kimi-acp.md | 151 ++ docs/zh/reference/kimi-command.md | 10 + flake.nix | 4 +- packages/acp-adapter/README.md | 23 + packages/acp-adapter/package.json | 48 + packages/acp-adapter/src/approval.ts | 316 +++++ packages/acp-adapter/src/auth-methods.ts | 74 + packages/acp-adapter/src/config-options.ts | 180 +++ packages/acp-adapter/src/convert.ts | 225 +++ packages/acp-adapter/src/events-map.ts | 514 +++++++ packages/acp-adapter/src/index.ts | 28 + packages/acp-adapter/src/kaos-acp.ts | 256 ++++ packages/acp-adapter/src/log-guard.ts | 60 + packages/acp-adapter/src/marker.ts | 40 + packages/acp-adapter/src/mcp.ts | 112 ++ packages/acp-adapter/src/model-catalog.ts | 86 ++ packages/acp-adapter/src/modes.ts | 108 ++ packages/acp-adapter/src/question.ts | 99 ++ packages/acp-adapter/src/server.ts | 988 +++++++++++++ packages/acp-adapter/src/session.ts | 1256 +++++++++++++++++ packages/acp-adapter/src/types.ts | 29 + packages/acp-adapter/src/version.ts | 50 + .../test/_helpers/harness-stubs.ts | 47 + .../acp-adapter/test/approval-cancel.test.ts | 345 +++++ .../acp-adapter/test/approval-display.test.ts | 367 +++++ .../test/approval-plan-review.test.ts | 225 +++ packages/acp-adapter/test/approval.test.ts | 341 +++++ packages/acp-adapter/test/auth-gate.test.ts | 134 ++ packages/acp-adapter/test/cancel.test.ts | 142 ++ .../acp-adapter/test/config-options.test.ts | 193 +++ packages/acp-adapter/test/convert.test.ts | 232 +++ packages/acp-adapter/test/e2e-fs.test.ts | 263 ++++ .../acp-adapter/test/e2e-happy-path.test.ts | 295 ++++ .../acp-adapter/test/error-mapping.test.ts | 261 ++++ packages/acp-adapter/test/ext-methods.test.ts | 83 ++ packages/acp-adapter/test/hide-output.test.ts | 91 ++ packages/acp-adapter/test/kaos-acp.test.ts | 442 ++++++ .../acp-adapter/test/kaos-activation.test.ts | 252 ++++ packages/acp-adapter/test/log-guard.test.ts | 54 + packages/acp-adapter/test/mcp-forward.test.ts | 267 ++++ .../test/plan-and-commands.test.ts | 361 +++++ packages/acp-adapter/test/question.test.ts | 101 ++ packages/acp-adapter/test/server.test.ts | 204 +++ .../test/session-config-option-funnel.test.ts | 256 ++++ .../acp-adapter/test/session-control.test.ts | 345 +++++ .../acp-adapter/test/session-list.test.ts | 181 +++ .../acp-adapter/test/session-load.test.ts | 322 +++++ packages/acp-adapter/test/session-new.test.ts | 207 +++ .../acp-adapter/test/session-prompt.test.ts | 229 +++ .../test/session-question-handler.test.ts | 273 ++++ .../acp-adapter/test/session-resume.test.ts | 264 ++++ .../test/set-session-config-option.test.ts | 296 ++++ packages/acp-adapter/test/shutdown.test.ts | 134 ++ .../acp-adapter/test/tool-call-stream.test.ts | 476 +++++++ packages/acp-adapter/test/tool-result.test.ts | 402 ++++++ packages/acp-adapter/test/version.test.ts | 29 + packages/acp-adapter/tsconfig.json | 5 + packages/acp-adapter/tsdown.config.ts | 18 + packages/acp-adapter/vitest.config.ts | 11 + packages/agent-core/src/mcp/session-config.ts | 15 + packages/agent-core/src/rpc/core-api.ts | 4 +- packages/agent-core/src/rpc/core-impl.ts | 12 +- .../test/mcp/session-config-merge.test.ts | 58 + packages/node-sdk/src/kimi-harness.ts | 2 +- packages/node-sdk/src/rpc.ts | 2 +- pnpm-lock.yaml | 29 +- 84 files changed, 13691 insertions(+), 15 deletions(-) create mode 100644 .changeset/acp-adapter.md create mode 100644 apps/kimi-code/src/cli/sub/acp.ts create mode 100644 apps/kimi-code/src/cli/sub/login-flow.ts create mode 100644 apps/kimi-code/src/cli/sub/login.ts rename apps/kimi-code/src/{tui => }/utils/open-url.ts (100%) create mode 100644 apps/kimi-code/test/cli/acp.test.ts create mode 100644 apps/kimi-code/test/cli/login.test.ts create mode 100644 docs/en/guides/ides.md create mode 100644 docs/en/reference/kimi-acp.md create mode 100644 docs/zh/guides/ides.md create mode 100644 docs/zh/reference/kimi-acp.md create mode 100644 packages/acp-adapter/README.md create mode 100644 packages/acp-adapter/package.json create mode 100644 packages/acp-adapter/src/approval.ts create mode 100644 packages/acp-adapter/src/auth-methods.ts create mode 100644 packages/acp-adapter/src/config-options.ts create mode 100644 packages/acp-adapter/src/convert.ts create mode 100644 packages/acp-adapter/src/events-map.ts create mode 100644 packages/acp-adapter/src/index.ts create mode 100644 packages/acp-adapter/src/kaos-acp.ts create mode 100644 packages/acp-adapter/src/log-guard.ts create mode 100644 packages/acp-adapter/src/marker.ts create mode 100644 packages/acp-adapter/src/mcp.ts create mode 100644 packages/acp-adapter/src/model-catalog.ts create mode 100644 packages/acp-adapter/src/modes.ts create mode 100644 packages/acp-adapter/src/question.ts create mode 100644 packages/acp-adapter/src/server.ts create mode 100644 packages/acp-adapter/src/session.ts create mode 100644 packages/acp-adapter/src/types.ts create mode 100644 packages/acp-adapter/src/version.ts create mode 100644 packages/acp-adapter/test/_helpers/harness-stubs.ts create mode 100644 packages/acp-adapter/test/approval-cancel.test.ts create mode 100644 packages/acp-adapter/test/approval-display.test.ts create mode 100644 packages/acp-adapter/test/approval-plan-review.test.ts create mode 100644 packages/acp-adapter/test/approval.test.ts create mode 100644 packages/acp-adapter/test/auth-gate.test.ts create mode 100644 packages/acp-adapter/test/cancel.test.ts create mode 100644 packages/acp-adapter/test/config-options.test.ts create mode 100644 packages/acp-adapter/test/convert.test.ts create mode 100644 packages/acp-adapter/test/e2e-fs.test.ts create mode 100644 packages/acp-adapter/test/e2e-happy-path.test.ts create mode 100644 packages/acp-adapter/test/error-mapping.test.ts create mode 100644 packages/acp-adapter/test/ext-methods.test.ts create mode 100644 packages/acp-adapter/test/hide-output.test.ts create mode 100644 packages/acp-adapter/test/kaos-acp.test.ts create mode 100644 packages/acp-adapter/test/kaos-activation.test.ts create mode 100644 packages/acp-adapter/test/log-guard.test.ts create mode 100644 packages/acp-adapter/test/mcp-forward.test.ts create mode 100644 packages/acp-adapter/test/plan-and-commands.test.ts create mode 100644 packages/acp-adapter/test/question.test.ts create mode 100644 packages/acp-adapter/test/server.test.ts create mode 100644 packages/acp-adapter/test/session-config-option-funnel.test.ts create mode 100644 packages/acp-adapter/test/session-control.test.ts create mode 100644 packages/acp-adapter/test/session-list.test.ts create mode 100644 packages/acp-adapter/test/session-load.test.ts create mode 100644 packages/acp-adapter/test/session-new.test.ts create mode 100644 packages/acp-adapter/test/session-prompt.test.ts create mode 100644 packages/acp-adapter/test/session-question-handler.test.ts create mode 100644 packages/acp-adapter/test/session-resume.test.ts create mode 100644 packages/acp-adapter/test/set-session-config-option.test.ts create mode 100644 packages/acp-adapter/test/shutdown.test.ts create mode 100644 packages/acp-adapter/test/tool-call-stream.test.ts create mode 100644 packages/acp-adapter/test/tool-result.test.ts create mode 100644 packages/acp-adapter/test/version.test.ts create mode 100644 packages/acp-adapter/tsconfig.json create mode 100644 packages/acp-adapter/tsdown.config.ts create mode 100644 packages/acp-adapter/vitest.config.ts create mode 100644 packages/agent-core/test/mcp/session-config-merge.test.ts diff --git a/.changeset/acp-adapter.md b/.changeset/acp-adapter.md new file mode 100644 index 000000000..2f16b6053 --- /dev/null +++ b/.changeset/acp-adapter.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/acp-adapter": minor +"@moonshot-ai/kimi-code": minor +--- + +Add `@moonshot-ai/acp-adapter` and the `kimi acp` subcommand: kimi-code now speaks [Agent Client Protocol 0.23](https://agentclientprotocol.com/) over stdio so IDEs (Zed, JetBrains AI Chat, custom clients) can drive sessions directly — coverage matrix, Zed configuration and breaking pre-release notes are in [`docs/en/reference/kimi-acp.md`](https://www.kimi.com/code/docs/en/kimi-code-cli/reference/kimi-acp.html). diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 84b62a694..9677d04e8 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -76,6 +76,7 @@ "zod": "^4.3.6" }, "devDependencies": { + "@moonshot-ai/acp-adapter": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", "@moonshot-ai/kimi-code-sdk": "workspace:^", "@moonshot-ai/kimi-telemetry": "workspace:^", diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 2c31b9fa3..0d4a558d5 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -5,7 +5,9 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; import { registerMigrateCommand } from '#/migration/index'; import type { CLIOptions } from './options'; +import { registerAcpCommand } from './sub/acp'; import { registerExportCommand } from './sub/export'; +import { registerLoginCommand } from './sub/login'; import { registerProviderCommand } from './sub/provider'; export type MainCommandHandler = (opts: CLIOptions) => void; @@ -78,6 +80,8 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); + registerAcpCommand(program); + registerLoginCommand(program); registerMigrateCommand(program, onMigrate); program .command('upgrade') diff --git a/apps/kimi-code/src/cli/sub/acp.ts b/apps/kimi-code/src/cli/sub/acp.ts new file mode 100644 index 000000000..5fa786676 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/acp.ts @@ -0,0 +1,83 @@ +/** + * `kimi acp` sub-command. + * + * Starts the Agent Client Protocol (ACP) server over stdio so that + * ACP-compatible clients (editors, IDEs, custom front-ends) can drive + * a kimi-code session. + * + * Wire-up: + * - A {@link KimiHarness} is constructed with the kimi-code host identity + * and a dedicated `uiMode: 'acp'` so downstream telemetry can + * distinguish ACP sessions from the TUI. + * - {@link runAcpServer} owns the JSON-RPC stdio bridge and redirects + * rogue `console.*` traffic to stderr. + * - `--login` pivots into the device-code login flow instead of + * starting the server. This is the entry point ACP clients hit + * via the first-class `AuthMethodTerminal` path when they re-invoke + * the agent binary with the advertised `args:['--login']` appended. + * - On stream close or unhandled error the process exits with the + * appropriate code. + */ + +import type { Command } from 'commander'; + +import { runAcpServer } from '@moonshot-ai/acp-adapter'; +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; +import { createKimiCodeHostIdentity, getVersion } from '#/cli/version'; + +import { runLoginFlow } from './login-flow'; + +export function registerAcpCommand(parent: Command): void { + parent + .command('acp') + .description('Run kimi-code as an Agent Client Protocol (ACP) server over stdio.') + .option( + '--login', + 'Run the device-code login flow then exit (entry point for ACP terminal-auth).', + false, + ) + .action(async (opts: { login?: boolean }) => { + if (opts.login === true) { + await runLoginFlow(); + return; + } + const identity = createKimiCodeHostIdentity(); + const harness = createKimiHarness({ + identity, + uiMode: 'acp', + }); + // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the + // `kimi login` subprocess clients spawn for terminal-auth writes its + // token under the same data root the ACP server reads from. Used for + // sandboxed test setups (Zed's `agent_servers.*.env.KIMI_CODE_HOME = + // /tmp/...`). Production runs leave the env unset and the field stays + // empty. + const sandboxHome = process.env[KIMI_CODE_HOME_ENV]; + const terminalAuthEnv = + sandboxHome !== undefined && sandboxHome.length > 0 + ? { [KIMI_CODE_HOME_ENV]: sandboxHome } + : undefined; + // Legacy `_meta.terminal-auth` fallback for clients that don't yet + // honor the first-class `type:'terminal'` (Zed without the + // AcpBetaFeatureFlag, current JetBrains plugin, etc.). `command` is + // the absolute path to this very binary (`process.argv[1]`) so the + // client can spawn it with `args:['login']` for the top-level + // `kimi login` subcommand — matches kimi-cli `acp/server.py:77-96`. + const legacyCommand = process.argv[1]; + try { + await runAcpServer(harness, { + agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), + }); + process.exit(0); + } catch (err) { + process.stderr.write(`acp server: fatal error: ${String(err)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/src/cli/sub/login-flow.ts b/apps/kimi-code/src/cli/sub/login-flow.ts new file mode 100644 index 000000000..3c1a123d4 --- /dev/null +++ b/apps/kimi-code/src/cli/sub/login-flow.ts @@ -0,0 +1,59 @@ +/** + * Shared device-code login flow used by both `kimi login` (top-level + * subcommand) and `kimi acp --login` (the first-class ACP terminal-auth + * entry point). Exiting the process is part of the contract — callers + * MUST treat the returned promise as `Promise`. + */ + +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { createKimiCodeHostIdentity } from '#/cli/version'; +import { openUrl } from '#/utils/open-url'; + +export async function runLoginFlow(): Promise { + const identity = createKimiCodeHostIdentity(); + const harness = createKimiHarness({ + identity, + uiMode: 'cli', + }); + const controller = new AbortController(); + process.once('SIGINT', () => controller.abort()); + try { + const result = await harness.auth.login(undefined, { + signal: controller.signal, + onDeviceCode: (data) => { + const url = data.verificationUriComplete || data.verificationUri; + // Best-effort: try to open the user's default browser at the + // pre-baked URL (which already embeds the user code). Print the + // URL + code as a fallback for headless boxes / when openUrl + // silently fails (it `execFile`s `open`/`xdg-open`/`cmd start` + // with no error handling — see `utils/open-url.ts`). + openUrl(url); + process.stderr.write( + [ + '', + `Opening browser for Kimi device login: ${url}`, + `If the browser did not open, paste the URL above and enter code: ${data.userCode}`, + data.expiresIn !== null && data.expiresIn !== undefined + ? `Code expires in ${data.expiresIn}s.` + : undefined, + 'Waiting for authorization to complete...', + '', + ] + .filter((line): line is string => line !== undefined) + .join('\n'), + ); + }, + }); + process.stderr.write(`Logged in to ${result.providerName}.\n`); + process.exit(0); + } catch (err) { + if (controller.signal.aborted) { + process.stderr.write('Login cancelled.\n'); + } else { + const message = err instanceof Error ? err.message : String(err); + process.stderr.write(`Login failed: ${message}\n`); + } + process.exit(1); + } +} diff --git a/apps/kimi-code/src/cli/sub/login.ts b/apps/kimi-code/src/cli/sub/login.ts new file mode 100644 index 000000000..2c17b4c3a --- /dev/null +++ b/apps/kimi-code/src/cli/sub/login.ts @@ -0,0 +1,20 @@ +/** + * `kimi login` — drive the OAuth device-code flow non-interactively. + * The `authMethods.terminal-auth.args=['login']` (legacy `_meta` path) + * advertised by the ACP server points clients at this entry point. The + * first-class ACP `args=['--login']` path enters the same flow via + * `kimi acp --login`. + */ + +import type { Command } from 'commander'; + +import { runLoginFlow } from './login-flow'; + +export function registerLoginCommand(parent: Command): void { + parent + .command('login') + .description('Authenticate with Kimi Code CLI via the device-code flow.') + .action(async () => { + await runLoginFlow(); + }); +} diff --git a/apps/kimi-code/src/tui/commands/info.ts b/apps/kimi-code/src/tui/commands/info.ts index a5dd47959..f49b8d22f 100644 --- a/apps/kimi-code/src/tui/commands/info.ts +++ b/apps/kimi-code/src/tui/commands/info.ts @@ -18,7 +18,7 @@ import { } from '../constant/feedback'; import { isManagedUsageProvider } from '../constant/kimi-tui'; import { formatErrorMessage } from '../utils/event-payload'; -import { openUrl } from '../utils/open-url'; +import { openUrl } from '#/utils/open-url'; import { promptFeedbackInput } from './prompts'; import type { SlashCommandHost } from './dispatch'; diff --git a/apps/kimi-code/src/tui/controllers/session-event-handler.ts b/apps/kimi-code/src/tui/controllers/session-event-handler.ts index 61770d816..36753a6cb 100644 --- a/apps/kimi-code/src/tui/controllers/session-event-handler.ts +++ b/apps/kimi-code/src/tui/controllers/session-event-handler.ts @@ -57,7 +57,7 @@ import { type McpServerStatusSnapshot, selectMcpStartupStatusRows, } from '../utils/mcp-server-status'; -import { openUrl } from '../utils/open-url'; +import { openUrl } from '#/utils/open-url'; import { setProcessTitle } from '../utils/proctitle'; import { errorReportHintLine } from '../constant/feedback'; import { formatStepDebugTiming } from '#/utils/usage/debug-timing'; diff --git a/apps/kimi-code/src/tui/kimi-tui.ts b/apps/kimi-code/src/tui/kimi-tui.ts index ec026115a..6c6bec8e3 100644 --- a/apps/kimi-code/src/tui/kimi-tui.ts +++ b/apps/kimi-code/src/tui/kimi-tui.ts @@ -116,7 +116,7 @@ import { formatErrorMessage } from './utils/event-payload'; import { ImageAttachmentStore, type ImageAttachment } from './utils/image-attachment-store'; import { extractMediaAttachments } from './utils/image-placeholder'; import { hasPatchChanges } from './utils/object-patch'; -import { openUrl } from './utils/open-url'; +import { openUrl } from '#/utils/open-url'; import { setProcessTitle } from './utils/proctitle'; import { sessionRowsForPicker } from './utils/session-picker-rows'; import { installTerminalFocusTracking } from './utils/terminal-focus'; diff --git a/apps/kimi-code/src/tui/utils/open-url.ts b/apps/kimi-code/src/utils/open-url.ts similarity index 100% rename from apps/kimi-code/src/tui/utils/open-url.ts rename to apps/kimi-code/src/utils/open-url.ts diff --git a/apps/kimi-code/test/cli/acp.test.ts b/apps/kimi-code/test/cli/acp.test.ts new file mode 100644 index 000000000..ceff4a3d9 --- /dev/null +++ b/apps/kimi-code/test/cli/acp.test.ts @@ -0,0 +1,165 @@ +/** + * `kimi acp` + * + * Verifies that the ACP sub-command is registered on the program and + * that the action wires the harness into `@moonshot-ai/acp-adapter`'s + * `runAcpServer` (the real server is stubbed so the test doesn't + * actually take over stdio). + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@moonshot-ai/acp-adapter', () => ({ + runAcpServer: vi.fn(async () => undefined), +})); + +import { runAcpServer } from '@moonshot-ai/acp-adapter'; + +import { registerAcpCommand } from '#/cli/sub/acp'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('kimi acp', () => { + let exitSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + vi.mocked(runAcpServer).mockClear(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new ExitCalled(code); + }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('registers an `acp` subcommand on the program', () => { + const program = new Command('kimi'); + registerAcpCommand(program); + + const acp = program.commands.find((c) => c.name() === 'acp'); + expect(acp).toBeDefined(); + expect(acp?.description()).toMatch(/Agent Client Protocol/); + }); + + it('invokes runAcpServer with a constructed harness and exits 0 on success', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + expect(runAcpServer).toHaveBeenCalledTimes(1); + const harnessArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(harnessArg).toBeDefined(); + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; + expect(optsArg).toEqual( + expect.objectContaining({ + agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) }, + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('forwards KIMI_CODE_HOME to terminalAuthEnv when set', async () => { + const previous = process.env['KIMI_CODE_HOME']; + process.env['KIMI_CODE_HOME'] = '/tmp/kimi-debug'; + try { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1]; + expect(optsArg).toEqual( + expect.objectContaining({ + terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/kimi-debug' }, + }), + ); + } finally { + if (previous === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = previous; + } + } + }); + + it('omits terminalAuthEnv when KIMI_CODE_HOME is unset', async () => { + const previous = process.env['KIMI_CODE_HOME']; + delete process.env['KIMI_CODE_HOME']; + try { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { + terminalAuthEnv?: unknown; + }; + expect(optsArg.terminalAuthEnv).toBeUndefined(); + } finally { + if (previous !== undefined) { + process.env['KIMI_CODE_HOME'] = previous; + } + } + }); + + it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[1] as { + terminalAuthLegacyCommand?: string; + }; + // process.argv[1] points at the test runner entry — non-empty + // absolute-ish path, exactly what we want forwarded. + expect(typeof optsArg.terminalAuthLegacyCommand).toBe('string'); + expect((optsArg.terminalAuthLegacyCommand ?? '').length).toBeGreaterThan(0); + expect(optsArg.terminalAuthLegacyCommand).toBe(process.argv[1]); + }); + + it('exits without starting the ACP server when --login is passed', async () => { + // Stub the harness module so runLoginFlow doesn't hit a real OAuth + // endpoint: harness.auth.login resolves immediately and triggers exit 0. + // `importOriginal` preserves the other named exports (`ErrorCodes`, etc.) + // that constant/app.ts depends on at module load. + const loginStub = vi.fn(async () => ({ providerName: 'kimi-code' })); + vi.doMock(import('@moonshot-ai/kimi-code-sdk'), async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createKimiHarness: () => + ({ + auth: { login: loginStub }, + }) as unknown as ReturnType, + }; + }); + vi.resetModules(); + const { registerAcpCommand: freshRegister } = await import('#/cli/sub/acp'); + try { + const program = new Command('kimi').exitOverride(); + freshRegister(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp', '--login'])).rejects.toThrow( + ExitCalled, + ); + + expect(loginStub).toHaveBeenCalledTimes(1); + expect(runAcpServer).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + vi.doUnmock('@moonshot-ai/kimi-code-sdk'); + vi.resetModules(); + } + }); +}); diff --git a/apps/kimi-code/test/cli/login.test.ts b/apps/kimi-code/test/cli/login.test.ts new file mode 100644 index 000000000..6e382c499 --- /dev/null +++ b/apps/kimi-code/test/cli/login.test.ts @@ -0,0 +1,130 @@ +/** + * `kimi login` + * + * Verifies that the login sub-command is registered on the program and + * that the action drives `harness.auth.login`, prints the device code to + * stderr, and exits with the right code on success / failure. + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const mockLogin = vi.fn(); + +vi.mock('@moonshot-ai/kimi-code-sdk', async () => { + const actual = await vi.importActual( + '@moonshot-ai/kimi-code-sdk', + ); + return { + ...actual, + createKimiHarness: vi.fn(() => ({ + auth: { + login: mockLogin, + }, + })), + }; +}); + +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { registerLoginCommand } from '#/cli/sub/login'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('kimi login', () => { + let exitSpy: ReturnType; + let stderrSpy: ReturnType; + + beforeEach(() => { + mockLogin.mockReset(); + vi.mocked(createKimiHarness).mockClear(); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number | string | null) => { + throw new ExitCalled(code); + }) as never); + stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + exitSpy.mockRestore(); + stderrSpy.mockRestore(); + }); + + it('registers a `login` subcommand on the program', () => { + const program = new Command('kimi'); + registerLoginCommand(program); + + const login = program.commands.find((c) => c.name() === 'login'); + expect(login).toBeDefined(); + expect(login?.description()).toMatch(/[Aa]uthenticat/); + }); + + it('invokes harness.auth.login and exits 0 on success', async () => { + mockLogin.mockResolvedValue({ providerName: 'kimi-code', ok: true }); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + expect(mockLogin).toHaveBeenCalledTimes(1); + expect(mockLogin).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ + signal: expect.any(AbortSignal), + onDeviceCode: expect.any(Function), + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('prints device code prompt to stderr', async () => { + mockLogin.mockImplementation( + async ( + _providerName: string | undefined, + options: { + onDeviceCode?: (data: { + userCode: string; + verificationUri: string; + verificationUriComplete: string; + expiresIn: number | null; + }) => void | Promise; + }, + ) => { + await options.onDeviceCode?.({ + userCode: 'ABCD-EFGH', + verificationUri: 'https://kimi.com/v', + verificationUriComplete: 'https://kimi.com/v?code=ABCD-EFGH', + expiresIn: 600, + }); + return { providerName: 'kimi-code', ok: true }; + }, + ); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('ABCD-EFGH'))).toBe(true); + expect(writtenChunks.some((chunk: string) => chunk.includes('https://kimi.com/v'))).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('exits 1 when auth.login throws', async () => { + mockLogin.mockRejectedValue(new Error('boom')); + + const program = new Command('kimi').exitOverride(); + registerLoginCommand(program); + + await expect(program.parseAsync(['node', 'kimi', 'login'])).rejects.toThrow(ExitCalled); + + const writtenChunks = stderrSpy.mock.calls.map((call: unknown[]) => String(call[0])); + expect(writtenChunks.some((chunk: string) => chunk.includes('boom'))).toBe(true); + expect(exitSpy).toHaveBeenCalledWith(1); + }); +}); diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index 185649f09..3766e0037 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -285,7 +285,7 @@ describe('CLI options parsing', () => { const commandNames: string[] = program.commands .filter((command) => !command.name().startsWith('__')) .map((command) => command.name()); - expect(commandNames).toEqual(['export', 'provider', 'migrate', 'upgrade']); + expect(commandNames).toEqual(['export', 'provider', 'acp', 'login', 'migrate', 'upgrade']); }); }); diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index b04ae5ac1..ca28cb119 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -47,6 +47,7 @@ export default withMermaid(defineConfig({ { text: '常见使用案例', link: '/zh/guides/use-cases' }, { text: '交互与输入', link: '/zh/guides/interaction' }, { text: '会话与上下文', link: '/zh/guides/sessions' }, + { text: '在 IDE 中使用', link: '/zh/guides/ides' }, ], }, ], @@ -79,6 +80,7 @@ export default withMermaid(defineConfig({ text: '参考手册', items: [ { text: 'kimi 命令', link: '/zh/reference/kimi-command' }, + { text: 'kimi acp 子命令', link: '/zh/reference/kimi-acp' }, { text: '内置工具', link: '/zh/reference/tools' }, { text: '斜杠命令', link: '/zh/reference/slash-commands' }, { text: '键盘快捷键', link: '/zh/reference/keyboard' }, @@ -121,6 +123,7 @@ export default withMermaid(defineConfig({ { text: 'Common Use Cases', link: '/en/guides/use-cases' }, { text: 'Interaction and Input', link: '/en/guides/interaction' }, { text: 'Sessions and Context', link: '/en/guides/sessions' }, + { text: 'Using in IDEs', link: '/en/guides/ides' }, ], }, ], @@ -153,6 +156,7 @@ export default withMermaid(defineConfig({ text: 'Reference', items: [ { text: 'kimi Command', link: '/en/reference/kimi-command' }, + { text: 'kimi acp Subcommand', link: '/en/reference/kimi-acp' }, { text: 'Built-in Tools', link: '/en/reference/tools' }, { text: 'Slash Commands', link: '/en/reference/slash-commands' }, { text: 'Keyboard Shortcuts', link: '/en/reference/keyboard' }, diff --git a/docs/en/guides/ides.md b/docs/en/guides/ides.md new file mode 100644 index 000000000..5162116f7 --- /dev/null +++ b/docs/en/guides/ides.md @@ -0,0 +1,69 @@ +# Using in IDEs + +Kimi Code CLI integrates with IDEs through the [Agent Client Protocol (ACP)](https://agentclientprotocol.com/), so you can use AI-assisted coding directly inside your editor. + +## Prerequisites + +Before configuring your IDE, make sure Kimi Code CLI is installed and signed in. + +The ACP adapter exposes a `kimi acp` subcommand. The IDE launches it as a child process and speaks JSON-RPC on its stdio. Every session reuses the CLI's existing auth state — no separate login is required from inside the IDE. + +::: tip Path note +On macOS, child processes spawned by an IDE's GUI typically do **not** inherit the `PATH` from your terminal shell. If `kimi` lives anywhere other than the usual `/usr/local/bin`-style directories, configure the IDE with an **absolute path**. Run `which kimi` in a terminal to find the current location. +::: + +## Using in Zed + +[Zed](https://zed.dev/) is a modern editor with native ACP support. + +Add the following to Zed's configuration file `~/.config/zed/settings.json`: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "type": "custom", + "command": "kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +Field reference: + +- `type`: fixed value `"custom"`. +- `command`: path to the Kimi Code CLI executable. If `kimi` is not on `PATH`, use a full absolute path (e.g. `/Users/you/.local/bin/kimi`). +- `args`: startup arguments. The `acp` subcommand switches Kimi Code into ACP mode. +- `env`: extra environment variables. Usually empty — Zed injects a sensible default environment. + +After saving, opening a new chat in Zed's Agent panel will spawn an ACP-mode `kimi` subprocess using your configuration. Any MCP servers declared inside Zed's `agent_servers` block are forwarded to Kimi Code via the ACP protocol. + +## Using in JetBrains IDEs + +JetBrains IDEs (IntelliJ IDEA, PyCharm, WebStorm, …) support ACP through the AI Chat plugin. + +If you do not have a JetBrains AI subscription, enable `llm.enable.mock.response` in the Registry so you can still reach the AI Chat panel when only ACP is needed. Press Shift twice to search for "Registry" and open it. + +From the AI Chat panel menu, click "Configure ACP agents" and add the following: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "command": "~/.local/bin/kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +JetBrains is strict about the `command` field — always use an **absolute path**. `which kimi` in a terminal will print the right value. After saving, `Kimi Code CLI` will appear in the AI Chat Agent selector. + +## Troubleshooting + +- **The session terminates immediately / the IDE shows "agent exited"**: usually a bad `command` path or the CLI is not signed in. Run `kimi acp` in a terminal: if it blocks waiting for stdin, the CLI itself is healthy and the issue is the IDE config; if it errors immediately, follow the message (most commonly `/login` is missing). +- **The IDE shows "auth required"**: there's no usable auth token. Quit the IDE, run `kimi` in a terminal to sign in, then relaunch the IDE. +- **MCP tools don't show up**: see the capability matrix in [`kimi acp`](../reference/kimi-acp.md) and confirm the MCP transport you configured is supported. The current ACP adapter forwards `http` and `stdio` transports; `sse` and `acp` MCP entries are silently dropped and a warn line is written to the diagnostic log. diff --git a/docs/en/reference/kimi-acp.md b/docs/en/reference/kimi-acp.md new file mode 100644 index 000000000..38572d0d2 --- /dev/null +++ b/docs/en/reference/kimi-acp.md @@ -0,0 +1,151 @@ +# `kimi acp` Subcommand + +`kimi acp` switches Kimi Code CLI into **ACP (Agent Client Protocol)** mode: it speaks JSON-RPC on stdio to an ACP client (Zed, JetBrains AI Chat, etc.) so an IDE can drive kimi's sessions, prompts, and tool calls directly. + +```sh +kimi acp +``` + +When launched, the command prints no banner — it immediately waits for an `initialize` request on stdin. Logs go to stderr (and to the diagnostic log under `~/.kimi-code/logs/`), keeping the ACP channel clean. + +::: tip Who calls this? +You typically never run `kimi acp` by hand — it's the subprocess entry point an IDE will spawn. For the IDE-side configuration, see [Using in IDEs](../guides/ides.md). +::: + +## Capability Matrix + +The ACP adapter advertises the following capabilities in its `initialize` response. IDEs can use these to enable or disable UI affordances. + +| Capability | Value | Notes | +| --- | --- | --- | +| `promptCapabilities.image` | `true` | Accepts ACP `image` content blocks (base64 + mimeType). | +| `promptCapabilities.audio` | `false` | Audio prompts are not supported. | +| `promptCapabilities.embeddedContext` | `false` | Embedded resource prompts are not advertised; `resource`/`resource_link` are still accepted through the text channel. | +| `mcpCapabilities.http` | `true` | HTTP MCP servers from the IDE are forwarded. | +| `mcpCapabilities.sse` | `false` | SSE MCP servers are dropped (with a warn log). | +| `loadSession` | `true` | `session/load` is supported; history is replayed via `session/update` on resume. | +| `sessionCapabilities.list` | `{}` | `session/list` enumerates the user's sessions. | + +## ACP method coverage + +The spec splits methods into a **stable** surface and a still-evolving **unstable** surface (the `unstable_*`-prefixed handlers on `@agentclientprotocol/sdk@0.23.0`). The two are tracked separately because they have very different stability guarantees — the stable surface is what any production ACP client will exercise; the unstable surface covers experimental extensions (inline-edit predictions, document buffer sync, provider management, elicitation, etc.). + +**Summary: 10/12 stable agent-side methods (83%) + 4/9 stable client reverse-RPCs (44%); on the unstable surface only `session/set_model` is wired (1/19).** Every method needed for a normal agent flow (initialize → auth → new/load/resume → prompt → cancel + file I/O + tool approval) is implemented. + +### Stable agent-side — IDE → agent (10 / 12) + +| Method | Status | Notes | +| --- | --- | --- | +| `initialize` | yes | Version negotiation; returns `agentInfo: { name: 'Kimi Code CLI', version }`, capability matrix, `authMethods` | +| `authenticate` | yes | Validates `method_id='login'`; missing token → `authRequired (-32000)`, unknown id → `invalidParams (-32602)` | +| `session/new` | yes | Accepts `cwd` / `mcpServers`; returns `configOptions[]` | +| `session/load` | yes | Rehydrates on-disk session and replays history as `session/update` notifications | +| `session/resume` | yes | Lighter-weight sibling of `session/load`; skips history replay (spec G4) | +| `session/prompt` | yes | Accepts `text` / `image` / `resource` / `resource_link` content blocks; streams `agent_message_chunk` | +| `session/cancel` | yes | Interrupts the current turn | +| `session/list` | yes | Enumerates on-disk sessions (advertised via `sessionCapabilities.list = {}`) | +| `session/set_mode` | yes | Compatibility path; funnels into the same dispatcher as `set_config_option({configId:'mode'})` | +| `session/set_config_option` | yes | Unified model / thinking / mode picker dispatch | +| `session/close` | no | | +| `logout` | no | | + +### Stable client-side reverse-RPC — agent → IDE (4 / 9) + +| Method | Status | Notes | +| --- | --- | --- | +| `session/update` | yes | Streams `agent_message_chunk` / `tool_call*` / `plan` / `config_option_update` / `available_commands_update` | +| `session/request_permission` | yes | Tool approvals and question elicitation share this channel | +| `fs/read_text_file` | yes | kaos file reads route to the client (advertised by `fsCapabilities`) | +| `fs/write_text_file` | yes | kaos file writes route to the client | +| `terminal/create` · `output` · `release` · `kill` · `wait_for_exit` | no | Terminal reverse-RPC not yet wired; shell commands run locally | + +### Unstable surface (1 / 19) + +| Method | Status | Notes | +| --- | --- | --- | +| `session/set_model` | yes | Compatibility path; equivalent to `set_config_option({configId:'model'})`. Also accepts the legacy `',thinking'` merged form, which is split into a bare-model `setModel` plus an implicit `setThinking('high')` | +| `session/delete` · `session/fork` | no | Session lifecycle extensions | +| `document/didOpen` · `didChange` · `didClose` · `didFocus` · `didSave` | no | Editor buffer sync | +| `nes/start` · `suggest` · `accept` · `reject` · `close` | no | Inline-edit predictions | +| `providers/list` · `set` · `disable` | no | Built-in provider management (use `kimi provider` instead) | +| `elicitation/create` · `elicitation/complete` | no | Currently overlapped by `session/request_permission` | + +(`mcp/connect`, `mcp/disconnect`, `mcp/message` are declared in the SDK enum but not yet routed by the SDK dispatcher itself, so they sit outside the coverage denominator.) + +Any method not listed above returns `methodNotFound`. + +## Session configOptions + +Starting in Phase 14, `session/new` and `session/load` no longer return a dedicated `modes` field. Instead, both pickers ride on the ACP spec's generic `configOptions: SessionConfigOption[]` surface. Phase 15 split thinking out of the model id into its own axis, and Phase 16 reshaped that axis from `SessionConfigBoolean` to a 2-entry `select` (`off` / `on`) so Zed renders it — Zed's chip strip currently only knows how to draw `type: 'select'`. The advertisement now ships up to three options: + +- `id: 'model'` (`type: 'select'`, `category: 'model'`) — one row per configured model alias. **No more `,thinking` variant rows** — thinking has moved to a separate axis below. +- `id: 'thinking'` (`type: 'select'`, `category: 'thought_level'`, options `[{value:'off'},{value:'on'}]`) — appears **only when the currently-selected model's catalog entry advertises `thinkingSupported`**. Switching to a non-thinking model causes the next `config_option_update` to omit this option entirely; the client should re-render the picker strip accordingly. +- `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the locked four-mode taxonomy from PLAN D9: `default` / `plan` / `auto` / `yolo`. + +Example `configOptions` payload (current model supports thinking): + +```json +{ + "configOptions": [ + { + "type": "select", + "id": "model", + "name": "Model", + "category": "model", + "currentValue": "kimi-coder", + "options": [ + { "value": "kimi-coder", "name": "Kimi Coder" }, + { "value": "kimi-v2", "name": "Kimi v2" } + ] + }, + { + "type": "select", + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "currentValue": "off", + "options": [ + { "value": "off", "name": "Thinking Off" }, + { "value": "on", "name": "Thinking On" } + ] + }, + { + "type": "select", + "id": "mode", + "name": "Mode", + "category": "mode", + "currentValue": "default", + "options": [ + { "value": "default", "name": "Default", "description": "Manual approvals; tools execute normally." }, + { "value": "plan", "name": "Plan", "description": "Read-only planning; no tool execution." }, + { "value": "auto", "name": "Auto", "description": "Auto-approve safe operations." }, + { "value": "yolo", "name": "YOLO", "description": "Auto-approve everything." } + ] + } + ] +} +``` + +**Switching:** clients should prefer the generic `session/set_config_option({ sessionId, configId, type, value })` path. For compatibility, `session/set_session_mode` and `session/unstable_set_session_model` still work — the entry points all funnel to the same execution path inside the adapter. Notes on each `configId`: + +- `'model'` accepts the bare alias id (e.g. `'kimi-coder'`) or the legacy merged form `'kimi-coder,thinking'` — the latter is split into a bare-model `setModel` plus an implicit thinking-on call so older clients keep working. +- `'thinking'` accepts the strings `'on'` / `'off'` and maps to `Session.setThinking('high')` (`'on'`) or `Session.setThinking('off')` (`'off'`). The granularity of `'low' / 'medium' / 'xhigh' / 'max'` is intentionally hidden behind the binary wire — Phase 16 uses a 2-entry `select` instead of `SessionConfigBoolean` for Zed UI compatibility only. +- `'mode'` accepts one of `'default' / 'plan' / 'auto' / 'yolo'` (PLAN D9). + +**Change notifications:** every model, thinking, or mode change pushes `sessionUpdate: 'config_option_update'` with the full `configOptions` snapshot. Phase 12's `current_mode_update` has been retired and is no longer emitted. + +## MCP Forwarding + +When an ACP client provides `mcpServers` in `session/new` or `session/load`, the adapter converts them as follows: + +- `http` → kimi `transport: 'http'` (headers projected to `Record`). +- `stdio` → kimi `transport: 'stdio'` (env projected the same way). +- `sse` / `acp` → dropped, with a warn log line, so unsupported transports never silently fall through. + +## When to Use `kimi acp` + +- **Direct IDE integration**: see [Using in IDEs](../guides/ides.md). +- **Custom ACP client**: connect `@agentclientprotocol/sdk`'s `ClientSideConnection` to `kimi acp`'s stdio. A single client implementation can then drive kimi, Claude Code, or any other ACP agent. +- **Local integration testing**: this repository's `packages/acp-adapter` ships an in-memory pipe end-to-end test that exercises `initialize` → `session/new` → `session/prompt` → `end_turn`, which can serve as a reference implementation. + +For normal CLI interactions, keep using `kimi` (without the `acp` subcommand) to launch the TUI. diff --git a/docs/en/reference/kimi-command.md b/docs/en/reference/kimi-command.md index d043775d6..ac12ea538 100644 --- a/docs/en/reference/kimi-command.md +++ b/docs/en/reference/kimi-command.md @@ -122,6 +122,16 @@ In `stream-json` mode, each stdout line is one JSON object. Ordinary replies are ## Subcommands +### `kimi login` + +Authenticate Kimi Code CLI with the Kimi Code OAuth provider via the RFC 8628 device-code flow, without entering the TUI. The command opens a device authorization request, prints the verification URL and user code to stderr, then polls until the browser flow completes. The resulting token is written to the same on-disk location used by the in-TUI `/login` command, so the next `kimi` invocation picks it up automatically. + +```sh +kimi login +``` + +This subcommand takes no flags. It is the entry point that ACP-compatible editors invoke via the `terminal-auth` authentication method advertised by `kimi acp`. Press Ctrl-C at any time to cancel the pending device-code poll; the command exits with status `1` on cancellation or failure, `0` on success. + ### `kimi export` Bundle a session into a ZIP file for sharing, archival, or bug reports. The exported archive contains all files under the session directory, such as context records, state files, and the session diagnostic log if that session has already produced `logs/kimi-code.log`. diff --git a/docs/zh/guides/ides.md b/docs/zh/guides/ides.md new file mode 100644 index 000000000..182e6960b --- /dev/null +++ b/docs/zh/guides/ides.md @@ -0,0 +1,69 @@ +# 在 IDE 中使用 + +Kimi Code CLI 支持通过 [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) 集成到 IDE 中,让你在编辑器内直接使用 AI 辅助编程。 + +## 前置准备 + +在配置 IDE 之前,请确保已安装 Kimi Code CLI 并完成登录配置。 + +ACP 适配层暴露子命令 `kimi acp`,IDE 通过子进程方式启动它,并在标准输入/输出上跑 JSON-RPC。每次 IDE 创建会话时,CLI 会复用它的鉴权状态——不需要重复登录。 + +::: tip 路径提示 +macOS 下从 IDE GUI 启动的子进程通常**不会**继承终端 shell 的 `PATH`,所以如果 `kimi` 不在 `/usr/local/bin` 这类系统目录里,IDE 配置中要使用绝对路径。终端里运行 `which kimi` 可以查到当前生效的路径。 +::: + +## 在 Zed 中使用 + +[Zed](https://zed.dev/) 是一个原生支持 ACP 的现代编辑器。 + +在 Zed 的配置文件 `~/.config/zed/settings.json` 中添加: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "type": "custom", + "command": "kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +配置说明: + +- `type`:固定值 `"custom"` +- `command`:Kimi Code CLI 的可执行路径。如果 `kimi` 不在 PATH 中,请使用完整路径(例如 `/Users/you/.local/bin/kimi`)。 +- `args`:启动参数。`acp` 子命令切换到 ACP 模式。 +- `env`:附加环境变量,通常留空即可。Zed 会自动注入一份默认环境。 + +保存配置后,在 Zed 的 Agent 面板里新建一次对话,就会以你刚才配置的 `Kimi Code CLI` 启动一个 ACP 子进程。Zed 在 `agent_servers` 这层声明的 MCP 服务也会通过 ACP 协议转发到 kimi 这一侧。 + +## 在 JetBrains IDE 中使用 + +JetBrains 系列 IDE(IntelliJ IDEA、PyCharm、WebStorm 等)通过 AI 聊天插件支持 ACP。 + +如果没有 JetBrains AI 订阅,可以在注册表中启用 `llm.enable.mock.response`,便于在仅使用 ACP 的场景里访问 AI 聊天面板。连按两次 Shift 搜索 "Registry / 注册表" 即可打开。 + +在 AI 聊天面板的菜单中点击 "Configure ACP agents",添加以下配置: + +```json +{ + "agent_servers": { + "Kimi Code CLI": { + "command": "~/.local/bin/kimi", + "args": ["acp"], + "env": {} + } + } +} +``` + +JetBrains 这一侧对 `command` 字段处理较严格——务必填写**绝对路径**,可以在终端执行 `which kimi` 拿到。保存后,AI 聊天的 Agent 选择器里就会出现 `Kimi Code CLI`。 + +## 故障排查 + +- **会话立刻被中断 / IDE 提示 "agent exited"**:通常是 `command` 路径不对或 kimi 没登录。先在终端跑一次 `kimi acp` 验证:如果阻塞等待标准输入则说明 CLI 本身没问题,问题在 IDE 配置;如果立刻报错则按报错提示处理(多数是没 `/login`)。 +- **IDE 显示 "auth required"**:表示 CLI 没有可用的鉴权令牌。退出 IDE,在终端执行 `kimi` 完成登录后再启动 IDE 即可。 +- **MCP 工具看不到**:参考 [`kimi acp`](../reference/kimi-acp.md) 中的能力表确认 IDE 配的 MCP 传输类型是否被支持。当前 Kimi Code CLI 的 ACP 适配层支持 `http`、`stdio` 两种传输方式,`sse` 与 `acp` 类型会被静默丢弃并在日志中给出 warn。 diff --git a/docs/zh/reference/kimi-acp.md b/docs/zh/reference/kimi-acp.md new file mode 100644 index 000000000..42d493f81 --- /dev/null +++ b/docs/zh/reference/kimi-acp.md @@ -0,0 +1,151 @@ +# `kimi acp` 子命令 + +`kimi acp` 把 Kimi Code CLI 切换到 **ACP (Agent Client Protocol)** 模式:在标准输入/输出上以 JSON-RPC 形式与 ACP 客户端(如 Zed、JetBrains AI Chat 等)对话,让 IDE 直接驱动 kimi 的会话、prompt 与工具调用。 + +```sh +kimi acp +``` + +启动后命令不会打印任何 banner,立刻等待 ACP 客户端在 stdin 上发出 `initialize` 请求。日志会写到标准错误(以及 `~/.kimi-code/logs/` 下的诊断日志),所以 ACP 通道本身保持干净。 + +::: tip 谁会调用它? +你通常不需要手动跑 `kimi acp`——这个命令是给 IDE 的子进程入口准备的。IDE 端的配置见 [在 IDE 中使用](../guides/ides.md)。 +::: + +## 能力矩阵 + +下表列出当前 ACP 适配层声明的能力。`agentCapabilities` 字段在 `initialize` 响应里完整返回,IDE 端可据此调整 UI。 + +| 能力 | 取值 | 说明 | +| --- | --- | --- | +| `promptCapabilities.image` | `true` | 支持 ACP `image` 内容块(base64 + mimeType)。 | +| `promptCapabilities.audio` | `false` | 暂不支持音频 prompt。 | +| `promptCapabilities.embeddedContext` | `false` | 暂不支持嵌入式资源 prompt(`resource`/`resource_link` 走文本通道)。 | +| `mcpCapabilities.http` | `true` | 转发 IDE 配置的 HTTP MCP 服务。 | +| `mcpCapabilities.sse` | `false` | 不支持 SSE MCP 服务,相关条目会被丢弃并写 warn 日志。 | +| `loadSession` | `true` | 支持 `session/load` 续接已有会话,加载时会同步回放历史。 | +| `sessionCapabilities.list` | `{}` | 支持 `session/list` 枚举当前用户的会话。 | + +## ACP 方法覆盖 + +规范把方法分为**稳定**面和仍在演化的**不稳定**面(`@agentclientprotocol/sdk@0.23.0` 中以 `unstable_*` 前缀挂载的 handler)。两部分稳定性保证完全不同——稳定面是任何生产 ACP 客户端都会用到的方法,不稳定面覆盖实验性扩展(inline-edit 预测、document 缓冲区同步、provider 管理、elicitation 等),因此分开追踪。 + +**概览:稳定面 agent-side 实现 10/12(83%)+ client reverse-RPC 实现 4/9(44%);不稳定面只接入了 `session/set_model`(1/19)。** 任何正常 agent 流程所需的方法(initialize → auth → new/load/resume → prompt → cancel + 文件 I/O + 工具审批)都已实现。 + +### 稳定面 agent-side — IDE → agent(10 / 12) + +| 方法 | 状态 | 说明 | +| --- | --- | --- | +| `initialize` | 是 | 版本协商;返回 `agentInfo: { name: 'Kimi Code CLI', version }`、能力矩阵、`authMethods` | +| `authenticate` | 是 | 校验 `method_id='login'`;token 缺失返回 `authRequired (-32000)`,未知 id 返回 `invalidParams (-32602)` | +| `session/new` | 是 | 接受 `cwd` / `mcpServers`,返回 `configOptions[]` | +| `session/load` | 是 | 恢复磁盘会话并把历史以 `session/update` 同步回放 | +| `session/resume` | 是 | `session/load` 的轻量兄弟方法,跳过历史回放(spec G4) | +| `session/prompt` | 是 | 接受 `text` / `image` / `resource` / `resource_link` 内容块,流式输出 `agent_message_chunk` | +| `session/cancel` | 是 | 中断当前 turn | +| `session/list` | 是 | 枚举磁盘会话(通过 `sessionCapabilities.list = {}` 公告) | +| `session/set_mode` | 是 | 兼容路径,与 `set_config_option({configId:'mode'})` 走同一 dispatcher | +| `session/set_config_option` | 是 | 统一的 model / thinking / mode picker 分发 | +| `session/close` | 否 | | +| `logout` | 否 | | + +### 稳定面 client-side reverse-RPC — agent → IDE(4 / 9) + +| 方法 | 状态 | 说明 | +| --- | --- | --- | +| `session/update` | 是 | 流式推送 `agent_message_chunk` / `tool_call*` / `plan` / `config_option_update` / `available_commands_update` | +| `session/request_permission` | 是 | 工具审批和问题 elicitation 共用此通道 | +| `fs/read_text_file` | 是 | kaos 层文件读取路由到客户端(通过 `fsCapabilities` 公告) | +| `fs/write_text_file` | 是 | kaos 层文件写入路由到客户端 | +| `terminal/create` · `output` · `release` · `kill` · `wait_for_exit` | 否 | 终端 reverse-RPC 未接,shell 命令走本地执行 | + +### 不稳定面(1 / 19) + +| 方法 | 状态 | 说明 | +| --- | --- | --- | +| `session/set_model` | 是 | 兼容路径,等价于 `set_config_option({configId:'model'})`。也接受老的 `',thinking'` 合并形式,会被拆为裸 model 的 `setModel` 加一次隐式的 `setThinking('high')` | +| `session/delete` · `session/fork` | 否 | 会话生命周期扩展 | +| `document/didOpen` · `didChange` · `didClose` · `didFocus` · `didSave` | 否 | 编辑器缓冲区同步 | +| `nes/start` · `suggest` · `accept` · `reject` · `close` | 否 | inline-edit 预测 | +| `providers/list` · `set` · `disable` | 否 | 内置 provider 管理(请用 `kimi provider`) | +| `elicitation/create` · `elicitation/complete` | 否 | 当前由 `session/request_permission` 覆盖 | + +(`mcp/connect`、`mcp/disconnect`、`mcp/message` 虽然在 SDK enum 中声明,但 SDK 本身的 dispatcher 尚未路由,故不计入分母。) + +上述未列出的方法一律返回 `methodNotFound`。 + +## 会话 configOptions + +Phase 14 起,`session/new` 和 `session/load` 不再返回独立的 `modes` 字段,而是把所有 picker 都收敛到 ACP 规范的通用 `configOptions: SessionConfigOption[]` 数组下。Phase 15 进一步把 thinking 从 model id 拆出来变成独立的轴,Phase 16 把 thinking 从 `SessionConfigBoolean` 改成 2 项 `select`(`off` / `on`),因为 Zed 当前的 chip 渲染器只识别 `select`;目前公告**最多三个**选项: + +- `id: 'model'`(`type: 'select'`、`category: 'model'`)— 列出 harness 配置的所有 model alias,**不再展开 `,thinking` 变体行**;thinking 走下面独立的轴。 +- `id: 'thinking'`(`type: 'select'`、`category: 'thought_level'`,options 为 `[{value:'off'},{value:'on'}]`)— **仅当当前选中的 model 在 catalog 里标了 `thinkingSupported` 时才出现**;切到不支持的 model 后下次 `config_option_update` 会直接省掉这一条,客户端按 spec 的 "configOptions 集合可在不同更新之间增减" 重绘 picker 即可。 +- `id: 'mode'`(`type: 'select'`、`category: 'mode'`)— 锁定的四模式分类法 PLAN D9:`default` / `plan` / `auto` / `yolo`。 + +`configOptions` 的样例(当前 model 支持 thinking): + +```json +{ + "configOptions": [ + { + "type": "select", + "id": "model", + "name": "Model", + "category": "model", + "currentValue": "kimi-coder", + "options": [ + { "value": "kimi-coder", "name": "Kimi Coder" }, + { "value": "kimi-v2", "name": "Kimi v2" } + ] + }, + { + "type": "select", + "id": "thinking", + "name": "Thinking", + "category": "thought_level", + "currentValue": "off", + "options": [ + { "value": "off", "name": "Thinking Off" }, + { "value": "on", "name": "Thinking On" } + ] + }, + { + "type": "select", + "id": "mode", + "name": "Mode", + "category": "mode", + "currentValue": "default", + "options": [ + { "value": "default", "name": "Default", "description": "Manual approvals; tools execute normally." }, + { "value": "plan", "name": "Plan", "description": "Read-only planning; no tool execution." }, + { "value": "auto", "name": "Auto", "description": "Auto-approve safe operations." }, + { "value": "yolo", "name": "YOLO", "description": "Auto-approve everything." } + ] + } + ] +} +``` + +**切换:** 客户端推荐统一走 `session/set_config_option({ sessionId, configId, type, value })`。为了向后兼容,旧的 `session/set_session_mode` 和 `session/unstable_set_session_model` 仍可用,所有入口在适配层汇聚到同一个执行路径。各 `configId` 的语义: + +- `'model'` 接受裸 alias id(如 `'kimi-coder'`)或旧的合并形式 `'kimi-coder,thinking'`——后者会被拆成裸 model 的 `setModel` 加一次隐式的 thinking-on,老客户端不会断。 +- `'thinking'` 接受字符串 `'on'` / `'off'`,映射到 `Session.setThinking('high')`(`'on'`)或 `Session.setThinking('off')`(`'off'`)。`'low' / 'medium' / 'xhigh' / 'max'` 这种粒度刻意隐藏在适配层背后——ACP 暴露的 thinking 轴是二态的(Phase 16 用 2 项 `select` 而非 `SessionConfigBoolean`,仅为 Zed UI 兼容)。 +- `'mode'` 接受 `'default' / 'plan' / 'auto' / 'yolo'`(PLAN D9)。 + +**变更通知:** model / thinking / mode 任一改变都会推送 `sessionUpdate: 'config_option_update'`,载荷为完整的 `configOptions` 快照。Phase 12 的 `current_mode_update` 已下线,不再发出。 + +## MCP 转发 + +ACP 客户端在 `session/new` 或 `session/load` 中提供 `mcpServers` 时,适配层会做如下转换: + +- `http` → kimi 的 `transport: 'http'` 配置(headers 以 `Record` 形式传入)。 +- `stdio` → kimi 的 `transport: 'stdio'` 配置(env 同样转 Record)。 +- `sse` / `acp` → 丢弃并写一条 warn 日志,避免错误地静默接受不支持的传输。 + +## 何时使用 `kimi acp` + +- **直接接入 IDE**:见 [在 IDE 中使用](../guides/ides.md)。 +- **写 ACP 客户端**:用 `@agentclientprotocol/sdk` 的 `ClientSideConnection` 接到 `kimi acp` 的 stdio,即可用一份代码同时驱动 kimi、Claude Code 等其他 ACP agent。 +- **本地集成测试**:能力矩阵稳定后,本仓库的 `packages/acp-adapter` 也跑了一个 in-memory pipe 的 e2e 测试,可作为参考实现。 + +普通 CLI 交互依旧通过 `kimi`(不带 `acp` 子命令)启动 TUI。 diff --git a/docs/zh/reference/kimi-command.md b/docs/zh/reference/kimi-command.md index b04e359f9..bbc830d56 100644 --- a/docs/zh/reference/kimi-command.md +++ b/docs/zh/reference/kimi-command.md @@ -122,6 +122,16 @@ kimi -p "List changed files" --output-format stream-json ## 子命令 +### `kimi login` + +通过 RFC 8628 device-code 流程登录 Kimi Code OAuth,无需进入 TUI。命令会发起一次 device authorization 请求,将验证地址和用户码打印到 stderr,然后轮询直到浏览器侧完成授权。生成的 token 写入 TUI `/login` 同款的本地位置,下次启动 `kimi` 时会自动加载。 + +```sh +kimi login +``` + +该子命令没有任何 flag。它就是 `kimi acp` 通过 `terminal-auth` 认证方式公告给 ACP 编辑器的入口点。在轮询期间随时按 Ctrl-C 可取消登录;取消或失败时退出码为 `1`,成功为 `0`。 + ### `kimi export` 把一个会话打包成 ZIP 文件,便于分享、归档或者提交问题反馈。导出的压缩包包含会话目录下的所有文件,例如上下文记录、状态文件和会话诊断日志(如果该会话已经产生 `logs/kimi-code.log`)。 diff --git a/flake.nix b/flake.nix index e5ae2a8dc..0007040d6 100644 --- a/flake.nix +++ b/flake.nix @@ -62,6 +62,7 @@ # pnpmConfigHook (dependencies for that workspace won't be fetched). # ------------------------------------------------------------------- workspacePaths = [ + ./packages/acp-adapter ./packages/agent-core ./packages/kaos ./packages/kosong @@ -77,6 +78,7 @@ ]; workspaceNames = [ + "@moonshot-ai/acp-adapter" "@moonshot-ai/agent-core" "@moonshot-ai/kaos" "@moonshot-ai/kosong" @@ -138,7 +140,7 @@ inherit (finalAttrs) pname version src pnpmWorkspaces; inherit pnpm; fetcherVersion = 3; - hash = "sha256-HpRlxlXZoVqAzrdMdSWhLcTRM1DvDvytVbzIGBo8QUo="; + hash = "sha256-/Kgq76JAgi1NygbnYkBNACUl+U9TO5zwF1MaCzk3n9o="; }; nativeBuildInputs = [ diff --git a/packages/acp-adapter/README.md b/packages/acp-adapter/README.md new file mode 100644 index 000000000..51663f2b5 --- /dev/null +++ b/packages/acp-adapter/README.md @@ -0,0 +1,23 @@ +# @moonshot-ai/acp-adapter + +Agent Client Protocol adapter for kimi-code. Exposes the kimi-code agent over the [Agent Client Protocol](https://agentclientprotocol.com/) so that ACP-compatible clients (editors, IDEs, custom front-ends) can drive a kimi-code session over stdio. + +Part of the [Kimi Code](https://github.com/MoonshotAI/kimi-code) monorepo. + +## Minimum usage + +```ts +import { createKimiHarness } from '@moonshot-ai/kimi-code-sdk'; +import { runAcpServer } from '@moonshot-ai/acp-adapter'; + +const harness = await createKimiHarness(); +await runAcpServer(harness); +``` + +`runAcpServer` reads JSON-RPC from `process.stdin`, writes to `process.stdout`, and resolves when the client closes the connection. SIGINT and SIGTERM trigger a graceful drain that calls `harness.close()` before the process exits. + +See `docs/zh/reference/kimi-acp.md` for the full capability matrix (which `Agent` methods are wired, which extensions are stubbed, image / MCP support) and `docs/zh/guides/ides.md` for Zed and JetBrains setup. + +## License + +MIT diff --git a/packages/acp-adapter/package.json b/packages/acp-adapter/package.json new file mode 100644 index 000000000..a5c1c95cd --- /dev/null +++ b/packages/acp-adapter/package.json @@ -0,0 +1,48 @@ +{ + "name": "@moonshot-ai/acp-adapter", + "version": "0.1.0", + "private": true, + "description": "Agent Client Protocol adapter for kimi-code", + "license": "MIT", + "author": "Moonshot AI", + "type": "module", + "imports": { + "#/*": [ + "./src/*.ts", + "./src/*/index.ts" + ] + }, + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "publishConfig": { + "access": "public", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "default": "./dist/index.mjs" + } + }, + "provenance": true + }, + "files": [ + "dist", + "README.md" + ], + "scripts": { + "build": "tsdown", + "test": "vitest run", + "typecheck": "tsc -p tsconfig.json --noEmit", + "clean": "rm -rf dist" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.23.0", + "@moonshot-ai/agent-core": "workspace:^", + "@moonshot-ai/kaos": "workspace:^", + "@moonshot-ai/kimi-code-sdk": "workspace:^" + } +} diff --git a/packages/acp-adapter/src/approval.ts b/packages/acp-adapter/src/approval.ts new file mode 100644 index 000000000..8e183fdae --- /dev/null +++ b/packages/acp-adapter/src/approval.ts @@ -0,0 +1,316 @@ +import type { + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallUpdate, +} from '@agentclientprotocol/sdk'; +import type { ApprovalRequest, ApprovalResponse } from '@moonshot-ai/kimi-code-sdk'; + +import { displayBlockToAcpContent } from './convert'; +import { acpToolCallId } from './events-map'; + +/** + * Canonical option ids surfaced to the ACP client. + * + * The wire-level `PermissionOption.optionId` is opaque to the client (it + * round-trips back in `RequestPermissionResponse.outcome.optionId`), so + * the adapter is free to pick any stable string. These literals are the + * single source of truth on both the build- and the parse-side; tests + * import them rather than re-typing the strings. + */ +export const APPROVE_ONCE_OPTION_ID = 'approve_once'; +export const APPROVE_ALWAYS_OPTION_ID = 'approve_always'; +export const REJECT_OPTION_ID = 'reject'; + +/** + * Phase 13.2 plan_review optionId namespace. Picked deliberately so the + * `plan_*` prefix never collides with the canonical `approve_*` / + * `reject` namespace nor with the question bridge's `q{n}_*` namespace. + * + * - `plan_opt_` — one per `display.options[i]` (rendered as + * `allow_once` in the ACP UI so the user can pick A / B / C without + * re-entering the prompt). + * - `plan_approve` — fallback approve when `display.options` is absent + * or has fewer than two entries (covers the "plan with no explicit + * selectable variants" branch). + * - `plan_revise` / `plan_reject_and_exit` — the two reject-side + * options surfaced in the TUI by `apps/kimi-code/src/tui/reverse-rpc/approval/adapter.ts:13`'s + * `PLAN_REJECT_CHOICES`. Order is preserved so Zed renders the same + * bottom-of-list ordering as the TUI. + */ +export const PLAN_APPROVE_OPTION_ID = 'plan_approve'; +export const PLAN_REVISE_OPTION_ID = 'plan_revise'; +export const PLAN_REJECT_AND_EXIT_OPTION_ID = 'plan_reject_and_exit'; + +function planOptOptionId(i: number): string { + return `plan_opt_${i}`; +} + +/** + * The three canonical permission options surfaced to the ACP client for + * a non-`plan_review` approval prompt. + * + * Order is load-bearing: ACP clients (Zed at the time of writing) render + * the options top-to-bottom, so allow-once is the primary action, + * allow-always is the secondary, and reject is the terminal/dangerous + * action that should be hardest to click by accident. + * + * The `kind` field is used by clients to choose icons / styling; the + * `name` is the human-readable label that surfaces in the UI and is + * the value that round-trips back via `ApprovalResponse.selectedLabel` + * (Phase 5.2). The list is `readonly` because callers treat it as a + * constant lookup table — they do not mutate it. + */ +const CANONICAL_OPTIONS: readonly PermissionOption[] = [ + { optionId: APPROVE_ONCE_OPTION_ID, name: 'Approve once', kind: 'allow_once' }, + { + optionId: APPROVE_ALWAYS_OPTION_ID, + name: 'Approve for this session', + kind: 'allow_always', + }, + { optionId: REJECT_OPTION_ID, name: 'Reject', kind: 'reject_once' }, +]; + +/** + * Build the {@link PermissionOption}[] surfaced to the ACP client for + * an approval prompt. + * + * Phase 13.2 adds a `plan_review` branch — when the request's display + * block carries `kind: 'plan_review'`, the options expand to: + * - one `allow_once` option per `display.options[i]` (A / B / C), or a + * single `plan_approve` fallback when the policy did not supply ≥ 2 + * discrete options; + * - the two `reject_once` exits `Revise` and `Reject and Exit` + * (order matches the TUI's `PLAN_REJECT_CHOICES`). + * + * For every other display kind, the function returns the canonical + * 3-option list (`Approve once` / `Approve for this session` / `Reject`) + * — Phase 5's behaviour, preserved verbatim. + * + * The `req` parameter is optional so that older callsites (notably + * tests that built their own non-plan_review fixtures with no request + * payload) continue to compile and exercise the canonical branch. + */ +export function approvalRequestToPermissionOptions( + req?: ApprovalRequest, +): readonly PermissionOption[] { + if (!req || req.display.kind !== 'plan_review') { + return CANONICAL_OPTIONS; + } + const display = req.display; + const approveOptions: PermissionOption[] = + display.options !== undefined && display.options.length >= 2 + ? display.options.map((opt, i) => ({ + optionId: planOptOptionId(i), + name: opt.label, + kind: 'allow_once' as const, + })) + : [{ optionId: PLAN_APPROVE_OPTION_ID, name: 'Approve', kind: 'allow_once' as const }]; + return [ + ...approveOptions, + { optionId: PLAN_REVISE_OPTION_ID, name: 'Revise', kind: 'reject_once' as const }, + { + optionId: PLAN_REJECT_AND_EXIT_OPTION_ID, + name: 'Reject and Exit', + kind: 'reject_once' as const, + }, + ]; +} + +/** + * Translate an ACP {@link RequestPermissionResponse} into Kimi's + * {@link ApprovalResponse}. + * + * Decision mapping (canonical / non-plan_review path — Phase 5): + * - `cancelled` outcome → `decision: 'cancelled'` (the client closed + * the prompt without selecting an option). + * - `approve_once` → `decision: 'approved'` (no scope, one-shot). + * - `approve_always` → `decision: 'approved'` with `scope: 'session'` + * so the SDK installs a session-runtime allow rule for subsequent + * invocations of the same matcher. + * - `reject` → `decision: 'rejected'`. + * - Any other optionId is treated as a defensive `rejected`: rejecting + * is strictly safer than approving for an unknown id. + * + * Phase 13.2 adds a plan_review branch: when `req.display.kind === + * 'plan_review'`, the `plan_opt_` / `plan_approve` / + * `plan_revise` / `plan_reject_and_exit` optionIds map directly to the + * SDK-side approval discriminator, and the matched option's label is + * attached as `selectedLabel` in-place (so + * `exit-plan-mode-review-ask.ts:49`'s `selectedExitPlanModeOption` + * lookup hits without a second pass through {@link attachSelectedLabel}). + * + * The `req` parameter is optional for backward compatibility with + * callsites that built fixtures without a request — those exercise the + * canonical 3-option mapping unchanged. + */ +export function permissionResponseToApprovalResponse( + req: ApprovalRequest | undefined, + response: RequestPermissionResponse, +): ApprovalResponse { + if (response.outcome.outcome === 'cancelled') { + return { decision: 'cancelled' }; + } + const optionId = response.outcome.optionId; + if (req?.display.kind === 'plan_review') { + return mapPlanReviewOptionId(req.display, optionId); + } + switch (optionId) { + case APPROVE_ONCE_OPTION_ID: + return { decision: 'approved' }; + case APPROVE_ALWAYS_OPTION_ID: + return { decision: 'approved', scope: 'session' }; + case REJECT_OPTION_ID: + return { decision: 'rejected' }; + default: + // Unknown optionId — defensive fallback. Reject is safer than + // approve. Logging is the caller's responsibility (the mapper is + // pure so unit tests don't need to mock a logger). + return { decision: 'rejected' }; + } +} + +/** + * Map a plan_review {@link RequestPermissionResponse}'s optionId to the + * SDK {@link ApprovalResponse}. Pulled out of + * {@link permissionResponseToApprovalResponse} so the canonical and + * plan_review branches stay readable side-by-side. + * + * `selectedLabel` is attached here for `plan_opt_` / + * `plan_revise` / `plan_reject_and_exit`. The downstream policy + * (`exit-plan-mode-review-ask.ts:49` and `:107`) drives its branch off + * `selectedLabel` so the labels must be stable strings — not + * re-derived from the option array on every call. + * + * `plan_approve` intentionally returns `{ decision: 'approved' }` with + * no `selectedLabel` so the policy walks its default approved path. + * + * Defensive: an unknown `plan_*` optionId or a `plan_opt_` with `i` + * out of bounds → `{ decision: 'rejected' }` (same posture as the + * canonical unknown→reject branch). + */ +function mapPlanReviewOptionId( + display: Extract, + optionId: string, +): ApprovalResponse { + if (optionId === PLAN_APPROVE_OPTION_ID) { + return { decision: 'approved' }; + } + if (optionId === PLAN_REVISE_OPTION_ID) { + return { decision: 'rejected', selectedLabel: 'Revise' }; + } + if (optionId === PLAN_REJECT_AND_EXIT_OPTION_ID) { + return { decision: 'rejected', selectedLabel: 'Reject and Exit' }; + } + const match = /^plan_opt_(\d+)$/.exec(optionId); + if (match) { + const i = Number(match[1]); + const opts = display.options; + if (opts !== undefined && Number.isInteger(i) && i >= 0 && i < opts.length) { + return { decision: 'approved', selectedLabel: opts[i]!.label }; + } + return { decision: 'rejected' }; + } + // Unknown plan_* optionId — same defensive reject as the canonical + // unknown branch. + return { decision: 'rejected' }; +} + +/** + * Build the ACP {@link ToolCallUpdate} that scopes a permission request + * to a specific in-flight tool call. + * + * The `toolCallId` is the **prefixed** ACP wire id `${turnId}:${rawId}` + * — matching the id format used by all other tool_call/tool_call_update + * notifications — so the client can correlate the approval prompt with + * the tool card it already rendered. If `turnId` is `undefined` (the + * `onEvent` listener has not yet observed any turn-scoped event), the + * raw SDK id is used as a defensive fallback. In practice approvals + * always fire **after** `tool.call.started`, so the fallback is + * effectively unreachable; it exists so the handler never throws. + * + * Content shape (Phase 5.2): + * - If `req.display` produces a diff-bearing entry via + * {@link displayBlockToAcpContent} (diff kind, or file_io with + * before+after), prepend it so the diff card is the headline of + * the approval prompt. Non-diff display kinds (command, search, …) + * contribute no structured content here — their information is + * already conveyed by the action text below. + * - Phase 13.2 adds a `plan_review` entry so the full plan markdown + * (and the optional `Plan saved to:` path prefix) lands at the top + * of the approval card — the previous Phase-5 fallback truncated + * everything but the action text, losing the plan body. + * - Always append a human-readable action summary + * (`"Requesting approval to ${req.action}"`). This is the fallback + * surface in narrow notification UIs that cannot render the full + * diff card and matches the wording used by the Python reference. + */ +export function buildPermissionToolCallUpdate( + turnId: number | undefined, + req: ApprovalRequest, +): ToolCallUpdate { + const toolCallId = + turnId !== undefined ? acpToolCallId(turnId, req.toolCallId) : req.toolCallId; + const content: ToolCallContent[] = []; + // Diff entry first — diffs and file-io previews carry the most + // context and should land at the top of the approval card. Phase 13.2 + // adds plan_review to the same path so plan markdown surfaces in the + // headline too. + const headlineEntry = displayBlockToAcpContent(req.display); + if (headlineEntry !== null) { + content.push(headlineEntry); + } + // Always include the action summary so the prompt is never empty. + content.push({ + type: 'content', + content: { type: 'text', text: `Requesting approval to ${req.action}` }, + }); + return { + toolCallId, + title: req.toolName, + content, + }; +} + +/** + * Look up the matched {@link PermissionOption}'s display name for the + * given response and return a new {@link ApprovalResponse} carrying + * `selectedLabel`. Returns the input unchanged when: + * - the outcome was `'cancelled'` (no option was matched), or + * - the `optionId` does not appear in the option table (defensive — + * matches the `permissionResponseToApprovalResponse` unknown→reject + * path), or + * - the response has already been mapped to `'cancelled'`, or + * - the optionId is in the `plan_*` namespace — Phase 13.2 attaches + * the label inside {@link permissionResponseToApprovalResponse}'s + * plan_review branch, so a second pass through the canonical option + * table here would either overwrite it with `undefined` (the canonical + * table has no plan ids) or no-op; short-circuiting is the simpler, + * explicit contract. + * + * Pure: returns a fresh object (never mutates the input) so callers + * can stitch the label on top of the discriminator mapping without + * worrying about TS strict-readonly fields. + */ +export function attachSelectedLabel( + response: RequestPermissionResponse, + approval: ApprovalResponse, + options: readonly PermissionOption[], +): ApprovalResponse { + const outcome = response.outcome; + if (outcome.outcome !== 'selected') return approval; + // Phase 13.2: plan_review optionIds already carry selectedLabel from + // the mapper. Short-circuit so this canonical-table lookup never + // strips an already-attached label. + if ( + outcome.optionId.startsWith('plan_opt_') || + outcome.optionId === PLAN_APPROVE_OPTION_ID || + outcome.optionId === PLAN_REVISE_OPTION_ID || + outcome.optionId === PLAN_REJECT_AND_EXIT_OPTION_ID + ) { + return approval; + } + const matched = options.find((o) => o.optionId === outcome.optionId); + if (!matched) return approval; + return { ...approval, selectedLabel: matched.name }; +} diff --git a/packages/acp-adapter/src/auth-methods.ts b/packages/acp-adapter/src/auth-methods.ts new file mode 100644 index 000000000..846d5151e --- /dev/null +++ b/packages/acp-adapter/src/auth-methods.ts @@ -0,0 +1,74 @@ +// Advertise the `terminal-auth` method to ACP clients. Two paths coexist: +// +// 1. First-class `type:'terminal'` per ACP 0.23 — clients re-invoke the +// configured agent binary appending `args` (we use `['--login']` so +// the combined command is ` acp --login`, handled by the +// `acp` subcommand's `--login` flag). +// 2. Legacy `_meta['terminal-auth']` shape — clients that don't yet +// honor the first-class field (Zed without `AcpBetaFeatureFlag`, +// current JetBrains plugin, etc.) read `{command,args,env,label}` +// from `_meta` and spawn ` ` directly. Mirrors +// kimi-cli `acp/server.py:77-96`. +// +// Most clients will hit path 1; path 2 is required for Zed today +// because the first-class handler is beta-gated. + +import type { AuthMethod } from '@agentclientprotocol/sdk'; + +/** + * Build the `terminal-auth` method advertised to ACP clients. + * + * Optional inputs: + * - `env`: extra env vars forwarded to the spawned `kimi login` + * subprocess (e.g. `{ KIMI_CODE_HOME: '/tmp/sandbox' }` for tests). + * - `legacyCommand`: absolute path of the agent binary, used to + * populate `_meta['terminal-auth'].command` so legacy clients can + * spawn ` login` (top-level subcommand). When omitted, the + * `_meta` fallback is left off entirely. + */ +export function buildTerminalAuthMethod( + opts: { + env?: Readonly>; + legacyCommand?: string; + } = {}, +): AuthMethod { + const env = opts.env ?? {}; + const method: AuthMethod = { + id: 'login', + type: 'terminal', + name: 'Login with Kimi account', + description: 'Open the device-code login flow in a terminal.', + // Appended to the agent's configured args by spec-compliant clients + // (e.g. `args:['acp']` + `args:['--login']` → `acp --login`). The + // `--login` flag on `kimi acp` pivots into the login flow before + // touching stdio. + args: ['--login'], + env: { ...env }, + }; + if (opts.legacyCommand !== undefined && opts.legacyCommand.length > 0) { + (method as AuthMethod & { _meta: { 'terminal-auth': unknown } })._meta = { + 'terminal-auth': { + type: 'terminal', + label: 'Login with Kimi account', + // Legacy clients use this verbatim as the executable path, NOT + // combined with the agent server's configured command (per Zed's + // `meta_terminal_auth_task` in `agent_servers/src/acp.rs`). + command: opts.legacyCommand, + // ` login` runs the top-level `kimi login` subcommand, + // skipping the `acp` subprocess entirely. Same behaviour the + // `kimi-cli` Python reference advertises. + args: ['login'], + env: { ...env }, + }, + }; + } + return method; +} + +/** + * Default `terminal-auth` advertisement with no env propagation and no + * legacy `_meta` fallback. Kept as a named export so test files that + * only need the default shape can import it directly without going + * through the factory. + */ +export const TERMINAL_AUTH_METHOD: AuthMethod = buildTerminalAuthMethod(); diff --git a/packages/acp-adapter/src/config-options.ts b/packages/acp-adapter/src/config-options.ts new file mode 100644 index 000000000..eab73eb0a --- /dev/null +++ b/packages/acp-adapter/src/config-options.ts @@ -0,0 +1,180 @@ +/** + * Build the unified `SessionConfigOption[]` surface (PLAN D11) advertised on + * `session/new` + `session/load` and refreshed by `config_option_update`. + * + * Phase 14 unifies model + mode selection under the spec's generic + * `configOptions` channel — replacing Phase 12's dedicated + * `NewSessionResponse.modes` field — so a client like Zed renders both + * pickers from a single source of truth and can flip either through + * `session/set_config_option`. + * + * The v0 surface has up to three options: + * - `id: 'model'` (`type: 'select'`, `category: 'model'`) — one row + * per {@link AcpModelEntry}, no `,thinking` variants. Thinking is + * an orthogonal axis exposed as a separate toggle. + * - `id: 'thinking'` (`type: 'select'`, `category: 'thought_level'`) + * — appears ONLY when the currently-selected model's catalog row has + * `thinkingSupported === true`; otherwise omitted from the snapshot + * so the client doesn't render a non-actionable toggle. Phase 16 + * converted this from `SessionConfigBoolean` to a 2-entry select + * (`off` / `on`) so Zed renders it — Zed's chip strip currently + * only knows how to draw `type: 'select'` options, and the spec's + * `boolean` arm shows up as "Unknown". Effort granularity + * (`'low' | 'medium' | …`) is still hidden behind the adapter — + * kimi-code uses a single non-`'off'` level under the hood (default + * `'high'`, resolved by agent-core's `resolveThinkingEffort`). + * - `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the + * locked 4-mode taxonomy from PLAN D9 ({@link ACP_MODES}). + * + * The wire shape mirrors `@agentclientprotocol/sdk` `SessionConfigOption` + * (`schema/types.gen.d.ts:4449-4480`): each option carries `id`, `name`, + * optional `category`, and a `type`-discriminated `currentValue` (string + * for `'select'`, boolean for `'boolean'`). + */ + +import type { SessionConfigOption, SessionConfigSelectOption } from '@agentclientprotocol/sdk'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { ACP_MODES, type AcpModeId } from './modes'; +import { listModelsFromHarness, type AcpModelEntry } from './model-catalog'; + +/** + * Project the catalog into the `SessionConfigOption` `model` arm. + * + * One option row per catalog entry — Phase 15 removed the inlined + * `${id},thinking` variant rows in favour of a separate + * {@link buildThinkingOption} toggle (Phase 16 then changed that toggle + * from `boolean` to a 2-entry `select` for Zed compatibility, but the + * model picker shape is unaffected), so the model dropdown stays at most + * N rows even when many catalog entries support thinking. The Python + * reference's `_expand_llm_models` (`kimi-cli/src/kimi_cli/acp/server.py:441-468`) + * still emits twin rows, but it has no `select`-based effort + * equivalent; we diverge intentionally for UX clarity. + * + * `currentValue` is the bare model id (no `,thinking` suffix). When + * an external caller still sends the merged form via + * `unstable_setSessionModel({ modelId: 'k2,thinking' })`, + * {@link AcpSession.setModel} splits the suffix off and updates both + * the model and thinking authoritative state before the snapshot is + * built — so the value reaching this builder is always already-split. + */ +export function buildModelOption( + models: readonly AcpModelEntry[], + currentBaseModelId: string, +): SessionConfigOption { + const options: SessionConfigSelectOption[] = models.map((model) => ({ + value: model.id, + name: model.name, + ...(model.description !== undefined ? { description: model.description } : {}), + })); + return { + type: 'select', + id: 'model', + name: 'Model', + category: 'model', + currentValue: currentBaseModelId, + options, + }; +} + +/** + * Build the `thinking` toggle. + * + * Spec category `'thought_level'` (`schema/types.gen.d.ts:4492`) is the + * reserved bucket for reasoning / thinking knobs; using it lets a client + * like Zed render the toggle with the right icon / placement without the + * adapter advertising a custom category. + * + * Phase 16 made this a 2-entry `type: 'select'` (`off` / `on`) instead + * of `type: 'boolean'` — Zed's chip strip currently only renders + * `select` options; boolean shows as "Unknown" because the UI hasn't + * been wired up to the spec's boolean arm yet. The adapter still tracks + * the toggle internally as a boolean (`AcpSession.currentThinkingEnabled`); + * only the wire encoding is `'on'` / `'off'` strings. + * + * The caller decides whether to include this option at all — when the + * currently-selected model has `thinkingSupported === false`, the + * snapshot omits it entirely (dynamic visibility), so the client never + * shows a toggle that wouldn't do anything. + */ +export function buildThinkingOption(enabled: boolean): SessionConfigOption { + return { + type: 'select', + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + currentValue: enabled ? 'on' : 'off', + options: [ + { value: 'off', name: 'Thinking Off' }, + { value: 'on', name: 'Thinking On' }, + ], + }; +} + +/** + * Project the locked 4-mode taxonomy ({@link ACP_MODES}) into the + * `SessionConfigOption` `mode` arm. Order is preserved (default → plan → + * auto → yolo) so the client renders the dropdown the same way Phase 12 + * did via the dedicated `modes:` field. + */ +export function buildModeOption(currentModeId: AcpModeId): SessionConfigOption { + const options: SessionConfigSelectOption[] = ACP_MODES.map((mode) => ({ + value: mode.id, + name: mode.name, + description: mode.description, + })); + return { + type: 'select', + id: 'mode', + name: 'Mode', + category: 'mode', + currentValue: currentModeId, + options, + }; +} + +/** + * Compose the v0 `SessionConfigOption[]` surface — `[modelOption, …(thinkingOption?), modeOption]`. + * Order is part of the contract: ACP clients render options top-to-bottom, and + * PLAN D11 fixes model on top of mode so the more frequently-used selector + * is reachable first. The thinking toggle is wedged between them so its + * effect on the model selection above is visually adjacent. + * + * The thinking toggle only appears when the currently-selected base + * model is `thinkingSupported`; otherwise the snapshot is just + * `[modelOption, modeOption]`. This means switching from a thinking- + * capable model (e.g. `kimi-coder`) to a non-thinking one (e.g. + * `kimi-plain`) causes the next `config_option_update` to omit the + * toggle entirely — Zed's UI is expected to handle "option set changes + * across updates", which is the standard configOptions contract. + * + * Calls {@link listModelsFromHarness} exactly once per invocation so a + * session refresh after each model/mode/thinking change is a single + * round-trip to the harness. The helper itself is tolerant to + * partial-stub harnesses: missing `getConfig` or a throwing one resolve + * to an empty catalog, so the model picker ships an empty options + * array and the thinking toggle is suppressed (no current model means + * no thinkingSupported signal to read). + * + * Returns a mutable `SessionConfigOption[]` (rather than `readonly`) so + * the value is assignable to the SDK's `NewSessionResponse.configOptions` + * field, which is typed `Array` — TypeScript treats + * `readonly T[]` as not assignable to `T[]` even when callers never + * mutate it. + */ +export async function buildSessionConfigOptions( + harness: KimiHarness, + currentBaseModelId: string, + currentThinkingEnabled: boolean, + currentModeId: AcpModeId, +): Promise { + const models = await listModelsFromHarness(harness); + const currentModelEntry = models.find((m) => m.id === currentBaseModelId); + const showThinking = currentModelEntry?.thinkingSupported === true; + const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)]; + if (showThinking) { + out.push(buildThinkingOption(currentThinkingEnabled)); + } + out.push(buildModeOption(currentModeId)); + return out; +} diff --git a/packages/acp-adapter/src/convert.ts b/packages/acp-adapter/src/convert.ts new file mode 100644 index 000000000..5fded81f3 --- /dev/null +++ b/packages/acp-adapter/src/convert.ts @@ -0,0 +1,225 @@ +import type { ContentBlock, ToolCallContent } from '@agentclientprotocol/sdk'; +import { + log, + type PromptPart, + type ToolInputDisplay, + type ToolResultEvent, +} from '@moonshot-ai/kimi-code-sdk'; + +import { isHideOutputMarker } from './marker'; + +/** + * Convert an array of ACP {@link ContentBlock}s into the SDK's + * {@link PromptPart} array. + * + * Phase 9.1 lifts `image` blocks into `image_url` parts: per the ACP + * schema (`ImageContent` at types.gen.d.ts:1905-1920) the `data` field + * is a raw base64-encoded payload accompanied by a required `mimeType`, + * so the data URL is constructed at the adapter boundary as + * `data:;base64,`. We treat `data` as opaque base64 — + * if a caller pre-wraps it as a `data:` URL the prefix detection isn't + * worth the complexity and that string lands inside another `data:` + * envelope (documented limitation; ACP spec says base64, so callers + * conforming to spec are unaffected). + * + * Phase 9.2 inlines `resource_link` and `resource` (EmbeddedResource) + * blocks as XML-flavoured text the model can read directly: + * - `resource_link` → `` + * - `resource` with TextResourceContents → `text` + * - `resource` with BlobResourceContents → dropped with a warn + * (per PLAN D3: "blob 忽略并 warn"). + * Attribute values are escaped via {@link escapeXmlAttr}. + * + * `audio` blocks remain dropped with a warn. + */ +export function acpBlocksToPromptParts( + blocks: readonly ContentBlock[], +): readonly PromptPart[] { + const out: PromptPart[] = []; + for (const block of blocks) { + if (block.type === 'text') { + out.push({ type: 'text', text: block.text }); + continue; + } + if (block.type === 'image') { + const url = `data:${block.mimeType};base64,${block.data}`; + out.push({ type: 'image_url', imageUrl: { url } }); + continue; + } + if (block.type === 'audio') { + log.warn('acp: dropping unsupported audio prompt block', { + mimeType: block.mimeType, + }); + continue; + } + if (block.type === 'resource_link') { + const text = ``; + out.push({ type: 'text', text }); + continue; + } + if (block.type === 'resource') { + const resource = block.resource; + if ('text' in resource) { + // TextResourceContents — wrap as a `` element so the + // model sees the uri provenance alongside the text body. + const text = `${ + resource.text + }`; + out.push({ type: 'text', text }); + continue; + } + // BlobResourceContents — D3 mandates drop+warn. + log.warn('acp: dropping blob embedded resource', { + uri: resource.uri, + mimeType: resource.mimeType, + }); + continue; + } + // Future-proof: anything else (new ACP block kinds) → warn and drop. + log.warn('acp: dropping unsupported prompt content block', { + type: (block as { type: string }).type, + }); + } + return out; +} + +/** + * Minimum-viable XML-attribute escaping for prompt-embedded resource + * wrappers. The output is consumed by an LLM, not parsed by a canonical + * XML parser, so we only escape the five characters that would change + * the apparent tag structure: `&`, `<`, `>`, `"`, `'`. `&` must run + * first to avoid double-escaping the entities introduced by the others. + */ +function escapeXmlAttr(s: string): string { + return s + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** + * Convert an SDK {@link ToolInputDisplay} block into an ACP + * {@link ToolCallContent} entry, when (and only when) the block carries + * structured before/after text suitable for a diff view or a Phase 13.2 + * plan-review markdown body. + * + * Mapped variants: + * - `kind: 'diff'` — always rendered (both `before` and `after` are + * required on the schema). + * - `kind: 'file_io'` with **both** `before` and `after` populated — + * matches the Edit tool's display payload; rendered as a diff too. + * - `kind: 'plan_review'` — rendered as a text block via + * {@link composePlanContent} so the ACP client surfaces the full plan + * markdown (and the optional `Plan saved to:` path prefix) at the top + * of the approval prompt. Empty plans defensively return `null` — + * the policy already guarantees non-empty, but the adapter must not + * trust that to avoid emitting a blank content entry. + * + * All other display kinds (command, search, url_fetch, agent_call, + * skill_call, todo_list, …) return `null` and the caller drops them. + * Phase 7 will add a `terminal` variant; Phase 9 may add image/resource. + */ +export function displayBlockToAcpContent( + block: ToolInputDisplay, +): ToolCallContent | null { + if (block.kind === 'diff') { + return { + type: 'diff', + path: block.path, + oldText: block.before, + newText: block.after, + }; + } + if ( + block.kind === 'file_io' && + block.before !== undefined && + block.after !== undefined + ) { + return { + type: 'diff', + path: block.path, + oldText: block.before, + newText: block.after, + }; + } + if (block.kind === 'plan_review') { + const text = composePlanContent(block); + if (text === null) return null; + return { type: 'content', content: { type: 'text', text } }; + } + return null; +} + +/** + * Render the text body of a `plan_review` display block: + * - When `block.plan` (after trimming) is empty, return `null` — the + * caller drops the content entry rather than surfacing a blank + * headline. The policy at + * `packages/agent-core/src/tools/builtin/planning/exit-plan-mode.ts:110` + * already guarantees a non-empty plan; this guard exists so the + * adapter does not depend on that invariant. + * - When `block.path` is set, prefix the plan with `Plan saved to: + * ` so the ACP client can show the on-disk location alongside + * the markdown body. Otherwise emit the plan markdown alone. + * + * The output is consumed by the ACP client as plain text inside a + * `tool_call_update` content entry; no markdown-specific escaping is + * needed (markdown is the content type, not a wire-format escape + * concern). + */ +function composePlanContent( + block: Extract, +): string | null { + if (block.plan.trim().length === 0) return null; + if (block.path !== undefined) { + return `Plan saved to: ${block.path}\n\n${block.plan}`; + } + return block.plan; +} + +/** + * Convert a {@link ToolResultEvent}'s `output` into ACP + * {@link ToolCallContent} entries. + * + * Phase 4 keeps the mapping intentionally simple: a non-empty string is + * passed through as a text block; objects/arrays are JSON-stringified + * (best-effort — falls back to `String(value)` on circular structures). + * Empty/undefined/null output yields an empty array — the caller still + * emits a `tool_call_update` so the client sees the status transition + * to completed/failed. + * + * Diff content does NOT come from this function: `ToolResultEvent` has + * no `display` field; diffs attach to `ToolCallStartedEvent.display` + * and are emitted by `toolCallStartToSessionUpdate`. + */ +export function toolResultToAcpContent(event: ToolResultEvent): ToolCallContent[] { + const out = event.output; + // Mechanism A — array output containing the HideOutputMarker tells + // the adapter to suppress this tool's textual content entirely + // (e.g. AcpTerminalTool emits via terminal/* reverse-RPC, so + // routing the bytes through tool_call_update would double-render + // in the client UI). Detected before any other processing so + // mark-bearing outputs never leak even a stringified preview. + if (Array.isArray(out) && out.some(isHideOutputMarker)) { + return []; + } + if (out === undefined || out === null) return []; + if (typeof out === 'string') { + if (out.length === 0) return []; + return [{ type: 'content', content: { type: 'text', text: out } }]; + } + // Best-effort stringify for object/array outputs. + let text: string; + try { + text = JSON.stringify(out); + } catch { + // eslint-disable-next-line no-base-to-string + text = typeof out === 'object' && out !== null ? '[object]' : String(out); + } + if (!text) return []; + return [{ type: 'content', content: { type: 'text', text } }]; +} diff --git a/packages/acp-adapter/src/events-map.ts b/packages/acp-adapter/src/events-map.ts new file mode 100644 index 000000000..37b4cc6ca --- /dev/null +++ b/packages/acp-adapter/src/events-map.ts @@ -0,0 +1,514 @@ +import type { + AvailableCommand, + PlanEntry, + PlanEntryStatus, + SessionConfigOption, + SessionNotification, + ToolCallContent, + ToolKind, +} from '@agentclientprotocol/sdk'; +import type { + AssistantDeltaEvent, + ThinkingDeltaEvent, + ToolCallDeltaEvent, + ToolCallStartedEvent, + ToolInputDisplay, + ToolProgressEvent, + ToolResultEvent, + TurnEndReason, +} from '@moonshot-ai/kimi-code-sdk'; + +import { displayBlockToAcpContent, toolResultToAcpContent } from './convert'; +import type { AcpStopReason } from './types'; + +/** + * Build an ACP `session/update` notification with an + * `agent_message_chunk` payload from an SDK `assistant.delta` event. + * + * Verified against `node_modules/.../sdk/dist/schema/types.gen.d.ts`: + * - `SessionNotification` has `{ sessionId, update }` (camelCase), + * - `SessionUpdate` is a discriminated union by the `sessionUpdate` + * field; the agent-text variant uses the literal `'agent_message_chunk'`, + * - inside the chunk the content is a `ContentBlock` with `type: 'text'`. + */ +export function assistantDeltaToSessionUpdate( + sessionId: string, + event: AssistantDeltaEvent, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: event.delta }, + }, + }; +} + +/** + * Map an SDK {@link TurnEndReason} to an ACP `stopReason`. + * + * `completed` → `end_turn`: the model finished a clean turn. + * `cancelled` → `cancelled`: the client/agent cancelled mid-turn. + * `failed` → `end_turn` *with* an out-of-band log: the SDK reports a + * step-level error via `TurnEndedEvent.error`. ACP's `StopReason` does + * not have a dedicated `failed` variant in this protocol version, and + * the spec discourages signaling errors through `stopReason` (errors + * belong on the JSON-RPC error channel). Returning `end_turn` keeps the + * client unblocked; the caller is expected to log the `error` payload + * separately so the failure is observable in the agent logs. + */ +export function turnEndReasonToStopReason(reason: TurnEndReason): AcpStopReason { + switch (reason) { + case 'completed': + return 'end_turn'; + case 'cancelled': + return 'cancelled'; + case 'failed': + return 'end_turn'; + } +} + +/** + * Build the ACP `toolCallId` for a wire-level tool call. + * + * Composes `${turnId}:${toolCallId}` so multiple turns within a single + * session (which legitimately reuse the same model-assigned tool call + * id when the model retries) do not collide on the ACP side. The SDK's + * raw `toolCallId` remains the in-process accumulator key — only the + * ACP wire id is prefixed (matches Python reference at `acp/session.py`). + */ +export function acpToolCallId(turnId: number, toolCallId: string): string { + return `${turnId}:${toolCallId}`; +} + +/** + * Heuristic map from a Kimi tool's `name` to ACP {@link ToolKind}. + * + * Pure, never throws — defaults to `'other'` whenever the name is + * unrecognized so we never block streaming on an unknown tool. The + * mapping favours common builtin tool names (Read/Write/Edit/Bash/etc.); + * MCP / user-defined tools fall through to `'other'` and the client UI + * picks a generic icon. + */ +export function inferToolKind(name: string): ToolKind { + switch (name) { + case 'Read': + case 'Glob': + case 'Grep': + return 'read'; + case 'Write': + case 'Edit': + return 'edit'; + case 'Bash': + case 'Terminal': + return 'execute'; + case 'WebFetch': + case 'WebSearch': + return 'fetch'; + case 'Think': + return 'think'; + default: + return 'other'; + } +} + +/** + * Best-effort JSON stringification for tool args. + * + * Tool args are typed as `unknown` on the SDK side; in practice they're + * JSON-encodable, but a `BigInt` / circular structure would throw. We + * never want a streaming push to crash the prompt loop, so we fall back + * to `String(args)` — the client UI shows a degraded preview, the + * turn keeps running. + * + * Exported because `session.ts` seeds the per-tool-call args accumulator + * with the **initial** args stringification so subsequent + * `tool.call.delta` fragments append correctly. + */ +export function stringifyArgs(args: unknown): string { + try { + return JSON.stringify(args) ?? String(args); + } catch { + return String(args); + } +} + +/** + * Build the ACP `session/update` for the **initial** `tool_call` create + * notification from an SDK `tool.call.started` event. + * + * The wire shape is verified at `types.gen.d.ts:5396-5443`: `ToolCall` + * has a required `title` plus optional `kind`/`status`/`content`/ + * `rawInput`. `sessionUpdate: 'tool_call'` is the discriminator (snake + * literal, camel field — `types.gen.d.ts:4845`). + */ +export function toolCallStartToSessionUpdate( + sessionId: string, + event: ToolCallStartedEvent, +): SessionNotification { + const title = event.description ?? event.name; + const content: ToolCallContent[] = [ + { + type: 'content', + content: { type: 'text', text: stringifyArgs(event.args) }, + }, + ]; + // If the tool attached a diff-bearing display (kind: 'diff' or + // 'file_io' with both before/after set), prepend an inline diff + // entry so the client can render it alongside the textual args + // preview. Non-diff display kinds are skipped here (their + // information is already in the args text). + if (event.display) { + const diff = displayBlockToAcpContent(event.display); + if (diff !== null) { + content.unshift(diff); + } + } + return { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + title, + kind: inferToolKind(event.name), + status: 'in_progress', + rawInput: event.args, + content, + }, + }; +} + +/** + * Build a `tool_call_update` for a streaming arguments delta. + * + * Mutates `accumulator.args` with the new fragment, then emits a wire + * notification whose `content` is the cumulative args text (so each + * update fully replaces the previous content array — that's the wire + * semantics; `ToolCallUpdate.content` is REPLACE, not APPEND, see + * `types.gen.d.ts:5520` "Replace the content collection"). + */ +export function toolCallDeltaToSessionUpdate( + sessionId: string, + event: ToolCallDeltaEvent, + accumulator: { args: string }, +): SessionNotification { + accumulator.args += event.argumentsPart ?? ''; + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + status: 'in_progress', + content: [ + { + type: 'content', + content: { type: 'text', text: accumulator.args }, + }, + ], + }, + }; +} + +/** + * Build the initial ACP `tool_call` (CREATE) notification from the + * **first** `tool.call.delta` event for a given `toolCallId`. + * + * Background: the agent-core emits `tool.call.delta` events while the + * provider streams the model's tool-call args, and only later emits + * `tool.call.started` (after the streaming phase, when the call is + * dispatched). The naive mapping — start → tool_call, delta → tool_call_update + * — therefore lands updates on the wire *before* the create, which makes + * Zed log "Tool call not found" until the start eventually arrives. + * This helper lets the adapter lazy-create the wire tool_call from the + * first delta so subsequent deltas have a legitimate parent to update. + * + * Trade-offs vs {@link toolCallStartToSessionUpdate}: + * - `title`: only `event.name` is available; `description` (from the + * started event) isn't known yet and gets filled in by the upgrade. + * - `kind`: inferred from `event.name`; falls back to `'other'` when + * the first delta omits the name (defensive — providers usually carry + * `name` on the first delta only). + * - `rawInput`: omitted; we don't have parsed args at this point. The + * upgrade sets it from `tool.call.started.event.args`. + * - `content`: seeded with the first `argumentsPart` so the rendered + * card starts to fill in immediately rather than flashing empty. + * - `status`: `'pending'` to convey "the model is still composing the + * call". The upgrade flips it to `'in_progress'`. + */ +export function toolCallLazyCreateToSessionUpdate( + sessionId: string, + event: ToolCallDeltaEvent, +): SessionNotification { + const name = event.name ?? 'tool'; + return { + sessionId, + update: { + sessionUpdate: 'tool_call', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + title: name, + kind: event.name ? inferToolKind(event.name) : 'other', + status: 'pending', + content: [ + { + type: 'content', + content: { type: 'text', text: event.argumentsPart ?? '' }, + }, + ], + }, + }; +} + +/** + * Build a `tool_call_update` that finalises a lazy-created tool call + * once `tool.call.started` arrives. + * + * Used only when {@link toolCallLazyCreateToSessionUpdate} has already + * emitted a `tool_call` for this `toolCallId` from a streaming delta — + * we cannot send a second `tool_call` CREATE, so the canonical + * metadata is delivered as an update instead. The fields are kept in + * sync with {@link toolCallStartToSessionUpdate}: `title` prefers + * `description`, `kind` is re-inferred from the canonical `name`, + * `rawInput` carries the parsed args, and `content` mirrors the + * start path (optional diff prepended + canonical args text). + * + * `status` flips to `'in_progress'`: streaming is done and execution is + * imminent (or already underway by the time the client renders the + * update). + */ +export function toolCallStartedUpgradeToSessionUpdate( + sessionId: string, + event: ToolCallStartedEvent, +): SessionNotification { + const title = event.description ?? event.name; + const content: ToolCallContent[] = [ + { + type: 'content', + content: { type: 'text', text: stringifyArgs(event.args) }, + }, + ]; + if (event.display) { + const diff = displayBlockToAcpContent(event.display); + if (diff !== null) { + content.unshift(diff); + } + } + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + title, + kind: inferToolKind(event.name), + status: 'in_progress', + rawInput: event.args, + content, + }, + }; +} + +/** + * Map an SDK `tool.progress` event to an ACP `tool_call_update`. + * + * Only `update.kind === 'status'` with non-empty `text` produces a wire + * notification (used to refresh the tool card title as the tool reports + * what it's currently doing). stdout/stderr/progress/custom updates + * return `null` here — they're folded into the final `tool.result` + * content in Phase 4.2 rather than streaming as title flickers. + */ +export function toolProgressToSessionUpdate( + sessionId: string, + event: ToolProgressEvent, +): SessionNotification | null { + if (event.update.kind === 'status' && event.update.text) { + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + title: event.update.text, + }, + }; + } + return null; +} + +/** + * Map a `thinking.delta` event to an `agent_thought_chunk` notification. + * + * Mirrors `assistantDeltaToSessionUpdate` shape but uses the + * `'agent_thought_chunk'` variant (`types.gen.d.ts:4845`). + */ +export function thinkingDeltaToSessionUpdate( + sessionId: string, + event: ThinkingDeltaEvent, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: event.delta }, + }, + }; +} + +/** + * Map a `tool.result` event to the **terminal** `tool_call_update` + * notification for that call. + * + * Wire shape (`types.gen.d.ts:5505-5547`): ToolCallUpdate is REPLACE + * semantics for `content` — by the time the result arrives, the + * adapter has been pushing cumulative-args `tool_call_update`s, so + * the result's content array overwrites the streaming args preview + * with the final tool output. `status` flips to `completed` (success) + * or `failed` (`event.isError === true`). `rawOutput` preserves the + * SDK's raw output for clients that want it. + */ +export function toolResultToSessionUpdate( + sessionId: string, + event: ToolResultEvent, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpToolCallId(event.turnId, event.toolCallId), + status: event.isError ? 'failed' : 'completed', + content: toolResultToAcpContent(event), + rawOutput: event.output, + }, + }; +} + +/** + * Translate the kimi-code TodoList display block into an ACP `plan` + * session update. + * + * Mapping rules (anchored at types.gen.d.ts:3530-3569 / :4849): + * - The `todo_list` input-display block carries + * `items: { title, status }[]` (schemas.ts:60). The status is the + * three-state TodoStatus union (todo-list.ts:26): + * `pending` | `in_progress` | `done`. + * - ACP {@link PlanEntryStatus} is `pending` | `in_progress` | `completed`, + * so `done` rewrites to `completed`. Anything outside the known + * enum lands on `pending` as a safe default — we never want a + * plan emission to crash the prompt loop. + * - We default `priority` to `'medium'` because the kimi-code + * TodoList does not carry a priority axis today. + * - `title` → `content` (ACP names it `content` per :3548). + * + * Returns `null` if the items array is empty — there is no useful + * client-side state in "I emit the plan now, but it's empty" beyond + * the eventual `plan_removed` story (deferred until kimi-code grows + * a clear-plan signal). + */ +export function todoListToSessionUpdate( + sessionId: string, + turnId: number, + items: ReadonlyArray<{ title: string; status: string }>, +): SessionNotification | null { + // turnId is accepted for symmetry with other events-map helpers and + // for future debug-log enrichment; the ACP `plan` wire shape is + // session-scoped (types.gen.d.ts:3499 — "The client replaces the + // entire plan with each update") so we do not embed it in the payload. + void turnId; + if (items.length === 0) return null; + const entries: PlanEntry[] = items.map((item) => ({ + content: item.title, + priority: 'medium', + status: mapTodoStatus(item.status), + })); + return { + sessionId, + update: { + sessionUpdate: 'plan', + entries, + }, + }; +} + +function mapTodoStatus(status: string): PlanEntryStatus { + switch (status) { + case 'pending': + return 'pending'; + case 'in_progress': + return 'in_progress'; + case 'done': + case 'completed': + return 'completed'; + default: + return 'pending'; + } +} + +/** + * If the given {@link ToolInputDisplay} carries a TodoList payload, + * project it into an ACP `plan` session update. Returns `null` for + * every other display kind (the caller drops them). + * + * The kimi-code TodoList tool publishes both a structured display + * (`kind: 'todo_list'`) and a textual `tool.result` output. The + * display is the canonical structured signal — we wire it to ACP + * here instead of trying to parse the textual output. + */ +export function planFromDisplayBlock( + sessionId: string, + turnId: number, + display: ToolInputDisplay, +): SessionNotification | null { + if (display.kind !== 'todo_list') return null; + return todoListToSessionUpdate(sessionId, turnId, display.items); +} + +/** + * Build a one-shot ACP `available_commands_update` session + * notification. The Kimi adapter sits at the SDK layer, beneath the + * TUI slash-command registry (`apps/kimi-code/src/tui/commands/`), + * so today we have no in-process source of structured slash commands + * to enumerate. We still emit the wire-shape once per session so + * clients that subscribe to the channel see a deterministic empty + * update rather than waiting forever; an upper layer can fill it in + * later (Phase 11 / ext_method handoff in PLAN D9). + */ +export function availableCommandsUpdateNotification( + sessionId: string, + commands: ReadonlyArray = [], +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'available_commands_update', + availableCommands: commands.slice(), + }, + }; +} + +/** + * Build a `config_option_update` session notification. + * + * Emitted from {@link AcpSession.emitConfigOptionUpdate} after either the + * model or the mode picker changes — through any of the three input + * paths (`unstable_setSessionModel`, `setSessionMode`, or the unified + * `setSessionConfigOption`). Consumed by ACP clients (Zed) to repaint + * the dropdown's selected indicator so the visible config mirrors the + * adapter's authoritative state. + * + * The discriminator literal `'config_option_update'` matches the SDK's + * `ConfigOptionUpdate & { sessionUpdate: 'config_option_update' }` arm of + * the `SessionUpdate` union (`types.gen.d.ts:788-803`, `:4858-4859`). + * + * Phase 14.3 (PLAN D11) introduces this in lieu of Phase 12's + * `current_mode_update`; the legacy helper was deleted in the same + * commit because it has no remaining callers. + */ +export function configOptionUpdateNotification( + sessionId: string, + configOptions: readonly SessionConfigOption[], +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'config_option_update', + configOptions: [...configOptions], + }, + }; +} diff --git a/packages/acp-adapter/src/index.ts b/packages/acp-adapter/src/index.ts new file mode 100644 index 000000000..659d1f514 --- /dev/null +++ b/packages/acp-adapter/src/index.ts @@ -0,0 +1,28 @@ +export type { Implementation } from '@agentclientprotocol/sdk'; +export { CURRENT_VERSION, MIN_PROTOCOL_VERSION, negotiateVersion } from './version'; +export type { AcpVersionSpec } from './version'; +export { TERMINAL_AUTH_METHOD, buildTerminalAuthMethod } from './auth-methods'; +export { AcpServer, runAcpServer, runAcpServerWithStream } from './server'; +export { AcpSession } from './session'; +export { + acpBlocksToPromptParts, + displayBlockToAcpContent, + toolResultToAcpContent, +} from './convert'; +export { + acpToolCallId, + assistantDeltaToSessionUpdate, + inferToolKind, + stringifyArgs, + thinkingDeltaToSessionUpdate, + toolCallDeltaToSessionUpdate, + toolCallLazyCreateToSessionUpdate, + toolCallStartedUpgradeToSessionUpdate, + toolCallStartToSessionUpdate, + toolProgressToSessionUpdate, + toolResultToSessionUpdate, + turnEndReasonToStopReason, +} from './events-map'; +export type { AcpStopReason, AcpToolCallStatus, AcpToolKind } from './types'; +export { HideOutputMarker, isHideOutputMarker } from './marker'; +export { redirectConsoleToStderr } from './log-guard'; diff --git a/packages/acp-adapter/src/kaos-acp.ts b/packages/acp-adapter/src/kaos-acp.ts new file mode 100644 index 000000000..d8ca1ae9f --- /dev/null +++ b/packages/acp-adapter/src/kaos-acp.ts @@ -0,0 +1,256 @@ +/** + * `AcpKaos` — a {@link Kaos} that bridges file reads/writes through the + * ACP client (e.g. Zed's unsaved-buffer view of the workspace) and + * delegates every other operation to an `inner` {@link Kaos} (typically + * a {@link LocalKaos}). + * + * Why a separate class instead of an `if (acpAvailable) { ... }` branch + * inside `LocalKaos`? Because the SDK and the tooling code talk to a + * single {@link Kaos} reference, and dependency-inverting the FS bridge + * is the cheapest way to keep capability gating *out* of every tool. + * When the client doesn't advertise `fs.read_text_file` / `write_text_file` + * we simply never wrap — tools observe a plain `LocalKaos` and Phase 6 + * is invisible to them. + * + * Construction is cheap (no I/O, no probes); one per {@link AcpSession} + * is the intended unit, but reusing across prompts is also fine. + */ + +import { Buffer } from 'node:buffer'; + +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; +import { + KaosError, + type Environment, + type Kaos, + type KaosProcess, + type StatResult, +} from '@moonshot-ai/kaos'; + +/** + * `Kaos` that routes `read*` / `write*` through the ACP reverse-RPC + * channel and delegates everything else to `inner`. + * + * Path semantics: the ACP spec requires absolute paths for + * `fs/readTextFile` and `fs/writeTextFile`. This class does NOT resolve + * relative paths — callers are expected to feed already-absolute paths + * (mirrors `LocalKaos._resolvePath`'s public surface). If you need + * cwd-relative resolution, route through `inner.normpath` first or use + * `withCwd()` to bind a base. + */ +export class AcpKaos implements Kaos { + constructor( + private readonly conn: AgentSideConnection, + private readonly sessionId: string, + private readonly inner: Kaos, + ) {} + + // ── identity ──────────────────────────────────────────────────────── + + /** Distinguishable name so logs / `name` checks can disambiguate. */ + get name(): string { + return `acp(${this.inner.name})`; + } + + get osEnv(): Environment { + return this.inner.osEnv; + } + + // ── path operations: delegate to inner ───────────────────────────── + + pathClass(): 'posix' | 'win32' { + return this.inner.pathClass(); + } + + normpath(path: string): string { + return this.inner.normpath(path); + } + + gethome(): string { + return this.inner.gethome(); + } + + getcwd(): string { + return this.inner.getcwd(); + } + + chdir(path: string): Promise { + return this.inner.chdir(path); + } + + /** + * Return a fresh `AcpKaos` wrapping the inner Kaos's cwd-derived + * instance — so a `chdir` followed by `readText('relative.ts')` + * continues to hit the ACP bridge rather than silently dropping back + * to local filesystem reads. + */ + withCwd(cwd: string): Kaos { + return new AcpKaos(this.conn, this.sessionId, this.inner.withCwd(cwd)); + } + + stat(path: string, options?: { followSymlinks?: boolean }): Promise { + return this.inner.stat(path, options); + } + + iterdir(path: string): AsyncGenerator { + return this.inner.iterdir(path); + } + + glob( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, + ): AsyncGenerator { + return this.inner.glob(path, pattern, options); + } + + mkdir(path: string, options?: { parents?: boolean; existOk?: boolean }): Promise { + return this.inner.mkdir(path, options); + } + + // ── reads: route through ACP `fs/readTextFile` ───────────────────── + + /** + * Read the file via ACP. Decoding parameters (`encoding`, `errors`) + * are accepted for interface compatibility but ignored — the ACP + * `fs/readTextFile` response is already a decoded string, so we have + * no bytes to re-decode. Tools that need byte-exact decoding control + * should be routed through a non-ACP Kaos. + */ + async readText( + path: string, + _options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): Promise { + try { + const resp = await this.conn.readTextFile({ sessionId: this.sessionId, path }); + return resp.content; + } catch (err) { + throw wrapKaosError(`acp: readTextFile failed for ${path}`, err); + } + } + + /** + * Read up to `n` bytes from the file. Implemented as + * `readText → utf8 encode → slice` because ACP only exposes string + * content. Callers that store non-text data through this path + * (uncommon) will get re-encoded bytes — acceptable per `Kaos.readBytes` + * which already permits encoding-dependent return values. + */ + async readBytes(path: string, n?: number): Promise { + const text = await this.readText(path); + const buf = Buffer.from(text, 'utf8'); + return n !== undefined ? buf.subarray(0, n) : buf; + } + + /** + * Yield lines from the file. Emulates Python `splitlines(keepends=False)`: + * splits on `\n`, drops the trailing empty token if the file ended with + * a newline, and yields nothing for an empty file. Matches + * {@link LocalKaos.readLines}'s observable output for the trailing-newline + * case (the local version yields `'line\n'` chunks; here we yield without + * the `\n` — see below). + * + * Note on divergence from `LocalKaos.readLines`: the local impl yields + * `'a\n'`, `'b\n'`, `'c'` while this impl yields `'a'`, `'b'`, `'c'`. + * The interface JSDoc says only "Yield lines from the file at `path` + * one by one" without pinning trailing-newline semantics, so both + * shapes satisfy it. Tools that depend on the trailing-newline (rare) + * should adapt. The Python reference's ACP backend does not implement + * `readLines` separately either. + */ + async *readLines( + path: string, + options?: { encoding?: BufferEncoding; errors?: 'strict' | 'replace' | 'ignore' }, + ): AsyncGenerator { + const text = await this.readText(path, options); + if (text.length === 0) return; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === 0x0a /* \n */) { + yield text.slice(start, i); + start = i + 1; + } + } + if (start < text.length) yield text.slice(start); + } + + // ── writes: route through ACP `fs/writeTextFile` ─────────────────── + + /** + * Write text via ACP. `encoding` is ignored — ACP wire format is + * always UTF-8 string content. `mode: 'a'` (append) emulates with a + * read-then-write fallback: ACP has no native append, and the + * intended audience (unsaved-buffer scratchpads) rarely needs it. + * If the prior read fails (e.g. file missing), the write proceeds + * as if the existing content were empty — matching Python `open('a')` + * which also creates new files. + * + * Returns `data.length` (chars) to match {@link LocalKaos.writeText}'s + * contract. + */ + async writeText( + path: string, + data: string, + options?: { mode?: 'w' | 'a'; encoding?: BufferEncoding }, + ): Promise { + if (options?.mode === 'a') { + let existing = ''; + try { + existing = await this.readText(path); + } catch { + // ENOENT-style failure → treat as empty (mirrors Python open('a')). + existing = ''; + } + await this.acpWrite(path, existing + data); + return data.length; + } + await this.acpWrite(path, data); + return data.length; + } + + /** + * Write raw bytes via ACP by interpreting them as UTF-8. Non-UTF-8 + * payloads will be lossy; the intended use case is text writes + * (Read/Write/Edit tools), not binary streaming. + */ + async writeBytes(path: string, data: Buffer): Promise { + await this.acpWrite(path, data.toString('utf8')); + return data.byteLength; + } + + private async acpWrite(path: string, content: string): Promise { + try { + await this.conn.writeTextFile({ sessionId: this.sessionId, path, content }); + } catch (err) { + throw wrapKaosError(`acp: writeTextFile failed for ${path}`, err); + } + } + + // ── process execution: delegate to inner ─────────────────────────── + + exec(...args: string[]): Promise { + return this.inner.exec(...args); + } + + execWithEnv(args: string[], env?: Record): Promise { + return this.inner.execWithEnv(args, env); + } +} + +/** + * Build a `KaosError` wrapping a raw RPC failure. We can't use the + * `Error(message, { cause })` overload here because {@link KaosError}'s + * constructor only accepts `(message: string)` (see + * `packages/kaos/src/errors.ts`). Instead we synthesize the message + * with the original error's `.message` appended and assign `.cause` + * post-construction so structured-clone consumers (logs, debuggers) + * can still walk the chain. + */ +function wrapKaosError(prefix: string, cause: unknown): KaosError { + const causeMessage = cause instanceof Error ? cause.message : String(cause); + const err = new KaosError(`${prefix}: ${causeMessage}`); + // Mutating `cause` after construction is the cheapest way to preserve + // it without touching the kaos package (denylist forbids edits there). + (err as Error & { cause?: unknown }).cause = cause; + return err; +} diff --git a/packages/acp-adapter/src/log-guard.ts b/packages/acp-adapter/src/log-guard.ts new file mode 100644 index 000000000..a7dbdc834 --- /dev/null +++ b/packages/acp-adapter/src/log-guard.ts @@ -0,0 +1,60 @@ +/** + * stdout-safe logging guard. + * + * ACP speaks JSON-RPC over stdout, so anything that leaks non-JSON bytes + * onto stdout corrupts the channel. `console.log` / `console.info` / + * `console.warn` all default to stdout in Node, which means a stray + * debug print from any dependency can break the protocol. + * + * {@link redirectConsoleToStderr} rebinds those three sinks to stderr. + * `console.error` is intentionally left alone because it already writes + * to stderr and many third-party libraries rely on that. + */ + +type ConsoleSink = (...args: unknown[]) => void; + +interface SavedConsole { + readonly log: ConsoleSink; + readonly info: ConsoleSink; + readonly warn: ConsoleSink; +} + +function formatArg(value: unknown): string { + if (typeof value === 'string') return value; + if (value instanceof Error) return value.stack ?? value.message; + try { + return JSON.stringify(value); + } catch { + return String(value); + } +} + +/** + * Redirect `console.log`, `console.info`, and `console.warn` to + * `process.stderr` until the returned restore function is invoked. + * + * Returns a restore function that puts the original sinks back; calling + * the restore function twice is harmless because it just reassigns + * the saved references. + */ +export function redirectConsoleToStderr(): () => void { + const saved: SavedConsole = { + log: console.log, + info: console.info, + warn: console.warn, + }; + + const writeStderr: ConsoleSink = (...args) => { + process.stderr.write(`${args.map(formatArg).join(' ')}\n`); + }; + + console.log = writeStderr; + console.info = writeStderr; + console.warn = writeStderr; + + return () => { + console.log = saved.log; + console.info = saved.info; + console.warn = saved.warn; + }; +} diff --git a/packages/acp-adapter/src/marker.ts b/packages/acp-adapter/src/marker.ts new file mode 100644 index 000000000..98bf0a35c --- /dev/null +++ b/packages/acp-adapter/src/marker.ts @@ -0,0 +1,40 @@ +/** + * Sentinel object that a tool can attach to its result `output` to + * signal the ACP adapter to suppress this tool's textual output. + * + * Motivation: Phase 7's `AcpTerminalTool` emits its output via the + * ACP `terminal/*` reverse-RPC channel — the adapter must NOT also + * relay the textual stdout / stderr through `tool_call_update` + * content or the Zed UI would render the same bytes twice (one in + * the terminal pane, one in the tool card). The tool implementation + * sets `output: [HideOutputMarker, ...]` (Mechanism A — array of + * marker plus possibly textual fallback) and the adapter's + * `toolResultToAcpContent` short-circuits to `[]` whenever the + * marker is present. + * + * Detection is by reference equality OR by `__kind === 'acp-hide-output'` + * on the value's shape — the latter is a defensive escape hatch in + * case the marker travels through a structured clone (e.g. via the + * worker_threads boundary), losing identity but preserving the field. + * Both checks live in `isHideOutputMarker`. + */ +export const HideOutputMarker = Object.freeze({ + __kind: 'acp-hide-output' as const, +}); + +export type HideOutputMarker = typeof HideOutputMarker; + +/** + * Type guard: detect whether `value` is the {@link HideOutputMarker} + * sentinel. Returns `false` for any non-object value (in particular + * strings whose text happens to contain `'acp-hide-output'` — only + * structural identity counts). + */ +export function isHideOutputMarker(value: unknown): value is HideOutputMarker { + if (value === HideOutputMarker) return true; + return ( + typeof value === 'object' && + value !== null && + (value as { __kind?: unknown }).__kind === 'acp-hide-output' + ); +} diff --git a/packages/acp-adapter/src/mcp.ts b/packages/acp-adapter/src/mcp.ts new file mode 100644 index 000000000..f2e8f7b99 --- /dev/null +++ b/packages/acp-adapter/src/mcp.ts @@ -0,0 +1,112 @@ +/** + * ACP → kimi MCP server conversion. + * + * Translates ACP `McpServer[]` (per the ACP schema discriminated by + * `type: 'http' | 'sse' | 'acp' | 'stdio'`) into kimi's + * keyed `Record` (the same shape the kernel's + * `loadMcpServers` returns and what + * `CreateSessionPayload.mcpServers` / `ResumeSessionPayload.mcpServers` + * accept). The conversion is intentionally narrow: + * + * - `http` → kimi `transport: 'http'` with headers projected from + * `Array<{name, value}>` to `Record`. + * - `stdio` → kimi `transport: 'stdio'` with env projected similarly. + * - `sse` → dropped with a `log.warn` (PLAN D3 declares + * `mcp_capabilities: sse=false`). + * - `acp` → dropped with a `log.warn` (experimental ACP-transport MCP + * is not yet supported). + * + * The kernel keys MCP servers by name at the config-map level, so the + * ACP `name` field becomes the Record key here. Duplicate names within a + * single ACP request collapse with last-write-wins — same behaviour as + * the kernel's own `loadMcpServers` user/project merge. + * + * @see packages/agent-core/src/config/schema.ts (McpServerConfigSchema) + * @see packages/agent-core/src/mcp/session-config.ts (mergeCallerMcpServers) + * @see node_modules/@agentclientprotocol/sdk/dist/schema/types.gen.d.ts (McpServer) + */ + +import type { McpServer, McpServerStdio } from '@agentclientprotocol/sdk'; +import type { McpServerConfig } from '@moonshot-ai/agent-core'; +import { log } from '@moonshot-ai/kimi-code-sdk'; + +/** + * Convert an ACP `McpServer[]` into the kernel-native + * `Record` keyed by server name. Unsupported + * transports (`sse`, `acp`) are warn-dropped — the caller never has to + * filter them out. + * + * Caveat (ACP schema 0.23): the `McpServer` union types stdio as a + * bare branch WITHOUT a discriminator. Members marked `http`, `sse`, + * `acp` carry an explicit `type` field; stdio is identified by the + * ABSENCE of `type`. We branch accordingly. + */ +export function acpMcpServersToConfigs( + servers: readonly McpServer[] | undefined, +): Record { + if (!servers || servers.length === 0) return {}; + const out: Record = {}; + for (const server of servers) { + const converted = acpMcpServerToConfig(server); + if (converted !== null) out[converted.name] = converted.config; + } + return out; +} + +function acpMcpServerToConfig( + server: McpServer, +): { name: string; config: McpServerConfig } | null { + // The stdio branch of the `McpServer` union has no `type` field + // (see ACP schema 0.23 — stdio is the bare `McpServerStdio` shape + // in the discriminated union). Anything without an explicit `type` + // is treated as stdio. + if (!('type' in server) || typeof server.type !== 'string') { + const stdio = server as McpServerStdio; + const config: McpServerConfig = { + transport: 'stdio', + command: stdio.command, + args: stdio.args, + env: envArrayToRecord(stdio.env), + }; + return { name: stdio.name, config }; + } + switch (server.type) { + case 'http': { + const config: McpServerConfig = { + transport: 'http', + url: server.url, + headers: headersArrayToRecord(server.headers), + }; + return { name: server.name, config }; + } + case 'sse': + case 'acp': + default: { + // Defensive: future ACP transports land here too. The cast is the + // narrowest way to read `name`/`type` off the leftover variant + // without re-declaring the union. + const fallback = server as { name?: string; type?: string }; + log.warn('acp: dropping unsupported MCP server transport', { + name: fallback.name, + type: fallback.type, + }); + return null; + } + } +} + +function headersArrayToRecord( + headers: ReadonlyArray<{ readonly name: string; readonly value: string }>, +): Record { + const out: Record = {}; + for (const h of headers) out[h.name] = h.value; + return out; +} + +function envArrayToRecord( + env: ReadonlyArray<{ readonly name: string; readonly value: string }>, +): Record { + const out: Record = {}; + for (const e of env) out[e.name] = e.value; + return out; +} diff --git a/packages/acp-adapter/src/model-catalog.ts b/packages/acp-adapter/src/model-catalog.ts new file mode 100644 index 000000000..4992b491b --- /dev/null +++ b/packages/acp-adapter/src/model-catalog.ts @@ -0,0 +1,86 @@ +/** + * ACP model catalog — adapter-local helper that turns the harness's + * config snapshot into a flat list of selectable models for the ACP + * `configOptions` picker (`packages/acp-adapter/src/config-options.ts`). + * + * Used to live inside `@moonshot-ai/kimi-code-sdk` as + * `KimiHarness.listAvailableModels()`; moved here so the SDK keeps a + * minimal surface and ACP-specific heuristics (thinking-capability + * derivation, the toggleable-models allow-list) stay scoped to the + * adapter. + * + * Iteration order mirrors `config.models` insertion order — Node's + * `Object.entries` over plain object keys is insertion-ordered for + * string keys, matching the Python reference's + * `for model_key, model in models.items()`. + * + * `thinkingSupported` is true if any of: + * 1. the alias's declared `capabilities` array contains `'thinking'`, or + * 2. the underlying model name matches `/thinking|reason/i` + * (always-thinking variants), or + * 3. the underlying model name is on the {@link TOGGLEABLE_THINKING_MODELS} + * allow-list (mirrors `kimi-cli/src/kimi_cli/llm.py:derive_model_capabilities`). + */ + +import type { KimiHarness, ModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +/** + * One catalog row per configured model alias, suitable for an ACP + * picker. `description` is left optional so the harness can populate it + * later without breaking callers; ACP UIs treat it as a flavour-text + * subtitle. + */ +export interface AcpModelEntry { + readonly id: string; + readonly name: string; + readonly description?: string | undefined; + readonly thinkingSupported: boolean; +} + +/** + * Models that support thinking by toggle (not by name match or + * `capabilities` declaration). Kept here because the list is + * ACP-picker-specific UX — moving it into the kernel would bake an + * adapter concern into a place that doesn't need to know about ACP. + */ +const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']); + +export function deriveThinkingSupported(alias: ModelAlias): boolean { + const declared = alias.capabilities ?? []; + if (declared.includes('thinking')) return true; + const lower = alias.model.toLowerCase(); + if (lower.includes('thinking') || lower.includes('reason')) return true; + if (TOGGLEABLE_THINKING_MODELS.has(alias.model)) return true; + return false; +} + +/** + * Project `harness.getConfig().models` into a flat catalog. Returns an + * empty array when the harness has no models configured, when + * `getConfig` is missing on the harness (partial test stubs), or when + * `getConfig` throws — letting the caller decide how to surface a + * degenerate config without forcing every test stub to provide every + * field. + */ +export async function listModelsFromHarness( + harness: KimiHarness, +): Promise { + if (typeof harness.getConfig !== 'function') return []; + let models: Record | undefined; + try { + const config = await harness.getConfig(); + models = config.models; + } catch { + return []; + } + if (models === undefined) return []; + const out: AcpModelEntry[] = []; + for (const [id, alias] of Object.entries(models)) { + out.push({ + id, + name: alias.displayName ?? alias.model ?? id, + thinkingSupported: deriveThinkingSupported(alias), + }); + } + return out; +} diff --git a/packages/acp-adapter/src/modes.ts b/packages/acp-adapter/src/modes.ts new file mode 100644 index 000000000..c0f4e0168 --- /dev/null +++ b/packages/acp-adapter/src/modes.ts @@ -0,0 +1,108 @@ +/** + * ACP session-mode taxonomy. + * + * The 4 modes (`default`, `plan`, `auto`, `yolo`) are the locked + * decision in PLAN D9 (`PLAN.md` §D9). Every `session/new` and + * `session/load` response advertises {@link ACP_MODES} as the + * `availableModes` plus {@link DEFAULT_MODE_ID} as `currentModeId`, + * so Zed (and any other ACP client) can render its mode dropdown + * from a single canonical source. + * + * Phase 12.2 wires `session/set_mode` to consume the same source of + * truth: {@link isAcpModeId} narrows the wire string, and the four + * arms branch on {@link AcpModeId}. This module exports the + * primitives but does **not** mutate session state — it is the + * registry, not the dispatcher. + */ + +import type { SessionMode } from '@agentclientprotocol/sdk'; +import type { PermissionMode } from '@moonshot-ai/kimi-code-sdk'; + +/** + * Canonical 4-mode taxonomy (PLAN D9). Order matters: the array + * is rendered as-is by the client, so `default` must appear first + * and `yolo` last. `as const satisfies` pins both the literal + * shape and the SDK contract so a future SDK type change surfaces + * here at typecheck rather than at runtime. + */ +export const ACP_MODES = [ + { + id: 'default', + name: 'Default', + description: 'Manual approvals; tools execute normally.', + }, + { + id: 'plan', + name: 'Plan', + description: 'Read-only planning; no tool execution.', + }, + { + id: 'auto', + name: 'Auto', + description: 'Auto-approve safe operations.', + }, + { + id: 'yolo', + name: 'YOLO', + description: 'Auto-approve everything.', + }, +] as const satisfies readonly SessionMode[]; + +/** Initial `currentModeId` for every freshly created ACP session. */ +export const DEFAULT_MODE_ID = 'default' as const; + +/** + * The four wire-level mode ids understood by this adapter. Keep + * this union in lock-step with {@link ACP_MODES} — Phase 12.2's + * dispatch table assumes the only valid ids are these four. + */ +export type AcpModeId = 'default' | 'plan' | 'auto' | 'yolo'; + +/** + * Narrow an unknown wire string to {@link AcpModeId}. Used by Phase + * 12.2's `setMode` handler to validate the client-supplied modeId + * before dispatching; centralising the guard here avoids drift if + * the taxonomy ever grows a fifth mode. + */ +export function isAcpModeId(value: unknown): value is AcpModeId { + return ( + value === 'default' || value === 'plan' || value === 'auto' || value === 'yolo' + ); +} + +/** + * The two underlying SDK toggles each ACP mode maps to. `plan` is the + * argument to `Session.setPlanMode` and `permission` is the argument to + * `Session.setPermission`. Returned as a pure value so the dispatcher + * in {@link AcpSession.setMode} can stay branch-free and the table is + * co-located with the {@link ACP_MODES} registry it derives from. + */ +export interface AcpModeToggles { + readonly plan: boolean; + readonly permission: PermissionMode; +} + +/** + * Resolve an {@link AcpModeId} to its underlying SDK toggles per + * PLAN D9 (`PLAN.md:93-98`). The `switch` deliberately enumerates every + * arm of {@link AcpModeId} so the TypeScript compiler enforces + * exhaustiveness — adding a 5th mode without extending this table is a + * typecheck error (the `never` fallthrough), not a silent runtime + * no-op. Pure: no side effects, no SDK calls. + */ +export function acpModeToToggles(id: AcpModeId): AcpModeToggles { + switch (id) { + case 'default': + return { plan: false, permission: 'manual' }; + case 'plan': + return { plan: true, permission: 'manual' }; + case 'auto': + return { plan: false, permission: 'auto' }; + case 'yolo': + return { plan: false, permission: 'yolo' }; + default: { + const _exhaustive: never = id; + throw new Error(`Unhandled AcpModeId: ${String(_exhaustive)}`); + } + } +} diff --git a/packages/acp-adapter/src/question.ts b/packages/acp-adapter/src/question.ts new file mode 100644 index 000000000..2270d8105 --- /dev/null +++ b/packages/acp-adapter/src/question.ts @@ -0,0 +1,99 @@ +import type { + PermissionOption, + RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; +import type { QuestionAnswers, QuestionItem } from '@moonshot-ai/kimi-code-sdk'; + +/** + * `optionId` namespace for the AskUserQuestion bridge. + * + * The wire-level `PermissionOption.optionId` is opaque to the client (it + * round-trips back via `RequestPermissionResponse.outcome.optionId`), so + * the adapter is free to pick any stable string. We embed the + * `questionIndex` in the prefix so multi-question support (when it + * arrives — Phase 13.1 still degrades to single-question) does not need + * a wire-format change: `q0_opt_*` / `q1_opt_*` are already + * non-conflicting. The skip option follows the same scheme so a single + * regex (`/^q(\d+)_(opt_(\d+)|skip)$/`) can parse any future surface. + */ +function optOptionId(questionIndex: number, optionIndex: number): string { + return `q${questionIndex}_opt_${optionIndex}`; +} + +function skipOptionId(questionIndex: number): string { + return `q${questionIndex}_skip`; +} + +/** + * Map a tool-side {@link QuestionItem} into ACP + * {@link PermissionOption}[]. + * + * Layout: + * - One `allow_once` option per `question.options[i]` (label preserved + * verbatim — it is the same string we surface back to the SDK as a + * `QuestionAnswers` value, so any UI normalisation belongs on the + * tool side, not here). + * - One trailing `reject_once` "Skip" option so the user can dismiss + * the prompt without forcing an answer. The SDK's ask-user tool + * already understands dismissal (`packages/agent-core/src/tools/builtin/collaboration/ask-user.ts:126` + * emits `question_dismissed` and resolves with a null result); the + * Skip surface is the user-facing path into that branch. + * + * `questionIndex` is currently always `0` (Phase 13.1 degrades + * multi-question to single-question), but the namespace is wired in so + * future multi-question support is a pure handler change with no wire + * format break. + * + * Returned `readonly` because callers treat it as a constant lookup + * table — they do not mutate it. + */ +export function questionItemToPermissionOptions( + question: QuestionItem, + questionIndex: number, +): readonly PermissionOption[] { + const options: PermissionOption[] = question.options.map((opt, i) => ({ + optionId: optOptionId(questionIndex, i), + name: opt.label, + kind: 'allow_once' as const, + })); + options.push({ + optionId: skipOptionId(questionIndex), + name: 'Skip', + kind: 'reject_once' as const, + }); + return options; +} + +/** + * Reverse-map an ACP {@link RequestPermissionResponse} into a tool-side + * {@link QuestionAnswers} payload, returning `null` when the user + * dismissed (skip, cancel) or selected an unknown option. + * + * Dismissal semantics align with the existing ask-user tool path: + * `null` causes the SDK to resolve the tool with the canonical + * "user dismissed" branch (mirrors `rpc.ts:567` — `requestQuestion` + * returning `null` is the dismissed signal). + * + * Defensive on out-of-bounds / unknown optionIds: returning `null` + * rather than throwing keeps the bridge robust against stale or custom + * options surfaced by the client. + */ +export function outcomeToQuestionAnswer( + question: QuestionItem, + response: RequestPermissionResponse, +): QuestionAnswers | null { + if (response.outcome.outcome === 'cancelled') return null; + const optionId = response.outcome.optionId; + // Skip — explicit dismissal path; treat the same as `cancelled`. + if (optionId === skipOptionId(0)) return null; + // Selected option — parse the `q0_opt_` shape and look up the + // matching label. Reject anything that does not match the namespace + // (or whose index is out of bounds) defensively rather than crashing. + const match = /^q0_opt_(\d+)$/.exec(optionId); + if (!match) return null; + const optionIndex = Number(match[1]); + if (!Number.isInteger(optionIndex) || optionIndex < 0) return null; + const selected = question.options[optionIndex]; + if (!selected) return null; + return { [question.question]: selected.label }; +} diff --git a/packages/acp-adapter/src/server.ts b/packages/acp-adapter/src/server.ts new file mode 100644 index 000000000..709a2eec1 --- /dev/null +++ b/packages/acp-adapter/src/server.ts @@ -0,0 +1,988 @@ +/** + * ACP `AgentSideConnection` wrapper. + * + * Phase 3 implements `initialize`, `session/new`, and `session/cancel` + * against {@link KimiHarness}. `prompt` is wired in step 3.4. `initialize` + * advertises the terminal-auth method (see {@link TERMINAL_AUTH_METHOD}). + */ + +import { Readable, Writable } from 'node:stream'; + +import { + AgentSideConnection, + ndJsonStream, + RequestError, + type Agent, + type AgentCapabilities, + type AuthenticateRequest, + type AuthenticateResponse, + type CancelNotification, + type ClientCapabilities, + type Implementation, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type McpServer, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionConfigOption, + type SessionInfo, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, + type Stream, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness, Session, SessionSummary } from '@moonshot-ai/kimi-code-sdk'; +import { log } from '@moonshot-ai/kimi-code-sdk'; + +import { TERMINAL_AUTH_METHOD, buildTerminalAuthMethod } from './auth-methods'; +import { redirectConsoleToStderr } from './log-guard'; +import { AcpSession, type TelemetryTrackFn } from './session'; +import { buildSessionConfigOptions } from './config-options'; +import { availableCommandsUpdateNotification } from './events-map'; +import { acpMcpServersToConfigs } from './mcp'; +import { listModelsFromHarness } from './model-catalog'; +import { DEFAULT_MODE_ID } from './modes'; +import { negotiateVersion, type AcpVersionSpec } from './version'; + +/** + * Inline auth gate — moved out of `KimiAuthFacade.hasUsableToken()` so + * the SDK doesn't have to carry an ACP-specific convenience method. + * Mirrors the original semantics exactly: any provider with `hasToken` + * set counts as authed. + */ +async function harnessIsAuthed(harness: KimiHarness): Promise { + const status = await harness.auth.status(); + return status.providers.some((entry) => entry.hasToken === true); +} + +/** + * Agent-side ACP handler. Routes `initialize` + `session/new` + `session/cancel` + * into {@link KimiHarness}; refuses methods that are not yet wired with a + * JSON-RPC "method not found" error so clients see a structured failure + * rather than a silent hang. + * + * The harness is captured eagerly so Phase 3 routes `session/new`, + * `session/cancel` (and Phase 3.4: `session/prompt`) into it without + * changing the public constructor. The {@link AgentSideConnection} (if + * supplied) is forwarded to every {@link AcpSession} so the session can + * push `session/update` chunks back to the client. + */ +export class AcpServer implements Agent { + private negotiated: AcpVersionSpec | undefined; + private clientCapabilities: ClientCapabilities | undefined; + private readonly sessions = new Map(); + private readonly agentInfo: Implementation | undefined; + private readonly terminalAuthEnv: Readonly> | undefined; + private readonly terminalAuthLegacyCommand: string | undefined; + + constructor( + private readonly harness: KimiHarness, + private readonly conn?: AgentSideConnection | undefined, + opts?: { + agentInfo?: Implementation; + /** + * Env vars to advertise in `authMethods[0].env` so the `kimi login` + * subprocess the client spawns (via `terminal-auth`) lands its + * token under the same data root the ACP server uses. Intended for + * sandboxed test setups (e.g. `{ KIMI_CODE_HOME: '/tmp/...' }`); + * leave undefined in production so the advertised env stays empty. + */ + terminalAuthEnv?: Readonly>; + /** + * Absolute binary path advertised in `_meta['terminal-auth'].command` + * for clients that don't yet honor the first-class + * `AuthMethodTerminal` (Zed without `AcpBetaFeatureFlag`, JetBrains + * plugin). Clients on this legacy path spawn ` login` + * directly. Defaults to undefined (the `_meta` fallback is omitted). + */ + terminalAuthLegacyCommand?: string; + }, + ) { + this.agentInfo = opts?.agentInfo; + this.terminalAuthEnv = opts?.terminalAuthEnv; + this.terminalAuthLegacyCommand = opts?.terminalAuthLegacyCommand; + } + + /** Returns the {@link AcpVersionSpec} chosen during `initialize`, if any. */ + get negotiatedVersion(): AcpVersionSpec | undefined { + return this.negotiated; + } + + /** Returns the client capabilities advertised during `initialize`, if any. */ + get clientCaps(): ClientCapabilities | undefined { + return this.clientCapabilities; + } + + /** @internal — for tests/inspection only. */ + getSession(sessionId: string): AcpSession | undefined { + return this.sessions.get(sessionId); + } + + async initialize(params: InitializeRequest): Promise { + this.negotiated = negotiateVersion(params.protocolVersion); + this.clientCapabilities = params.clientCapabilities; + + const agentCapabilities: AgentCapabilities = { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: false, + }, + mcpCapabilities: { + http: true, + sse: false, + }, + sessionCapabilities: { + list: {}, + resume: {}, + }, + }; + + return { + protocolVersion: this.negotiated.protocolVersion, + agentCapabilities, + authMethods: [ + this.terminalAuthEnv !== undefined || this.terminalAuthLegacyCommand !== undefined + ? buildTerminalAuthMethod({ + env: this.terminalAuthEnv, + legacyCommand: this.terminalAuthLegacyCommand, + }) + : TERMINAL_AUTH_METHOD, + ], + ...(this.agentInfo ? { agentInfo: this.agentInfo } : {}), + }; + } + + async newSession(params: NewSessionRequest): Promise { + if (!(await harnessIsAuthed(this.harness))) { + throw RequestError.authRequired(); + } + // ACP's `cwd` maps to the SDK's `workDir`. `model`, `planMode`, and + // similar fields are wired in Phase 8 (per PLAN D3) — Phase 3.2 keeps + // the surface minimal. Phase 10.1 adds `mcpServers` forwarding so + // ACP-supplied servers (Zed config, JetBrains config) are passed + // alongside the on-disk config; unsupported transports (sse/acp) + // are warn-dropped inside the conversion. `mcpServers` is NOT a + // declared field on `CreateSessionOptions` — the SDK is a + // transparent passthrough for unknown fields (see + // `packages/node-sdk/src/kimi-harness.ts:createSession` and + // `packages/node-sdk/src/rpc.ts:createSession`), so the kernel + // (`CreateSessionPayload.mcpServers` in agent-core) receives the + // record verbatim. The `@ts-expect-error` documents this contract; + // if the SDK ever switches from spread-passthrough to explicit field + // copy, this line breaks and we revisit the boundary. + const mcpServers = acpMcpServersToConfigs(params.mcpServers); + const session = await this.harness.createSession({ + workDir: params.cwd, + // @ts-expect-error — `mcpServers` is a kernel-side extension + // (agent-core `CreateSessionPayload`) the SDK transparently + // forwards via spread. See block comment above. + mcpServers, + }); + if (!this.conn) { + // Defensive: every code path that constructs `AcpServer` (the + // runners below, and any test that intends to drive `newSession`) + // must supply the connection. Surface a clear internal error + // rather than letting Phase 3.4's `prompt` discover a missing + // connection mid-stream. + throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection'); + } + const currentModelId = await this.resolveCurrentModelId(); + const currentThinkingEnabled = await this.resolveCurrentThinkingEnabled(); + const acpSession = new AcpSession( + this.conn, + session, + this.clientCapabilities, + this.makeTelemetryTrack(), + currentModelId, + this.harness, + currentThinkingEnabled, + ); + this.sessions.set(session.id, acpSession); + // Telemetry breadcrumb so we can observe ACP adoption (number of + // sessions started via this surface, vs. TUI / SDK direct). The + // property set is deliberately minimal: `mode` distinguishes + // `newSession` from `loadSession`; no user content / PII. + this.trackSessionStarted(session.id, 'new'); + await this.emitAvailableCommandsUpdate(session.id); + // Phase 14 (PLAN D11) advertises both the model and mode pickers as + // a unified `configOptions: SessionConfigOption[]` surface. The + // dedicated Phase 12 `modes:` field is gone — see + // `docs/{zh,en}/reference/kimi-acp.md` and the changeset for the + // pre-release breaking note. `currentModeId` always starts at + // `default` (PLAN D9); `currentModelId` is resolved from the harness + // config (`defaultModel` if set, else the first listed alias) so + // the dropdown's "current" highlight matches the session the SDK + // just constructed. Phase 15 adds the `thinking` toggle when the + // current model's catalog row advertises `thinkingSupported`; + // Phase 16 reshaped that toggle from `boolean` to a 2-entry + // `select` so Zed actually renders it. + return { + sessionId: session.id, + configOptions: await buildSessionConfigOptions( + this.harness, + currentModelId, + currentThinkingEnabled, + DEFAULT_MODE_ID, + ), + }; + } + + /** + * Handle ACP `session/load`. Mirrors {@link newSession}'s auth gate + * and connection guard, but resumes an existing on-disk session + * via the shared {@link setupSessionFromExisting} helper instead of + * creating a new one. After the AcpSession is wired up, replays the + * persisted history as a synchronous batch of `session/update` + * notifications so the client sees the prior turns before the + * response settles. + * + * The ACP `LoadSessionResponse` shape allows an empty body — every + * field (`configOptions`, `models`, `modes`) is optional. Phase 12.1 + * starts populating `modes` so a resumed session re-renders Zed's + * mode dropdown identically to a freshly created one; the + * `currentModeId` is always `default` on load because the SDK does + * not persist mode across runs (PLAN D9). + * + * The non-trivial setup (auth gate, connection guard, harness + * resume, AcpSession construction, session registration, configOptions + * computation) is shared with {@link resumeSession} via + * {@link setupSessionFromExisting}; the ONE differentiator is that + * `loadSession` calls `replayHistory()` here, whereas `resumeSession` + * deliberately skips it (per ACP spec G4 / plan gap-4.3). + */ + async loadSession(params: LoadSessionRequest): Promise { + const { session, acpSession, configOptions } = await this.setupSessionFromExisting({ + cwd: params.cwd, + sessionId: params.sessionId, + mcpServers: params.mcpServers, + }); + // Same telemetry breadcrumb as `newSession`, but `mode: 'load'` + // so we can distinguish session creation from resumption. No PII. + this.trackSessionStarted(session.id, 'load'); + // Synchronously replay history — the response must not settle + // until every historical `session/update` has been pushed, + // otherwise the client would race the load completion against + // its own UI bootstrap. This is the ONE difference vs. + // `resumeSession`, which intentionally omits this step. + await acpSession.replayHistory(); + await this.emitAvailableCommandsUpdate(session.id); + return { configOptions }; + } + + /** + * Handle ACP `session/resume`. Per ACP spec, `session/resume` is the + * lighter-weight sibling of `session/load`: same on-disk session + * rehydration, same `configOptions:` advertisement — but the client + * is expected to have already seen the prior turns, so the agent + * deliberately does NOT replay history. This makes `resumeSession` + * the right surface for clients that maintain their own transcript + * (e.g. external session managers, or a TUI reattaching to a still- + * running session) and would only flicker if the agent re-emitted + * the historical `session/update` notifications. + * + * Setup is shared verbatim with {@link loadSession} via + * {@link setupSessionFromExisting} (auth gate, conn guard, harness + * `resumeSession` with `session.not_found` mapping, AcpSession + * construction, configOptions build). The only differences are: + * (a) telemetry mode is `'resume'` (vs `'load'`), and (b) no + * `replayHistory()` call. See plan G4 (lines 106-170) for the + * rationale, and gap-4.1 for the matching capability advertisement. + */ + async resumeSession(params: ResumeSessionRequest): Promise { + const { session, configOptions } = await this.setupSessionFromExisting({ + cwd: params.cwd, + sessionId: params.sessionId, + mcpServers: params.mcpServers, + }); + // Telemetry breadcrumb — distinguishes resume from new/load so we + // can observe which clients adopt the lighter-weight resume + // surface vs the history-replaying load surface. No PII. + this.trackSessionStarted(session.id, 'resume'); + await this.emitAvailableCommandsUpdate(session.id); + return { configOptions }; + } + + /** + * Shared setup for `session/load` and `session/resume`: gates auth, + * checks the connection, resolves MCP servers, asks the harness to + * resume the on-disk session, computes the current model/thinking + * projection (with a resume-state fallback), constructs the + * {@link AcpSession}, registers it under `session.id`, and builds + * the unified `configOptions:` surface (PLAN D11) that both handlers + * return. + * + * Behavior is byte-for-byte identical to the pre-refactor + * `loadSession` body minus the `replayHistory()` call — which lives + * in `loadSession` itself because `resumeSession` per ACP spec must + * NOT replay history (the client is expected to have already seen + * those turns; replay is a load-only behavior). See plan G4 + * (lines 106-170) for the rationale. + * + * The `@ts-expect-error` boundary at the SDK `resumeSession` call + * is preserved verbatim — `mcpServers` is a kernel-only extension + * the SDK forwards via spread (see the `newSession` comment block + * for the full contract). The `session.not_found` → `invalidParams` + * mapping is also preserved so unknown-session errors surface as a + * structured JSON-RPC failure rather than a generic internal error. + */ + private async setupSessionFromExisting(params: { + cwd: string; + sessionId: string; + mcpServers?: ReadonlyArray; + }): Promise<{ + session: Session; + acpSession: AcpSession; + configOptions: SessionConfigOption[]; + }> { + if (!(await harnessIsAuthed(this.harness))) { + throw RequestError.authRequired(); + } + if (!this.conn) { + throw RequestError.internalError(undefined, 'AcpServer is missing its AgentSideConnection'); + } + // ACP `cwd` → SDK `workDir` for parity with `newSession`. The + // harness's `resumeSession` only takes `{ id }` today; the cwd + // arrives on the request for future validation but is not enforced + // here (the on-disk session already has its own workDir). Phase + // 10.1 also forwards `mcpServers` so a resumed session can pick up + // ACP-supplied MCP servers (matching `newSession` behaviour). Same + // `@ts-expect-error` boundary as `newSession` — the SDK's + // `resumeSession` spreads `input` so unknown fields ride to the + // kernel. + const mcpServers = acpMcpServersToConfigs(params.mcpServers); + let session: Session; + try { + session = await this.harness.resumeSession({ + id: params.sessionId, + // @ts-expect-error — see block comment above; mcpServers is a + // kernel-only field that the SDK forwards via spread. + mcpServers, + }); + } catch (err) { + // Surface unknown-session as invalid_params so the JSON-RPC layer + // returns a structured failure rather than a generic internal + // error. Other errors propagate as-is. + const code = (err as { code?: string } | undefined)?.code; + if (code === 'session.not_found') { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } + throw err; + } + // Phase 14 (PLAN D11) — same `configOptions:` advertisement as + // `newSession`. `currentModeId` is `default` on every load (mode + // is session-scoped per PLAN D9); `currentModelId` is read from + // the resumed session's main-agent config when available so the + // dropdown's highlight matches the model the resumed turn will + // actually use — falling back to the harness-level default + // resolution when the resume state lacks a `modelAlias`. + const resumeState = session.getResumeState?.(); + const resumedModelAlias = resumeState?.agents?.['main']?.config?.modelAlias; + const currentModelId = + typeof resumedModelAlias === 'string' && resumedModelAlias.length > 0 + ? resumedModelAlias + : await this.resolveCurrentModelId(); + // Phase 15 reads the resumed thinking level off the main-agent + // config and projects it onto the binary toggle: any non-`'off'` + // effort level reads as "thinking on" because the ACP surface only + // exposes the boolean axis. Falls back to the harness-level default + // when the resume state lacks the field. + const resumedThinkingLevel = resumeState?.agents?.['main']?.config?.thinkingLevel; + const currentThinkingEnabled = + typeof resumedThinkingLevel === 'string' + ? resumedThinkingLevel.trim().toLowerCase() !== 'off' && + resumedThinkingLevel.trim().length > 0 + : await this.resolveCurrentThinkingEnabled(); + const acpSession = new AcpSession( + this.conn, + session, + this.clientCapabilities, + this.makeTelemetryTrack(), + currentModelId, + this.harness, + currentThinkingEnabled, + ); + this.sessions.set(session.id, acpSession); + const configOptions = await buildSessionConfigOptions( + this.harness, + currentModelId, + currentThinkingEnabled, + DEFAULT_MODE_ID, + ); + return { session, acpSession, configOptions }; + } + + /** + * Re-check whether the on-disk token is usable; does NOT trigger an + * actual OAuth flow. The stdio JSON-RPC channel has no TTY to render + * the device-code prompt — clients are expected to spawn + * `kimi login` themselves via the terminal-auth method advertised in + * `initialize.authMethods` (`args:['login']`, see {@link TERMINAL_AUTH_METHOD}) + * and then re-invoke `authenticate('login')` to confirm the token + * landed on disk. Mirrors kimi-cli `acp/server.py:374-398` semantics + * (plan G3, lines 68-104). + */ + async authenticate(params: AuthenticateRequest): Promise { + if (params.methodId !== 'login') { + throw RequestError.invalidParams( + { methodId: params.methodId }, + `Unknown auth method: ${params.methodId}`, + ); + } + if (!(await harnessIsAuthed(this.harness))) { + throw RequestError.authRequired(); + } + // void = empty success body (ACP allows AuthenticateResponse | void). + } + + async prompt(params: PromptRequest): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + throw RequestError.invalidParams(undefined, `Unknown sessionId: ${params.sessionId}`); + } + return acpSession.prompt(params.prompt); + } + + async cancel(params: CancelNotification): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + // `cancel` is a JSON-RPC notification — the spec forbids notifications + // returning errors. Log so unknown sessionIds aren't silently absorbed. + log.warn('acp: cancel for unknown sessionId', { sessionId: params.sessionId }); + return; + } + try { + await acpSession.cancel(); + } catch (err) { + // Same notification-cannot-error rule: log and swallow. + log.warn('acp: error while cancelling session', { + sessionId: params.sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + /** + * Handle ACP `session/set_mode`. Looks the session up by id and + * forwards to {@link AcpSession.setMode}. Unknown session ids throw + * `invalid_params`; unknown modeIds throw `invalid_params` from + * inside {@link AcpSession.setMode}. + * + * The ACP schema models the response as a `_meta`-only object; we + * return `undefined` (allowed by the `Agent` interface's + * `SetSessionModeResponse | void` union) so the wire payload is the + * canonical empty success. + */ + async setSessionMode(params: SetSessionModeRequest): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } + await acpSession.setMode(params.modeId); + } + + /** + * Handle the experimental ACP `session/set_model` + * (`unstable_setSessionModel`). Looks the session up by id and + * forwards to {@link AcpSession.setModel}. Errors from the SDK + * (e.g. an unknown model) propagate as-is so the JSON-RPC layer can + * surface a structured failure. + */ + async unstable_setSessionModel( + params: SetSessionModelRequest, + ): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } + await acpSession.setModel(params.modelId); + } + + /** + * Handle ACP `session/set_config_option` — the spec's generic + * config-picker dispatch (PLAN D11). Routes by `params.configId`: + * + * - `'model'` → {@link AcpSession.setModel} (same path as + * {@link unstable_setSessionModel}). + * - `'mode'` → {@link AcpSession.setMode} (same path as + * {@link setSessionMode}). + * - anything else → JSON-RPC `invalid_params` (-32602) BEFORE any + * SDK call, so the client sees a structured rejection rather + * than a half-applied state change. + * + * The underlying {@link AcpSession} methods already emit + * `config_option_update` via {@link AcpSession.emitConfigOptionUpdate} + * after the SDK call lands, so the response handler does NOT + * double-emit — it only builds a fresh snapshot from the now-current + * `currentModelId` + `currentModeId` and returns it on the wire. + * This funnels all three input paths + * (`unstable_setSessionModel` / `setSessionMode` / `setSessionConfigOption`) + * through the same notification channel with identical shape. + */ + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + ): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } + const value = (params as { value: unknown }).value; + switch (params.configId) { + case 'model': + await acpSession.setModel(String(value)); + break; + case 'mode': + await acpSession.setMode(String(value)); + break; + case 'thinking': { + // Phase 16 changed the wire shape from boolean to a 2-entry + // `select` (`'on'` / `'off'`) for Zed UI compatibility. Strict + // equality with `'on'` keeps the parse deterministic — any + // other string (including a stale `true` / `false` boolean + // sent by a pre-Phase-16 client) reads as "off" rather than + // silently flipping based on truthiness. + await acpSession.setThinking(value === 'on'); + break; + } + default: + throw RequestError.invalidParams( + { configId: params.configId }, + `Unknown configId: ${params.configId}`, + ); + } + return { + configOptions: await buildSessionConfigOptions( + this.harness, + acpSession.currentModelId, + acpSession.currentThinkingEnabled, + acpSession.currentModeId, + ), + }; + } + + /** + * Handle ACP `session/list`. Forwards to + * {@link KimiHarness.listSessions} (optionally filtered by `cwd` — + * the SDK calls it `workDir`) and projects each + * {@link SessionSummary} into an ACP {@link SessionInfo}. + * + * No pagination support in this version — `nextCursor` is always + * `null`. Mirrors the Python reference at `acp/server.py:303-322` + * where the response is built in a single shot from the harness' + * full snapshot. + */ + async listSessions(params: ListSessionsRequest): Promise { + // ACP `cwd` ↔ SDK `workDir`. The filter is optional; treat + // `null` (the schema-allowed sentinel for "no filter") the same + // as `undefined`. + const cwd = params.cwd ?? undefined; + const summaries = await this.harness.listSessions( + cwd === undefined ? {} : { workDir: cwd }, + ); + const sessions: SessionInfo[] = summaries.map((summary) => + sessionSummaryToSessionInfo(summary), + ); + return { sessions, nextCursor: null }; + } + + /** + * Stub the ACP `ext/` extension surface. The interface + * declares both `extMethod` and `extNotification` as optional, but + * implementing them explicitly with a structured `MethodNotFound` + * response gives clients a uniform failure shape (mirrors the + * `authenticate` pattern at {@link AcpServer.authenticate}) — some + * clients treat "method absent on the agent" differently from an + * explicit error reply. + * + * Future work (PLAN D9): route slash-command bridge / model-list / + * mode-list extensions through here once the adapter has access to + * the kimi-code app's registry. Phase 11 keeps it as a no-op stub. + */ + async extMethod( + method: string, + _params: Record, + ): Promise> { + throw RequestError.methodNotFound(method); + } + + /** + * Stub the ACP extension-notification surface. Symmetric to + * {@link extMethod}: throwing `MethodNotFound` here surfaces a + * structured failure on the JSON-RPC channel rather than a silent + * drop. The ACP SDK currently models notifications as void-returning + * promises; throwing is the only way to signal "unsupported" back to + * the connection layer. + */ + async extNotification(method: string, _params: Record): Promise { + throw RequestError.methodNotFound(method); + } + + /** + * Compute the `currentValue` for the `model` config option when the + * caller (either `newSession` or `loadSession`'s fallback path) does + * not have a more specific signal. Prefers the harness's configured + * `defaultModel`; otherwise falls back to the first listed catalog + * alias so the dropdown's "current" highlight is always one of the + * options the client will render. Returns the empty string when the + * harness has no models at all — a degenerate config the UI can still + * render (an empty dropdown with an empty `currentValue`). + * + * Tolerant to partial-stub harnesses (`getConfig` missing or + * throwing) — adapter-level unit tests routinely construct minimal + * `KimiHarness` shapes that only stub `auth.status` + `createSession`. + * Production callers always supply a real harness with both methods; + * the swallow-and-fallback path exists purely for test ergonomics + * (matches the `track?.bind(...)` pattern at `trackSessionStarted`). + * + * Logged at `warn` when a fallback fires so a dev who forgot to set + * `default_model = ...` sees a breadcrumb in the agent log. + */ + private async resolveCurrentModelId(): Promise { + // Minimal-stub harnesses (no `getConfig`) skip the catalog entirely + // and return the empty string silently. The old code path was the + // same — `listAvailableModels` used to live behind a + // `typeof harness.listAvailableModels === 'function'` guard, and we + // preserve that ergonomic so adapter unit tests with bare-bones + // stubs don't fire spurious "no models" warnings. + if (typeof this.harness.getConfig !== 'function') return ''; + try { + const config = await this.harness.getConfig(); + const declared = config.defaultModel; + if (typeof declared === 'string' && declared.length > 0) { + return declared; + } + } catch (err) { + log.warn('acp: harness.getConfig threw during configOptions assembly; falling back', { + error: err instanceof Error ? err.message : String(err), + }); + return ''; + } + try { + const models = await listModelsFromHarness(this.harness); + if (models.length === 0) { + log.warn('acp: harness exposes no models; configOptions will ship an empty model picker'); + return ''; + } + log.warn( + 'acp: harness has no defaultModel; falling back to first catalog entry for configOptions.currentValue', + { fallbackModelId: models[0]!.id }, + ); + return models[0]!.id; + } catch (err) { + log.warn('acp: listModelsFromHarness threw during configOptions assembly', { + error: err instanceof Error ? err.message : String(err), + }); + } + return ''; + } + + /** + * Compute the initial value for the `thinking` toggle when + * a session is created (or loaded with no persisted thinking state). + * Reads the harness's `getConfig().defaultThinking` flag if exposed — + * the same source `Session.createSession` would consult for new + * sessions. Returns `false` when the harness has no opinion, so the + * toggle starts off. + * + * Tolerant to partial-stub harnesses for the same reason + * {@link resolveCurrentModelId} is — adapter-level unit tests + * routinely omit `getConfig`. The swallow-and-fallback path keeps + * the test ergonomics symmetric. + */ + private async resolveCurrentThinkingEnabled(): Promise { + if (typeof this.harness.getConfig !== 'function') return false; + try { + const config = await this.harness.getConfig(); + const declared = (config as { defaultThinking?: unknown }).defaultThinking; + if (typeof declared === 'boolean') return declared; + if (typeof declared === 'string') { + const normalized = declared.trim().toLowerCase(); + return normalized !== 'off' && normalized.length > 0; + } + return false; + } catch (err) { + log.warn('acp: harness.getConfig threw during thinking toggle resolution; defaulting to off', { + error: err instanceof Error ? err.message : String(err), + }); + return false; + } + } + + /** + * Build a {@link TelemetryTrackFn} wrapper bound to the underlying + * harness so the {@link AcpSession} (and its reverse-RPC bridges in + * Phase 13) can emit PII-free breadcrumbs through the same + * `harness.track` channel `trackSessionStarted` uses. The wrapper + * shape is required by the broader `Record` properties + * type {@link TelemetryTrackFn} uses — the harness's own `track` is + * typed against the narrower `TelemetryProperties` (a + * `Readonly>`), + * and TS won't widen the parameter type implicitly when assigning into + * a function-valued field. Phase 13's call sites (`session.ts:790,797,820,822,717`) + * only emit primitive-valued properties so the runtime narrowing is + * upheld by construction; the cast is purely a compile-time bridge. + * + * Returns `undefined` when the harness lacks `.track` (unit-test + * stubs); {@link AcpSession} treats absence as "silent passthrough" + * via {@link safeTrack}. + */ + private makeTelemetryTrack(): TelemetryTrackFn | undefined { + const harness = this.harness; + if (typeof harness.track !== 'function') return undefined; + return (event, properties) => { + // Cast: the harness expects the narrower `TelemetryProperties` + // shape (Readonly>); Phase 13 callers + // only pass primitive values so the runtime contract holds. + harness.track(event, properties as Parameters[1]); + }; + } + + /** + * Push the one-shot `available_commands_update` session_update that + * ACP clients use to populate a slash-command palette. Called once + * per session, immediately after the {@link AcpSession} is + * registered (so the wire id is stable when the notification + * arrives) but before the public RPC reply is returned (so a client + * that synchronously sets up listeners on session creation cannot + * miss the event). + * + * The kimi-code slash-command registry lives in + * `apps/kimi-code/src/tui/commands/registry.ts` — i.e. an app-level + * concern that the acp-adapter (a `packages/` library) has no + * access to. We emit an empty list so the client sees a + * deterministic update; a richer surface is deferred to a future + * step (PLAN D9 / ext_method). + * + * Errors are caught and logged, never thrown: pushing + * `session/update` is a streaming concern, not load-bearing for + * the `session/new` (or `session/load`) reply. + */ + private async emitAvailableCommandsUpdate(sessionId: string): Promise { + if (!this.conn) return; + try { + await this.conn.sessionUpdate(availableCommandsUpdateNotification(sessionId)); + } catch (err) { + log.warn('acp: failed to push available_commands_update', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + /** + * Emit the single ACP-adapter telemetry event. + * + * Wraps {@link KimiHarness.track} so a partial harness stub + * (common in unit tests — see `auth-gate.test.ts`, `session-new.test.ts`) + * cannot crash the request path. Production callers always supply a + * real harness with `.track`; the swallow-and-log fallback exists + * purely for test ergonomics. + * + * Property set is deliberately minimal: `sessionId` + `mode`. No + * user content, no IDE identity, no client capabilities — keeping + * the breadcrumb PII-free. + */ + private trackSessionStarted(sessionId: string, mode: 'new' | 'load' | 'resume'): void { + const track = this.harness.track?.bind(this.harness); + if (typeof track !== 'function') return; + try { + track('acp_session_started', { sessionId, mode }); + } catch (err) { + log.warn('acp: telemetry track failed', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Drive an {@link AcpServer} over an arbitrary ACP {@link Stream}. + * + * Useful for tests that build the stream with `ndJsonStream` over an + * in-memory pair instead of process stdio. + */ +export async function runAcpServerWithStream( + harness: KimiHarness, + stream: Stream, + opts?: { + agentInfo?: Implementation; + terminalAuthEnv?: Readonly>; + terminalAuthLegacyCommand?: string; + }, +): Promise { + const conn = new AgentSideConnection((c) => new AcpServer(harness, c, opts), stream); + await conn.closed; +} + +/** + * Drive an {@link AcpServer} over Node stdio (or the supplied streams). + * + * The ACP SDK speaks Web `ReadableStream` / `WritableStream`, so Node stdio + * is bridged through `Readable.toWeb` / `Writable.toWeb`. + * + * Phase 11.1 wires SIGINT / SIGTERM to a single-shot cleanup that calls + * {@link KimiHarness.close} so an editor terminating the agent process + * (Zed closing the panel, JetBrains stopping the run config, the user + * pressing Ctrl-C) drains in-flight sessions before the OS reaps the + * process. The handlers are installed via `.once(...)` and explicitly + * uninstalled in `finally` so repeat invocations from tests do not + * pollute the process-wide listener set. + * + * The `signals` option exists primarily for tests — production callers + * use the default of `process`. A test can pass a fresh + * `EventEmitter`, emit `'SIGINT'` on it, and assert `harness.close()` + * was called exactly once without touching the real Node signal + * handlers (which vitest itself relies on). + */ +export async function runAcpServer( + harness: KimiHarness, + opts?: { + input?: NodeJS.ReadableStream; + output?: NodeJS.WritableStream; + /** + * Optional agent identity metadata advertised in the `initialize` + * response (`InitializeResponse.agentInfo`). When omitted, the + * field is left out of the response rather than serialized as + * `null`, matching the kimi-cli reference implementation. + */ + agentInfo?: Implementation; + /** + * Env vars to forward to the `kimi login` subprocess clients spawn + * via `terminal-auth`. See {@link AcpServer} ctor for the use case. + */ + terminalAuthEnv?: Readonly>; + /** + * Absolute path to the agent binary, advertised in the legacy + * `_meta['terminal-auth'].command` fallback. See {@link AcpServer} + * ctor for compatibility rationale. + */ + terminalAuthLegacyCommand?: string; + /** + * @internal Test seam — supply a fake `EventEmitter` (or a + * subset that exposes `.once` / `.off`) to drive SIGINT / SIGTERM + * without touching the real `process` listener set. Defaults to + * `process` in production. + */ + signals?: Pick; + }, +): Promise { + // Stdout is the JSON-RPC channel; protect it before anything else + // (a dependency, harness, etc.) can emit non-JSON via console.log. + redirectConsoleToStderr(); + const input = (opts?.input ?? process.stdin) as Readable; + const output = (opts?.output ?? process.stdout) as Writable; + const stream = ndJsonStream(Writable.toWeb(output), Readable.toWeb(input)); + const signals = opts?.signals ?? process; + + let cleanedUp = false; + const cleanup = async (signal?: NodeJS.Signals): Promise => { + // Idempotent: signal-then-natural-close (or vice-versa) must not + // call `harness.close()` twice. `cleanedUp` is checked-and-set + // synchronously so concurrent invocations cannot race. + if (cleanedUp) return; + cleanedUp = true; + if (signal) { + log.info('acp: received signal, draining harness', { signal }); + } + try { + await harness.close(); + } catch (err) { + // The process is exiting either way; log so the diagnostic is + // preserved rather than disappearing into a thrown promise. + log.error('acp: harness close failed during shutdown', { + error: err instanceof Error ? err.message : String(err), + }); + } + }; + + const onSigint = (): void => { + void cleanup('SIGINT'); + }; + const onSigterm = (): void => { + void cleanup('SIGTERM'); + }; + signals.once('SIGINT', onSigint); + signals.once('SIGTERM', onSigterm); + + try { + // Resolves when `AgentSideConnection.closed` settles — either + // because the client disconnected stdin (natural EOF) or because + // a signal handler closed the underlying stream. + await runAcpServerWithStream(harness, stream, { + agentInfo: opts?.agentInfo, + terminalAuthEnv: opts?.terminalAuthEnv, + terminalAuthLegacyCommand: opts?.terminalAuthLegacyCommand, + }); + } finally { + // Uninstall BEFORE the final cleanup so a second SIGINT (a user + // double-tapping Ctrl-C while the drain is in flight) propagates + // to the default handler and force-kills the process — exactly + // the behaviour terminal users expect. + signals.off('SIGINT', onSigint); + signals.off('SIGTERM', onSigterm); + await cleanup(); + } +} + +/** + * Project a Kimi SDK {@link SessionSummary} into the ACP + * {@link SessionInfo} shape used by `session/list`. + * + * Field mapping (mirrors the Python reference at + * `acp/server.py:303-322`): + * - `sessionId` ← `summary.id`. + * - `cwd` ← `summary.workDir` (the SDK's name for the same + * concept; ACP picked `cwd` and the rename happens + * at every boundary in this adapter). + * - `title` ← `summary.title` when present; otherwise omitted + * (ACP's `title` is `string | null | undefined`). + * Empty strings are normalized to `null` so the + * client can detect "no title" via `=== null` + * rather than chasing falsy semantics. + * - `updatedAt` ← `new Date(summary.updatedAt).toISOString()`. The + * SDK stores epoch ms (`number`); ACP wants ISO 8601. + * Invalid timestamps fall back to `null` rather + * than producing `Invalid Date` strings on the wire. + */ +function sessionSummaryToSessionInfo(summary: SessionSummary): SessionInfo { + let updatedAt: string | null = null; + if (typeof summary.updatedAt === 'number' && Number.isFinite(summary.updatedAt)) { + const date = new Date(summary.updatedAt); + if (!Number.isNaN(date.getTime())) { + updatedAt = date.toISOString(); + } + } + const titleRaw = summary.title; + const title = typeof titleRaw === 'string' && titleRaw.length > 0 ? titleRaw : null; + return { + sessionId: summary.id, + cwd: summary.workDir, + title, + updatedAt, + }; +} diff --git a/packages/acp-adapter/src/session.ts b/packages/acp-adapter/src/session.ts new file mode 100644 index 000000000..a281ab103 --- /dev/null +++ b/packages/acp-adapter/src/session.ts @@ -0,0 +1,1256 @@ +import { + RequestError, + type AgentSideConnection, + type ClientCapabilities, + type ContentBlock, + type ModelId, + type PromptResponse, + type SessionModeId, +} from '@agentclientprotocol/sdk'; +import { LocalKaos, runWithKaos, type Kaos } from '@moonshot-ai/kaos'; +import { + ErrorCodes, + log, + type ApprovalRequest, + type ApprovalResponse, + type ContextMessage, + type KimiErrorPayload, + type KimiHarness, + type QuestionAnswers, + type QuestionRequest, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { + approvalRequestToPermissionOptions, + attachSelectedLabel, + buildPermissionToolCallUpdate, + permissionResponseToApprovalResponse, +} from './approval'; +import { buildSessionConfigOptions } from './config-options'; +import { acpBlocksToPromptParts } from './convert'; +import { AcpKaos } from './kaos-acp'; +import { + acpToolCallId, + assistantDeltaToSessionUpdate, + configOptionUpdateNotification, + planFromDisplayBlock, + stringifyArgs, + thinkingDeltaToSessionUpdate, + toolCallDeltaToSessionUpdate, + toolCallLazyCreateToSessionUpdate, + toolCallStartedUpgradeToSessionUpdate, + toolCallStartToSessionUpdate, + toolProgressToSessionUpdate, + toolResultToSessionUpdate, + turnEndReasonToStopReason, +} from './events-map'; +import { acpModeToToggles, DEFAULT_MODE_ID, isAcpModeId, type AcpModeId } from './modes'; +import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; + +/** + * Telemetry sink threaded into {@link AcpSession} so reverse-RPC bridges + * (`handleApproval`, `handleQuestion`) can emit PII-free breadcrumbs + * without reaching back through the harness. Optional — when absent, + * the session is a silent passthrough (matches the Phase 11.2 stub- + * tolerant pattern in `server.ts:trackSessionStarted`). + */ +export type TelemetryTrackFn = ( + event: string, + properties?: Record, +) => void; + +/** + * Adapter-side wrapper around a {@link Session} from the Kimi node SDK. + * + * Stored in `AcpServer.sessions` so subsequent `session/prompt` and + * `session/cancel` calls can locate the underlying SDK session by its + * ACP `sessionId`. The `conn` field holds the {@link AgentSideConnection} + * so `prompt()` can emit `session/update` chunks back to the client + * without re-plumbing the connection through the call stack. + */ +export class AcpSession { + /** + * The most recently observed turnId from the underlying SDK event + * stream. Used by {@link handleApproval} to compose the prefixed ACP + * `toolCallId` (`${turnId}:${rawId}`) so the client can correlate the + * permission prompt with the tool card it has already rendered. + * + * Updated inside the existing `onEvent` listener in {@link prompt} + * (any event carrying a numeric `turnId` advances the value), and + * reset to `undefined` on `turn.ended`. Approval flows are gated by + * the SDK on the active turn so a stale value is effectively + * unreachable in practice; the `undefined` fallback in + * `buildPermissionToolCallUpdate` exists for defence-in-depth. + */ + private currentTurnId: number | undefined = undefined; + + /** + * Lazily-built inner {@link Kaos} (a {@link LocalKaos}) that the + * per-prompt {@link AcpKaos} wraps. Cached on the session so we don't + * re-probe the environment on every prompt. Built lazily because the + * majority of sessions (those whose client does not advertise the FS + * capability) never need it. + */ + private innerKaos: Kaos | undefined = undefined; + + /** + * The adapter-side authoritative current BASE model id (no + * `,thinking` suffix) for the `configOptions` model picker (PLAN D11). + * Updated by {@link setModel} after the SDK call lands. Phase 15 + * decoupled thinking from the model id — see + * {@link currentThinkingEnabledInternal} — so this field never carries + * a `,thinking` suffix even when the client originally sent one + * through `unstable_setSessionModel`. + */ + private currentModelIdInternal: string; + + /** + * The adapter-side authoritative current thinking-toggle state. + * Phase 15 split this out of the model id so the client renders a + * separate boolean `SessionConfigOption` (the spec's + * `'thought_level'` category) instead of an inlined `,thinking` + * variant row in the model dropdown. Updated by {@link setThinking} + * and by {@link setModel} when the caller passed a merged + * `${id},thinking` form (legacy `unstable_setSessionModel` + * compatibility). + * + * Maps to the SDK's effort-level string at the boundary: + * `true` → `'high'` (the typical default for kimi-code), `false` + * → `'off'`. The granularity of `'low' | 'medium' | 'xhigh' | 'max'` + * is intentionally not surfaced — the ACP `thinking` axis is binary + * (Phase 16 wire form: 2-entry `select` `off` / `on`; pre-Phase-16 + * was `SessionConfigBoolean`). + */ + private currentThinkingEnabledInternal = false; + + /** + * The adapter-side authoritative current mode id. Updated by + * {@link setMode} after both SDK toggles (`setPlanMode` + `setPermission`) + * land so the next `config_option_update` notification reflects the + * new mode. Always one of the four PLAN D9 literals. + */ + private currentModeIdInternal: AcpModeId = DEFAULT_MODE_ID; + + constructor( + readonly conn: AgentSideConnection, + readonly session: Session, + /** + * Capabilities the client declared during `initialize`. Passed in + * by `AcpServer.newSession` so `prompt()` can decide whether to + * route file I/O through ACP reverse-RPC (`fs.readTextFile` / + * `fs.writeTextFile`) or fall back to local FS. Optional because + * adapter-level unit tests still construct `AcpSession` with the + * two-arg form; absence means "no FS reverse-RPC". + */ + private readonly clientCapabilities?: ClientCapabilities, + /** + * Optional telemetry sink. `AcpServer` threads in + * `harness.track?.bind(harness)` (Phase 11.2 PII-free pattern); unit + * tests that construct `AcpSession` with a stub session leave this + * undefined and the bridges become silent. Internal emits use the + * {@link safeTrack} guard so a missing or throwing sink can never + * crash a reverse-RPC handler. + */ + private readonly track?: TelemetryTrackFn, + /** + * Initial value of the adapter-side current BASE model id, supplied by + * the server when creating / loading the session so the first + * `config_option_update` snapshot matches the response's + * `configOptions.model.currentValue`. Defaults to empty string when + * absent (adapter-level unit tests). Phase 15: must be the bare model + * key (no `,thinking` suffix); thinking is carried separately by + * {@link initialThinkingEnabled}. + */ + initialModelId?: string, + /** + * Harness reference used by {@link emitConfigOptionUpdate} to + * re-list available models when emitting the post-change snapshot. + * Optional because adapter-level unit tests build `AcpSession` + * without a harness; when absent, `emitConfigOptionUpdate` is a + * silent no-op (matches the {@link safeTrack} pattern). Phase 14.3 + * introduces this so the model + mode picker funnel can refresh + * the full SessionConfigOption[] snapshot on every change. + */ + private readonly harness?: KimiHarness, + /** + * Initial value of the adapter-side thinking-toggle state, supplied + * by the server when creating / loading the session. Phase 15 + * introduces this so resumed sessions whose persisted + * `thinkingLevel` was non-`'off'` start with the toggle on. + * Defaults to `false` when absent. + */ + initialThinkingEnabled?: boolean, + ) { + this.currentModelIdInternal = initialModelId ?? ''; + this.currentThinkingEnabledInternal = initialThinkingEnabled ?? false; + // Register the approval bridge once, at session-construction time — + // NOT per-prompt — because `setApprovalHandler` is scoped to the + // SDK session, not the individual turn. The handler captures `this` + // lexically; the arrow form avoids re-binding on every event. + // + // Defensive: the real `Session` class always provides this method, + // but partial-stub `Session` instances used in adapter-level unit + // tests may omit it. Treat absence as "no approval channel" rather + // than crashing the constructor — the SDK still works end-to-end, + // just without reverse-RPC approvals. + if (typeof this.session.setApprovalHandler === 'function') { + this.session.setApprovalHandler((req) => this.handleApproval(req)); + } + // Same pattern as the approval handler, but for the AskUserQuestion + // reverse-RPC channel (Phase 13.1). Pre-Phase-13 builds of the SDK + // do not expose `setQuestionHandler`, and unit-test stubs may omit + // it; the `typeof === 'function'` guard keeps both cases working. + if (typeof this.session.setQuestionHandler === 'function') { + this.session.setQuestionHandler(async (req) => this.handleQuestion(req)); + } + } + + /** ACP-level session identifier — matches the underlying SDK session id. */ + get id(): string { + return this.session.id; + } + + /** + * Adapter-side authoritative current BASE model id (no `,thinking` + * suffix), used by {@link AcpServer.setSessionConfigOption} to build + * the response's `configOptions` snapshot after a model / mode / + * thinking change. + */ + get currentModelId(): string { + return this.currentModelIdInternal; + } + + /** + * Adapter-side authoritative thinking-toggle state, used by + * {@link AcpServer.setSessionConfigOption} to build the response's + * `configOptions` snapshot. + */ + get currentThinkingEnabled(): boolean { + return this.currentThinkingEnabledInternal; + } + + /** + * Adapter-side authoritative current mode id, used by + * {@link AcpServer.setSessionConfigOption} to build the response's + * `configOptions` snapshot after a model / mode change. + */ + get currentModeId(): AcpModeId { + return this.currentModeIdInternal; + } + + /** + * Forward an ACP `session/cancel` notification to the underlying SDK + * session. The SDK's `cancel()` is idempotent at the RPC layer, so + * repeated cancels (or a cancel on an already-finished turn) are + * acceptable. + */ + async cancel(): Promise { + await this.session.cancel(); + } + + /** + * Forward an ACP `session/set_model` (`unstable_setSessionModel`) + * request to the underlying SDK session. + * + * ACP allows model identifiers like `"kimi-k2,thinking"` where the + * `,thinking` suffix signals "always-thinking" mode (mirrors the + * Python ref's `_ModelIDConv.from_acp_model_id` at + * `kimi-cli/src/kimi_cli/acp/server.py:425-433`). Phase 15 decoupled + * thinking from the model id at the ACP surface — it's now its own + * `thought_level` config option (Phase 16 wire form: 2-entry `select` + * `off` / `on`) — but this legacy compat path is + * kept: when the caller sends a merged form, we split it into the + * bare model key (forwarded to `Session.setModel`) plus a thinking + * flag (forwarded to `Session.setThinking`). + * + * Wire semantics: + * - `'kimi-v2'` → setModel('kimi-v2'); thinking state unchanged. + * - `'kimi-v2,thinking'` → setModel('kimi-v2') + setThinking('high'); + * thinking state flips on. + * + * Note the asymmetry: a bare model id does NOT turn thinking OFF. + * That keeps the model / thinking axes orthogonal — model changes + * preserve thinking state. To explicitly disable thinking, the + * client must call `setSessionConfigOption({ configId: 'thinking', + * value: false })` (or send `setThinking('off')` directly through + * the SDK channel, but the ACP surface only exposes the boolean). + * + * `currentModelIdInternal` is updated to the bare key — the snapshot + * therefore never carries a `,thinking` suffix in the model option's + * `currentValue`. Thinking visibility in the snapshot is governed + * by `currentThinkingEnabledInternal` and + * {@link buildSessionConfigOptions}'s `thinkingSupported` gate. + * + * Unknown model errors bubble up from the SDK as-is; the caller in + * `AcpServer.unstable_setSessionModel` decides how to translate them. + */ + async setModel(modelId: ModelId): Promise { + const suffix = ',thinking'; + const hasSuffix = modelId.endsWith(suffix); + const baseKey = hasSuffix ? modelId.slice(0, -suffix.length) : modelId; + await this.session.setModel(baseKey); + if (hasSuffix && typeof this.session.setThinking === 'function') { + await this.session.setThinking(THINKING_ON_LEVEL); + this.currentThinkingEnabledInternal = true; + } + this.currentModelIdInternal = baseKey; + await this.emitConfigOptionUpdate(); + } + + /** + * Forward an ACP thinking-toggle change to the underlying SDK. + * + * Phase 15 introduces this as the new canonical channel for the + * thinking axis. Boolean → effort-level mapping: + * - `true` → `Session.setThinking('high')` (kimi-code's typical + * default; the agent-core `resolveThinkingEffort` would also + * coerce a missing config to `'high'`). + * - `false` → `Session.setThinking('off')`. + * + * Tolerant to partial-stub `Session` instances (adapter-level unit + * tests construct minimal fakes that may omit `setThinking`): when + * the method is missing we still update the adapter-side toggle + * state and emit the snapshot, so the ACP wire stays consistent — + * the test simply doesn't observe an SDK call. + * + * Always emits a `config_option_update` notification afterwards so + * the client sees the toggle reflect the new value, even if it + * came in through the funnel and the response itself already + * carries a fresh snapshot. + */ + async setThinking(enabled: boolean): Promise { + if (typeof this.session.setThinking === 'function') { + await this.session.setThinking(enabled ? THINKING_ON_LEVEL : THINKING_OFF_LEVEL); + } + this.currentThinkingEnabledInternal = enabled; + await this.emitConfigOptionUpdate(); + } + + /** + * Forward an ACP `session/set_mode` request to the underlying SDK + * session. + * + * Phase 12.2 supports the full 4-mode taxonomy (PLAN D9 at + * `PLAN.md:85-106`): + * + * - `'default'` → `setPlanMode(false)` + `setPermission('manual')` + * - `'plan'` → `setPlanMode(true)` + `setPermission('manual')` + * - `'auto'` → `setPlanMode(false)` + `setPermission('auto')` + * - `'yolo'` → `setPlanMode(false)` + `setPermission('yolo')` + * + * Order inside every arm is `setPlanMode` → `setPermission` → + * `emitConfigOptionUpdate`. The dispatch table lives in + * {@link acpModeToToggles} so the registry of modes and the toggles + * each mode maps to stay co-located. + * + * Phase 14.3 (PLAN D11) emits the generic `config_option_update` + * notification in place of Phase 12's `current_mode_update` — model + * and mode pickers share the same notification channel now so a + * client that listens for either change has exactly one subscription + * point. + * + * No idempotency optimisation (PLAN D9 line 105): even if the client + * re-asserts the current mode, both SDK calls fire and a fresh + * `config_option_update` notification is emitted. + * + * Error policy: + * - Unknown `modeId` → JSON-RPC `invalid_params` (-32602) BEFORE any + * SDK call, so the client sees a structured rejection rather than + * a partial state change. + * - SDK errors from `setPlanMode` or `setPermission` propagate + * as-is up to {@link AcpServer.setSessionMode}. When either throws, + * the `config_option_update` notification is suppressed (the client + * will see the rejection and can re-query state). + */ + async setMode(modeId: SessionModeId): Promise { + if (!isAcpModeId(modeId)) { + throw RequestError.invalidParams({ modeId }, `Unknown sessionModeId: ${modeId}`); + } + const { plan, permission } = acpModeToToggles(modeId); + await this.session.setPlanMode(plan); + await this.session.setPermission(permission); + this.currentModeIdInternal = modeId; + await this.emitConfigOptionUpdate(); + } + + /** + * Push a `config_option_update` session notification carrying the + * full {@link SessionConfigOption}[] snapshot computed from the + * adapter-side `currentModelId` + `currentModeId` authoritative state. + * + * Called from {@link setModel} and {@link setMode} after the SDK + * toggle(s) succeed. Tolerant to missing `harness` (adapter-level + * unit tests construct `AcpSession` without one): when absent, the + * snapshot cannot be assembled and the emit is silently skipped so + * the SDK call path still completes. The failure mode is symmetric + * to {@link safeTrack}. + * + * Errors during the underlying `listModelsFromHarness` call or + * the `sessionUpdate` push are caught and logged at `warn` — same + * policy as {@link emitAvailableCommandsUpdate}: pushing a session + * update is a streaming concern, not load-bearing for the SDK call + * that triggered it. + */ + private async emitConfigOptionUpdate(): Promise { + if (!this.harness) return; + try { + const snapshot = await buildSessionConfigOptions( + this.harness, + this.currentModelIdInternal, + this.currentThinkingEnabledInternal, + this.currentModeIdInternal, + ); + await this.conn.sessionUpdate(configOptionUpdateNotification(this.id, snapshot)); + } catch (err) { + log.warn('acp: failed to emit config_option_update', { + sessionId: this.id, + error: err instanceof Error ? err.message : String(err), + }); + } + } + + /** + * Replay the underlying SDK session's persisted history as a stream + * of ACP `session/update` notifications. + * + * Used by `session/load` (`AcpServer.loadSession`) to bring a freshly + * reattached client up to the same on-screen state it would have if + * it had observed every prior `session/prompt` live. Replay is pure + * event emission: no `onEvent` subscription, no `session.prompt()` + * call, no Kaos. The method walks {@link Session.getResumeState} + * (which the node SDK populates from the on-disk session snapshot + * during `harness.resumeSession`) and synthesizes per-message + * notifications: + * + * - role `user` → `user_message_chunk` per text {@link ContentPart}. + * - role `assistant` → `agent_message_chunk` / `agent_thought_chunk` + * per text/think content, plus a `tool_call` notification per + * `toolCalls` entry. A monotonically increasing synthetic `turnId` + * starts at 1 and bumps on each assistant message so the wire ids + * (`${turnId}:${toolCallId}`) match the live emission scheme used + * in {@link runPromptBody}. + * - role `tool` → `tool_call_update` with `status: 'completed'` + * (or `'failed'` if the SDK marked the message as an error). + * `toolCallId` is looked up from the bookkeeping map populated when + * the originating assistant message was replayed. + * + * Tool calls whose result we never observe (interrupted turn, + * truncated history) are emitted as `tool_call` only — they stay in + * `in_progress` on the client, which is honest about the underlying + * state. Likewise, tool messages whose originating `toolCallId` we + * cannot find are skipped with a warning rather than crashing + * replay; the latter would deny the rest of the session a chance to + * surface. + * + * Errors thrown by individual `sessionUpdate` calls are caught and + * logged so a single transient push failure does not truncate the + * whole replay. The method awaits every push (unlike the live + * `runPromptBody` fire-and-forget path) because replay is a one-shot + * batch — completion ordering is what tells the caller (`loadSession`) + * that the response is safe to return. + */ + async replayHistory(agentId: string = 'main'): Promise { + const sessionId = this.id; + const conn = this.conn; + const resumeState = this.session.getResumeState?.(); + if (!resumeState) { + log.warn('acp: replayHistory called on session without resume state', { sessionId }); + return; + } + const agent = resumeState.agents?.[agentId]; + if (!agent) { + log.warn('acp: replayHistory found no agent state for replay', { + sessionId, + agentId, + knownAgents: resumeState.agents ? Object.keys(resumeState.agents) : [], + }); + return; + } + + let turnId = 0; + // Map from SDK toolCallId → owning synthetic turnId, populated when + // the assistant message that issued the call is replayed and read + // when the tool result lands. Lives for the duration of one replay. + const toolCallTurnIds = new Map(); + + for (const message of agent.context.history) { + try { + await this.replayMessage(message, sessionId, conn, { + getTurnId: () => turnId, + beginAssistantTurn: () => { + turnId += 1; + }, + recordToolCall: (toolCallId) => { + toolCallTurnIds.set(toolCallId, turnId); + }, + lookupToolCallTurnId: (toolCallId) => toolCallTurnIds.get(toolCallId), + }); + } catch (err) { + log.warn('acp: replayHistory failed to emit a message; continuing', { + sessionId, + role: message.role, + error: err instanceof Error ? err.message : String(err), + }); + } + } + } + + /** + * Emit ACP session updates for a single historical {@link ContextMessage}. + * + * Factored out of {@link replayHistory} so the per-message dispatch + * stays small and the outer loop is just the turnId/tool-bookkeeping + * shell. Awaits every `sessionUpdate` so the replay completes in + * order (see {@link replayHistory} JSDoc for the rationale). + */ + private async replayMessage( + message: ContextMessage, + sessionId: string, + conn: AgentSideConnection, + ctx: { + getTurnId: () => number; + beginAssistantTurn: () => void; + recordToolCall: (toolCallId: string) => void; + lookupToolCallTurnId: (toolCallId: string) => number | undefined; + }, + ): Promise { + switch (message.role) { + case 'user': + for (const part of message.content) { + if (part.type === 'text' && part.text) { + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: part.text }, + }, + }); + } + } + return; + case 'assistant': { + ctx.beginAssistantTurn(); + const turnId = ctx.getTurnId(); + for (const part of message.content) { + await this.replayAssistantContentPart(part, sessionId, conn, turnId); + } + for (const toolCall of message.toolCalls ?? []) { + ctx.recordToolCall(toolCall.id); + await this.replaySyntheticToolCall(toolCall, sessionId, conn, turnId); + } + return; + } + case 'tool': { + const rawToolCallId = message.toolCallId; + if (!rawToolCallId) { + // Tool result with no correlation id — log and skip rather + // than crash. The on-disk session is the source of truth; + // we cannot synthesize a missing id. + log.warn('acp: replayHistory skipped tool message with no toolCallId', { sessionId }); + return; + } + const turnId = ctx.lookupToolCallTurnId(rawToolCallId); + if (turnId === undefined) { + log.warn('acp: replayHistory found tool message with no matching call', { + sessionId, + toolCallId: rawToolCallId, + }); + return; + } + const isError = message.isError === true; + await conn.sessionUpdate({ + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: acpToolCallId(turnId, rawToolCallId), + status: isError ? 'failed' : 'completed', + content: toolMessageContentToAcpToolCallContent(message.content), + }, + }); + return; + } + default: + // system / unknown roles — ACP has no analogue; skip. + return; + } + } + + private async replayAssistantContentPart( + part: ContextMessage['content'][number], + sessionId: string, + conn: AgentSideConnection, + turnId: number, + ): Promise { + if (part.type === 'text' && part.text) { + await conn.sessionUpdate( + assistantDeltaToSessionUpdate(sessionId, { + type: 'assistant.delta', + turnId, + delta: part.text, + }), + ); + return; + } + if (part.type === 'think' && part.think) { + await conn.sessionUpdate( + thinkingDeltaToSessionUpdate(sessionId, { + type: 'thinking.delta', + turnId, + delta: part.think, + }), + ); + return; + } + // image_url / audio_url / video_url are skipped at this layer — + // they belong to the user input side and ACP does not have a + // dedicated assistant-media chunk. + } + + private async replaySyntheticToolCall( + toolCall: NonNullable[number], + sessionId: string, + conn: AgentSideConnection, + turnId: number, + ): Promise { + const name = toolCall.name; + const argsRaw = toolCall.arguments; + const parsedArgs = parseToolCallArguments(argsRaw); + await conn.sessionUpdate( + toolCallStartToSessionUpdate(sessionId, { + type: 'tool.call.started', + turnId, + toolCallId: toolCall.id, + name, + args: parsedArgs, + }), + ); + } + + /** + * Run an ACP `session/prompt` against the underlying SDK session. + * + * Error mapping (Phase 11.1): + * - Auth-coded errors (`AUTH_LOGIN_REQUIRED`, `PROVIDER_AUTH_ERROR`) + * surface as `RequestError.authRequired()` so the ACP client can + * drive its own re-auth UX rather than a generic internal error. + * - Everything else becomes `RequestError.internalError(...)` with + * the stack/message logged to the agent log file but NOT exposed + * to the client (the JSON-RPC layer would otherwise leak details). + * - Auth-coded failures may arrive on TWO paths: a `turn.ended` + * event with `reason: 'failed'` and an `event.error` payload, OR + * a synchronous `session.prompt(...)` rejection. Both are + * routed through {@link mapPromptError} for parity. + * + * Subscribes to the session event stream; for every `assistant.delta`, + * pushes an `agent_message_chunk` `session/update` notification to the + * client. Resolves with the ACP `PromptResponse` (containing + * `stopReason`) when a `turn.ended` event arrives. + * + * Cleanup invariants: + * - The event subscription is unsubscribed on EVERY exit path + * (success, cancel, failed turn, and `session.prompt()` rejection). + * - If `session.prompt()` rejects synchronously or asynchronously, the + * rejection is propagated as a `prompt` request error so the client + * sees a JSON-RPC error rather than a hung request. + */ + async prompt(blocks: readonly ContentBlock[]): Promise { + const parts = acpBlocksToPromptParts(blocks); + const sessionId = this.id; + const conn = this.conn; + + // Decide whether to bridge file I/O through ACP for this prompt. + // We honor the client's advertised capability surface only — + // unsupported clients silently fall back to `LocalKaos`, which keeps + // the rest of the codebase unaware of Phase 6. + // + // `runWithKaos` (NOT `setCurrentKaos`) is the correct primitive: + // `enterWith` persists for the rest of the current async context + // with no proper restore semantics, so concurrent prompts on the + // same process would step on each other. `run(...)` scopes the + // binding to the callback's async subtree. + const acpKaos = await this.maybeBuildAcpKaos(); + if (!acpKaos) { + return this.runPromptBody(parts, sessionId, conn); + } + return runWithKaos(acpKaos, () => this.runPromptBody(parts, sessionId, conn)); + } + + /** + * Build an {@link AcpKaos} for this prompt if (and only if) the + * client advertised any FS reverse-RPC capability. Returns + * `undefined` otherwise — the caller then runs the prompt body in + * whatever Kaos was active before (typically the process-wide + * `LocalKaos` set up at startup). + * + * The inner {@link LocalKaos} is built lazily on the first capable + * prompt and cached on the instance (`this.innerKaos`); subsequent + * prompts reuse it. + */ + private async maybeBuildAcpKaos(): Promise { + const fs = this.clientCapabilities?.fs; + if (!fs?.readTextFile && !fs?.writeTextFile) { + return undefined; + } + if (!this.innerKaos) { + this.innerKaos = await LocalKaos.create(); + } + return new AcpKaos(this.conn, this.id, this.innerKaos); + } + + /** + * The pre-Phase-6 body of {@link prompt}, extracted verbatim so that + * the new `runWithKaos` wrapper can apply uniformly to capable + * clients while non-capable clients hit the same code path with no + * wrapping. Splitting it out (rather than inlining the if/else twice) + * keeps the event-listener invariants — single `onEvent` subscription, + * `settled` flag semantics, `currentTurnId` reset — in one place. + */ + private runPromptBody( + parts: ReturnType, + sessionId: string, + conn: AgentSideConnection, + ): Promise { + return new Promise((resolve, reject) => { + let settled = false; + // Per-tool-call streaming args accumulator. Lives in the Promise + // executor closure so each `prompt()` invocation gets its own + // map and no state leaks across concurrent or sequential turns. + // Keyed on the **SDK** `toolCallId` (not the ACP-prefixed one) + // because the SDK delta events only carry the raw id. + const argsByToolCall = new Map(); + // Set of **wire-level** (turn-prefixed) tool-call ids for which + // we have already sent the `tool_call` CREATE notification. The + // agent-core actually emits `tool.call.delta` events BEFORE + // `tool.call.started` (deltas come from the model's args stream; + // the started event comes from the loop dispatching the call + // afterwards). Without this set, the naive "started → tool_call, + // delta → tool_call_update" mapping puts updates on the wire + // ahead of the create, and clients such as Zed surface "Tool + // call not found" until the create eventually lands. We instead + // lazy-create the wire `tool_call` on the first delta and + // downgrade the eventual started event into a `tool_call_update` + // carrying the canonical title/kind/rawInput (and any + // `display`-derived diff). + // + // Keyed on the wire id (`${turnId}:${rawToolCallId}`) — not the + // raw SDK `toolCallId` — because providers may legitimately + // reuse the same raw id across turns within one prompt, and + // each turn produces a distinct wire-level tool call that needs + // its own CREATE. + const startedToolCalls = new Set(); + const unsub = this.session.onEvent((event) => { + // Track the active turn so `handleApproval` (registered once at + // construction, called via `setApprovalHandler`) can compose the + // prefixed `${turnId}:${toolCallId}` wire id that matches the + // tool card the client already rendered. This branch is purely + // additive: it runs before the existing dispatch and never + // returns, so the if-chain below behaves exactly as in Phase 4. + if ('turnId' in event && typeof event.turnId === 'number') { + this.currentTurnId = event.turnId; + } + if (event.type === 'assistant.delta') { + // `sessionUpdate` is itself async (it serializes onto the + // ndjson stream). The text deltas form a strictly ordered + // single-producer/single-consumer pipeline, so each await + // would force the next delta to wait for the previous flush. + // Fire-and-forget keeps the stream pumping; we log push + // failures rather than dropping them silently. + conn + .sessionUpdate(assistantDeltaToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push agent_message_chunk', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if (event.type === 'thinking.delta') { + conn + .sessionUpdate(thinkingDeltaToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push agent_thought_chunk', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if (event.type === 'tool.call.started') { + // Seed the accumulator with the **stringified initial args**. + // The wire-level `tool_call_update` is REPLACE-content (not + // append) so each subsequent delta emits the cumulative args + // string; if we seeded with an empty string the first delta + // would silently drop the initial args from the rendered card. + argsByToolCall.set(event.toolCallId, { args: stringifyArgs(event.args) }); + // Branch on whether a streaming delta already lazy-created + // the wire `tool_call` for this id: + // - YES → we cannot send a second `tool_call` CREATE; emit a + // `tool_call_update` (the "upgrade") so `title`/`kind`/ + // `rawInput`/`display`-derived diff land on the existing + // card and `status` flips to `'in_progress'`. + // - NO → no prior deltas (e.g. provider doesn't stream args); + // take the original path and emit the `tool_call` CREATE. + const startedWireId = acpToolCallId(event.turnId, event.toolCallId); + if (startedToolCalls.has(startedWireId)) { + conn + .sessionUpdate(toolCallStartedUpgradeToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push tool_call_update (start upgrade)', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } else { + startedToolCalls.add(startedWireId); + conn + .sessionUpdate(toolCallStartToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push tool_call', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + // Phase 9.3: when the tool exposed a structured TodoList + // display, additionally fire a `plan` session_update so ACP + // clients can render the agent's evolving TODO list. Other + // display kinds (diff/file_io/command/…) are already folded + // into the tool_call card; only `todo_list` becomes a plan. + // The emission is fire-and-forget under the same idle-stream + // discipline as the assistant deltas above. + if (event.display) { + const planNote = planFromDisplayBlock(sessionId, event.turnId, event.display); + if (planNote !== null) { + conn.sessionUpdate(planNote).catch((err) => { + log.warn('acp: failed to push plan', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + }); + } + } + return; + } + if (event.type === 'tool.call.delta') { + // The agent-core emits these args-stream deltas BEFORE the + // `tool.call.started` event (deltas come from the provider's + // streaming phase; started is dispatched afterwards). If we + // haven't yet sent a `tool_call` CREATE for this id, do so now + // from the delta — Zed otherwise sees a `tool_call_update` + // for an unknown id and surfaces "Tool call not found" until + // the start eventually lands. + const deltaWireId = acpToolCallId(event.turnId, event.toolCallId); + if (!startedToolCalls.has(deltaWireId)) { + const initial = event.argumentsPart ?? ''; + argsByToolCall.set(event.toolCallId, { args: initial }); + startedToolCalls.add(deltaWireId); + conn + .sessionUpdate(toolCallLazyCreateToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push tool_call (lazy create from delta)', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + // Subsequent delta — accumulate then emit an update with the + // cumulative args text (REPLACE-content semantics). + let acc = argsByToolCall.get(event.toolCallId); + if (!acc) { + acc = { args: '' }; + argsByToolCall.set(event.toolCallId, acc); + } + conn + .sessionUpdate(toolCallDeltaToSessionUpdate(sessionId, event, acc)) + .catch((err) => { + log.warn('acp: failed to push tool_call_update (delta)', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if (event.type === 'tool.progress') { + const note = toolProgressToSessionUpdate(sessionId, event); + if (note === null) return; + conn.sessionUpdate(note).catch((err) => { + log.warn('acp: failed to push tool_call_update (progress)', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if (event.type === 'tool.result') { + conn + .sessionUpdate(toolResultToSessionUpdate(sessionId, event)) + .catch((err) => { + log.warn('acp: failed to push tool_call_update (result)', { + sessionId, + toolCallId: event.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + }); + return; + } + if (event.type === 'turn.ended') { + if (settled) return; + settled = true; + if (event.reason === 'failed') { + // Failures bubble up via the SDK `error` payload. Phase 11.1 + // upgrades the prior "log + resolve end_turn" behaviour to + // route auth-coded failures through `RequestError.authRequired()` + // so the client can trigger its re-auth UX. Other failure + // codes still resolve with `end_turn` (the spec discourages + // signaling errors through `stopReason`; the failure is + // observable in the log). + log.warn('acp: turn ended with failed reason', { + sessionId, + error: event.error, + }); + argsByToolCall.clear(); + startedToolCalls.clear(); + this.currentTurnId = undefined; + unsub(); + const authErr = authRequiredFromPayload(event.error); + if (authErr) { + reject(authErr); + return; + } + } else { + argsByToolCall.clear(); + startedToolCalls.clear(); + // Drop the turnId so a late-arriving approval (e.g. an SDK + // reverse-RPC racing the turn boundary) falls back to the raw + // SDK id rather than re-prefixing with a stale value. + this.currentTurnId = undefined; + unsub(); + } + resolve({ stopReason: turnEndReasonToStopReason(event.reason) }); + } + }); + + this.session.prompt(parts).catch((err) => { + if (settled) return; + settled = true; + unsub(); + reject(mapPromptError(err, sessionId)); + }); + }); + } + + /** + * Bridge an SDK {@link ApprovalRequest} through the ACP reverse-RPC + * `session/request_permission`. + * + * Flow: + * 1. Build the wire-level {@link ToolCallUpdate} so the client can + * correlate the prompt with the tool card it already rendered + * (uses the prefixed `${turnId}:${rawId}` form when available). + * 2. Forward to the client via `conn.requestPermission` with the + * three canonical options (`allow_once`, `allow_always`, `reject`). + * 3. Map the response back to {@link ApprovalResponse} for the SDK. + * + * Error policy: any RPC failure (transport drop, client error, + * timeout) resolves with `decision: 'rejected'` and a structured log + * line. Rejecting on failure is strictly safer than approving when + * the client cannot confirm intent, and matches the Python + * reference's behaviour for the same edge case. + * + * The handler is registered exactly once in the constructor; this + * method is invoked by the SDK reverse-RPC layer whenever the loop + * needs human authorization to proceed with a tool call. + */ + private async handleApproval(req: ApprovalRequest): Promise { + const toolCall = buildPermissionToolCallUpdate(this.currentTurnId, req); + const options = approvalRequestToPermissionOptions(req); + // Phase 13.2 telemetry breadcrumb: how many discrete options does + // the plan_review surface carry? PII-free (just a count), matches + // the Phase 11.2 telemetry discipline. + if (req.display.kind === 'plan_review') { + const count = req.display.options?.length ?? 0; + this.emitTelemetry('plan_review_options_count', { count }); + } + try { + // `requestPermission` is an awaitable JSON-RPC request (unlike + // the fire-and-forget `sessionUpdate` notifications elsewhere in + // this file), so the SDK call site naturally blocks on the + // user's decision before the tool runs. + const response = await this.conn.requestPermission({ + sessionId: this.id, + options: [...options], + toolCall, + }); + // Map the discriminator first (pure mapper, easy to unit-test), + // then stitch the matched option's human-readable name as + // `selectedLabel` so the SDK can surface "approved as + // 'Approve once'" in subsequent reasoning. `attachSelectedLabel` + // is a no-op for `cancelled` outcomes, unknown optionIds, and + // plan_* optionIds (Phase 13.2 — the plan_review branch attaches + // selectedLabel inside `permissionResponseToApprovalResponse`). + return attachSelectedLabel( + response, + permissionResponseToApprovalResponse(req, response), + options, + ); + } catch (err) { + log.warn('acp: requestPermission failed; rejecting', { + sessionId: this.id, + toolCallId: req.toolCallId, + toolName: req.toolName, + error: err instanceof Error ? err.message : String(err), + }); + return { decision: 'rejected' }; + } + } + + /** + * Bridge an SDK {@link QuestionRequest} (the AskUserQuestion tool's + * reverse-RPC) through the same ACP + * `session/request_permission` surface used by approvals. + * + * ACP currently has no dedicated `session/request_question` method, so + * the adapter re-uses `requestPermission` and tags the options with a + * `q{n}_*` namespace so the round-trip is unambiguous. + * + * Degradation rules: + * - `req.questions.length > 1` → only the first question is asked; + * telemetry records the dropped count so we can observe how often + * multi-question prompts land in the wild. + * - `q.multiSelect === true` → still asked as single-select; the + * SDK's ask-user tool tolerates a single-key answer for a multi- + * select prompt so this is a graceful narrow rather than a hard + * fail. + * + * Error policy mirrors {@link handleApproval}: any RPC failure logs + * a warning and returns `null` so the SDK resolves the tool with the + * canonical "user dismissed" branch (`rpc.ts:567`). Returning `null` + * is strictly safer than fabricating an answer the user did not give. + */ + private async handleQuestion(req: QuestionRequest): Promise { + const questions = req.questions; + if (questions.length === 0) { + // Pathological input — log and dismiss. No telemetry: the SDK + // would never emit an empty `questions` payload in practice. + log.warn('acp: handleQuestion received empty questions array', { + sessionId: this.id, + }); + return null; + } + if (questions.length > 1) { + log.warn('acp: handleQuestion degrading to first question only', { + sessionId: this.id, + dropped: questions.length - 1, + }); + this.emitTelemetry('question_degraded', { + reason: 'multi_question', + dropped: questions.length - 1, + }); + } + const q = questions[0]!; + if (q.multiSelect === true) { + this.emitTelemetry('question_degraded', { reason: 'multi_select' }); + } + const options = questionItemToPermissionOptions(q, 0); + const rawToolCallId = req.toolCallId ?? 'ask-user'; + const toolCallId = + this.currentTurnId !== undefined + ? acpToolCallId(this.currentTurnId, rawToolCallId) + : rawToolCallId; + try { + const response = await this.conn.requestPermission({ + sessionId: this.id, + options: [...options], + toolCall: { + toolCallId, + title: 'AskUserQuestion', + content: [{ type: 'content', content: { type: 'text', text: q.question } }], + }, + }); + const answer = outcomeToQuestionAnswer(q, response); + if (answer === null) { + // Dismissed via skip / cancel / unknown optionId — telemetry + // matches the ask-user tool's existing `question_dismissed` + // event so dashboards stay coherent. + this.emitTelemetry('question_dismissed'); + } else { + this.emitTelemetry('question_answered'); + } + return answer; + } catch (err) { + log.warn('acp: requestPermission (question) failed; dismissing', { + sessionId: this.id, + toolCallId: req.toolCallId, + error: err instanceof Error ? err.message : String(err), + }); + return null; + } + } + + /** + * Fire-and-forget telemetry emitter that guards a missing or + * throwing `track` sink. Mirrors the Phase 11.2 pattern in + * `server.ts:trackSessionStarted` — telemetry must never crash a + * reverse-RPC handler. + */ + private emitTelemetry(event: string, properties?: Record): void { + if (typeof this.track !== 'function') return; + try { + this.track(event, properties); + } catch (err) { + log.warn('acp: telemetry track failed', { + sessionId: this.id, + event, + error: err instanceof Error ? err.message : String(err), + }); + } + } +} + +/** + * Map a Kimi SDK error (raw `Error`, `KimiError`, or `KimiErrorPayload`) + * into the ACP {@link RequestError} shape used by the JSON-RPC layer. + * + * Auth-coded inputs (`auth.login_required`, `provider.auth_error`) + * become `RequestError.authRequired()` so the client can drive its own + * re-auth UX. Everything else becomes `RequestError.internalError(...)` + * with the raw error logged to the agent log file but NOT exposed in + * the JSON-RPC response — the client only sees the canonical + * "session prompt failed" message, preventing accidental leakage of + * stack frames or PII through the wire. + * + * The kimi-cli Python reference performs the same mapping at + * `kimi-cli/src/kimi_cli/acp/session.py:218-247`; this is the TS port. + */ +function mapPromptError(err: unknown, sessionId: string): RequestError { + const authErr = authRequiredFromUnknown(err); + if (authErr) { + log.warn('acp: prompt rejected with auth error; mapping to authRequired', { + sessionId, + error: err instanceof Error ? err.message : String(err), + }); + return authErr; + } + log.error('acp: prompt failed', { + sessionId, + error: err instanceof Error ? { message: err.message, stack: err.stack } : String(err), + }); + return RequestError.internalError(undefined, 'session prompt failed'); +} + +/** + * Inspect a {@link KimiErrorPayload} (as carried on `turn.ended` + * failed events) and return a `RequestError.authRequired()` if its + * `code` is one of the auth-required codes; otherwise `undefined`. + * + * Kept separate from {@link authRequiredFromUnknown} because the + * `turn.ended` event hands us a serialized payload (no class identity + * to branch on) — we only need the `code` discriminator here. + */ +function authRequiredFromPayload(payload: KimiErrorPayload | undefined): RequestError | undefined { + if (!payload) return undefined; + if (isAuthErrorCode(payload.code)) { + return RequestError.authRequired(); + } + return undefined; +} + +/** + * Type-narrowing predicate for the codes the adapter treats as + * "the client must re-authenticate before retrying". Currently: + * - `auth.login_required` — Kimi Platform / OAuth login flow needed. + * - `provider.auth_error` — the downstream provider rejected the + * request with a 401 (the node SDK lifts these into `KimiError` + * at `kimi-code-model-provider.ts:99-103`). + */ +function isAuthErrorCode(code: unknown): boolean { + return code === ErrorCodes.AUTH_LOGIN_REQUIRED || code === ErrorCodes.PROVIDER_AUTH_ERROR; +} + +/** + * Best-effort detection of "auth required" for the `session.prompt(...)` + * rejection path. The thrown value MAY be: + * - A `KimiError` instance with a recognized `code` field. + * - A plain object that happens to expose a `code` (covers RPC-layer + * deserialized payloads that lost class identity). + * - Anything else — returns `undefined`. + */ +function authRequiredFromUnknown(err: unknown): RequestError | undefined { + if (err && typeof err === 'object' && 'code' in err) { + const code = (err as { code?: unknown }).code; + if (isAuthErrorCode(code)) { + return RequestError.authRequired(); + } + } + return undefined; +} + +/** + * Effort-level strings passed to {@link Session.setThinking} when the + * ACP `thinking` toggle flips. Phase 15 wired the ACP-side binary axis + * (then a `SessionConfigBoolean`; Phase 16 reshaped it to a 2-entry + * `select` `off` / `on` for Zed UI compatibility) to the SDK's + * effort-level channel: `true` → `'high'` (kimi-code's typical default, + * also `resolveThinkingEffort`'s fallback), `false` → `'off'`. The + * granularity of `'low' | 'medium' | 'xhigh' | 'max'` is intentionally + * not exposed — the ACP `thinking` axis is binary. + */ +const THINKING_ON_LEVEL = 'high'; +const THINKING_OFF_LEVEL = 'off'; + +/** + * Parse a tool call's `arguments` field (kosong wire format: a JSON + * string or `null`) into the structured object expected by the live + * {@link toolCallStartToSessionUpdate} mapper. Falls back to the raw + * string when the payload is not valid JSON — the mapper itself uses + * {@link stringifyArgs}, which gracefully `String(x)`s anything it + * cannot serialize, so the worst case is a degraded preview rather + * than a crash. + */ +function parseToolCallArguments(rawArguments: string | null): unknown { + if (rawArguments === null || rawArguments === '') return {}; + try { + return JSON.parse(rawArguments); + } catch { + return rawArguments; + } +} + +/** + * Project a `tool` role {@link ContextMessage}'s `content` array into + * the ACP `tool_call_update.content` shape (an array of + * `ToolCallContent` entries). The historical message's content is a + * sequence of kosong content parts — for replay we surface text parts + * directly and stringify anything else (image refs etc.) as a + * `[type]` placeholder so the client still sees that something was + * returned. + */ +function toolMessageContentToAcpToolCallContent( + parts: ContextMessage['content'], +): Array<{ type: 'content'; content: { type: 'text'; text: string } }> { + const result: Array<{ type: 'content'; content: { type: 'text'; text: string } }> = []; + for (const part of parts) { + if (part.type === 'text') { + if (part.text) { + result.push({ type: 'content', content: { type: 'text', text: part.text } }); + } + continue; + } + // image_url / audio_url / video_url / think — surface a marker so + // the result card is not empty. Replay should not lose evidence + // that a non-text part was present. + result.push({ + type: 'content', + content: { type: 'text', text: `[${part.type}]` }, + }); + } + return result; +} diff --git a/packages/acp-adapter/src/types.ts b/packages/acp-adapter/src/types.ts new file mode 100644 index 000000000..d4f312629 --- /dev/null +++ b/packages/acp-adapter/src/types.ts @@ -0,0 +1,29 @@ +import type { PromptResponse, ToolCallStatus, ToolKind } from '@agentclientprotocol/sdk'; + +/** + * Local alias for the ACP `stopReason` enum. + * + * Surfaced separately so internal helpers (e.g. `turnEndReasonToStopReason`) + * don't have to repeat the literal union and the file is the single place + * to look when the upstream SDK widens or renames a variant. + */ +export type AcpStopReason = PromptResponse['stopReason']; + +/** + * Local alias for the ACP `ToolCallStatus` enum. + * + * Same rationale as {@link AcpStopReason}: keep SDK-coupled enum + * names confined to this file so the rest of the adapter only sees + * project-local types. + */ +export type AcpToolCallStatus = ToolCallStatus; + +/** + * Local alias for the ACP `ToolKind` enum. + * + * The kind is heuristic-mapped from Kimi tool names by + * `events-map.inferToolKind`; aliasing here keeps the consumer side + * (UI integration / future tool registries) decoupled from the raw + * SDK type name. + */ +export type AcpToolKind = ToolKind; diff --git a/packages/acp-adapter/src/version.ts b/packages/acp-adapter/src/version.ts new file mode 100644 index 000000000..b938de251 --- /dev/null +++ b/packages/acp-adapter/src/version.ts @@ -0,0 +1,50 @@ +/** + * ACP protocol version negotiation. + * + * Ported from kimi-cli/src/kimi_cli/acp/version.py. Tracks the (negotiation + * integer, spec tag, SDK version) tuple per supported protocol revision and + * picks the highest mutually-supported one when the client initializes. + */ + +export interface AcpVersionSpec { + /** Negotiation integer used in InitializeRequest/Response. */ + readonly protocolVersion: number; + /** ACP specification tag, e.g. "v0.10.x". */ + readonly specTag: string; + /** Corresponding npm SDK semver string, e.g. "0.23.0". */ + readonly sdkVersion: string; +} + +export const CURRENT_VERSION: AcpVersionSpec = { + protocolVersion: 1, + specTag: 'v0.10.x', + sdkVersion: '0.23.0', +}; + +const SUPPORTED_VERSIONS: ReadonlyMap = new Map([ + [1, CURRENT_VERSION], +]); + +export const MIN_PROTOCOL_VERSION = 1; + +/** + * Negotiate the protocol version with the client. + * + * Returns the highest server-supported version that does not exceed the + * client's requested version. If the client version is lower than + * {@link MIN_PROTOCOL_VERSION} the server still returns its own current + * version so the client can decide whether to disconnect. + */ +export function negotiateVersion(clientProtocolVersion: number): AcpVersionSpec { + if (clientProtocolVersion < MIN_PROTOCOL_VERSION) { + return CURRENT_VERSION; + } + + let best: AcpVersionSpec | undefined; + for (const [ver, spec] of SUPPORTED_VERSIONS) { + if (ver <= clientProtocolVersion && (best === undefined || ver > best.protocolVersion)) { + best = spec; + } + } + return best ?? CURRENT_VERSION; +} diff --git a/packages/acp-adapter/test/_helpers/harness-stubs.ts b/packages/acp-adapter/test/_helpers/harness-stubs.ts new file mode 100644 index 000000000..54b11375e --- /dev/null +++ b/packages/acp-adapter/test/_helpers/harness-stubs.ts @@ -0,0 +1,47 @@ +/** + * Test stubs for `KimiHarness` interactions that used to live as + * dedicated convenience methods on the SDK (`auth.hasUsableToken`, + * `listAvailableModels`). The methods are gone; the adapter now calls + * the underlying SDK API directly (`auth.status`, `getConfig().models`) + * and the helpers below produce the matching stub shapes so each test + * file doesn't have to hand-roll them. + */ + +import type { ModelAlias } from '@moonshot-ai/kimi-code-sdk'; + +/** Stub `auth.status()` payload for an authenticated harness. */ +export const AUTHED_STATUS = { + providers: [{ providerName: 'kimi', hasToken: true }], +} as const; + +/** Stub `auth.status()` payload for an unauthenticated harness. */ +export const UNAUTHED_STATUS = { + providers: [{ providerName: 'kimi', hasToken: false }], +} as const; + +/** + * Build a `Record` suitable for stubbing + * `harness.getConfig().models`. Each input entry maps to one alias; + * `capabilities: ['thinking']` is added when `thinkingSupported` is + * true so `deriveThinkingSupported` (in `src/model-catalog.ts`) reads + * it back correctly — this opts out of the name-regex and + * allow-list heuristics in favour of an explicit declaration that + * mirrors what a real config file would carry. + */ +export function makeModelsMap( + entries: ReadonlyArray<{ id: string; name?: string; thinkingSupported?: boolean }>, +): Record { + const out: Record = {}; + for (const entry of entries) { + out[entry.id] = { + // The fields below are the minimum shape the adapter reads off + // each alias — `provider`/`max_context_size` are required by the + // schema but unused by the model catalog, so they're skipped + // here and the partial-record cast keeps the test stub honest. + model: entry.id, + ...(entry.name !== undefined ? { displayName: entry.name } : {}), + ...(entry.thinkingSupported === true ? { capabilities: ['thinking'] } : {}), + } as ModelAlias; + } + return out; +} diff --git a/packages/acp-adapter/test/approval-cancel.test.ts b/packages/acp-adapter/test/approval-cancel.test.ts new file mode 100644 index 000000000..c491104be --- /dev/null +++ b/packages/acp-adapter/test/approval-cancel.test.ts @@ -0,0 +1,345 @@ +/** + * Regression coverage for the `session/cancel` ⇄ pending + * `session/request_permission` interaction. + * + * Background. The SDK reverse-RPC layer parks `handleApproval` at + * `conn.requestPermission` until the client answers. When the user + * (or the IDE shutting down) sends `session/cancel` mid-await, two + * invariants must hold: + * + * 1. The cancel notification flows through unblocked — neither the + * JSON-RPC layer nor `AcpServer.cancel` may be queued behind the + * parked `requestPermission`. `Session.cancel()` must observe the + * notification immediately so it can tear down the turn. + * + * 2. When the client subsequently honours the cancel by responding + * `outcome: 'cancelled'` to the still-pending request, the bridge + * must surface `{ decision: 'cancelled' }` to the SDK — not + * `rejected` (which would be a confusing audit trail) and not + * leak the await (which would wedge the next turn). + * + * These tests are the dev-2 analogue of the kimi-cli regression at + * `tests/acp/test_session_notifications.py::test_acp_prompt_cancel_closes_abandoned_approval_stream`. + * The Python side cancels the prompt task directly (asyncio + * `CancelledError`); in TS land cancellation is observable as a + * `session/cancel` notification that the SDK turns into a + * `turn.ended { reason: 'cancelled' }` event — so the test exercises + * the path the harness will actually take. + */ + +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + ApprovalRequest, + ApprovalResponse, + Event, + KimiHarness, + Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { APPROVE_ONCE_OPTION_ID } from '../src/approval'; +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * Scripted SDK Session that parks `prompt()` and exposes hooks for the + * test to (a) inject events, (b) invoke the registered approval handler + * just like the reverse-RPC layer would, and (c) observe `cancel()`. + * + * Mirrors the shape of `approval.test.ts`'s `makeApprovalSession`, with + * one addition: a public `cancelCalls` counter so the test can prove the + * `session/cancel` notification reached the SDK while another request + * was parked. + */ +function makeCancellableApprovalSession(sessionId: string): { + session: Session; + emit: (event: Event) => void; + invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; + resolvePrompt: () => void; + cancelCalls: () => number; +} { + const listeners = new Set<(event: Event) => void>(); + let approvalHandler: ApprovalHandler | undefined; + let releasePrompt: (() => void) | undefined; + let cancelCount = 0; + + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + }, + cancel: async () => { + cancelCount += 1; + }, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + setApprovalHandler: (handler: ApprovalHandler | undefined) => { + approvalHandler = handler; + }, + } as unknown as Session; + + return { + session, + emit: (event: Event) => { + for (const fn of listeners) fn(event); + }, + invokeHandler: (req: ApprovalRequest) => { + if (!approvalHandler) { + throw new Error('approval handler was not registered by AcpSession'); + } + return approvalHandler(req); + }, + resolvePrompt: () => releasePrompt?.(), + cancelCalls: () => cancelCount, + }; +} + +/** + * Test-only client that holds `requestPermission` open until the test + * resolves it explicitly. Lets the test interleave a `session/cancel` + * notification with a parked permission request and decide when (and + * how) to settle the request. + */ +class ParkingPermissionClient implements Client { + readonly updates: SessionNotification[] = []; + readonly permissionRequests: RequestPermissionRequest[] = []; + + private pending: ((response: RequestPermissionResponse) => void) | undefined; + + /** Resolves on the first `requestPermission` call so the test can synchronise. */ + readonly received: Promise; + private signalReceived: (() => void) | undefined; + + constructor() { + this.received = new Promise((resolve) => { + this.signalReceived = resolve; + }); + } + + /** Settle the parked request with the supplied outcome. */ + respond(response: RequestPermissionResponse): void { + const cb = this.pending; + if (!cb) throw new Error('respond() called before a requestPermission was received'); + this.pending = undefined; + cb(response); + } + + isPending(): boolean { + return this.pending !== undefined; + } + + async requestPermission(p: RequestPermissionRequest): Promise { + this.permissionRequests.push(p); + this.signalReceived?.(); + this.signalReceived = undefined; + return new Promise((resolve) => { + this.pending = resolve; + }); + } + + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('not used'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('not used'); + } +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('AcpServer cancel ⇄ pending requestPermission', () => { + it('processes session/cancel without blocking on an in-flight requestPermission, and the parked request can still settle to { decision: cancelled }', async () => { + const sessionId = 'sess-cancel-while-approval'; + const turnId = 11; + const handle = makeCancellableApprovalSession(sessionId); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ParkingPermissionClient(); + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + // Start a prompt so the in-prompt onEvent subscription is live; the + // scripted session parks `prompt()` until `resolvePrompt` is called. + const pending = clientConn.prompt({ + sessionId, + prompt: [textBlock('do the thing')], + }); + + // Yield once so the agent-side subscribes to events before we emit. + await new Promise((r) => setTimeout(r, 5)); + + // Advance the turnId so `buildPermissionToolCallUpdate` uses the + // prefixed `${turnId}:${rawId}` form — proves the cancel test also + // covers the production wire id. + handle.emit({ + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId: 'tc-cancel', + name: 'Bash', + args: { command: 'rm -rf /' }, + } as Event); + + // Invoke the approval handler as the SDK reverse-RPC layer would. + // The bridge will call `conn.requestPermission`, which `ParkingPermissionClient` + // parks until we explicitly respond. + const approvalPromise = Promise.resolve( + handle.invokeHandler({ + toolCallId: 'tc-cancel', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'rm -rf /' }, + }), + ); + + // Wait until the request has reached the client and is parked. + await client.received; + expect(client.isPending()).toBe(true); + expect(client.permissionRequests).toHaveLength(1); + expect(client.permissionRequests[0]!.toolCall.toolCallId).toBe(`${turnId}:tc-cancel`); + + // The critical invariant: `session/cancel` (a notification) must + // reach the SDK even though `requestPermission` is still parked at + // the client. If the JSON-RPC handler queue were head-of-line + // blocked on the pending request, `Session.cancel()` would never + // fire and this would hang / fail. + await clientConn.cancel({ sessionId }); + // Give the agent a tick to dispatch the notification. + await new Promise((r) => setTimeout(r, 10)); + expect(handle.cancelCalls()).toBe(1); + + // Now the client honours the cancel by closing the permission + // prompt: `outcome: 'cancelled'`. The bridge must translate that + // into `{ decision: 'cancelled' }` for the SDK so the audit trail + // is "user cancelled", not "user rejected". + client.respond({ outcome: { outcome: 'cancelled' } }); + const decision = await approvalPromise; + expect(decision.decision).toBe('cancelled'); + + // Close out the parked prompt so the test exits cleanly. The + // adapter resolves the prompt promise with `stopReason: 'cancelled'` + // when the SDK lands the `turn.ended` event below. + handle.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'cancelled', + } as Event); + handle.resolvePrompt(); + const promptResp = await pending; + expect(promptResp.stopReason).toBe('cancelled'); + }); + + it('a client that ignores the cancel and approves the parked request still resolves the bridge to { decision: approved } — cancel and approval are independent channels', async () => { + // Sister case to the test above: this guards against a refactor + // that ties the `requestPermission` await to `Session.cancel()` and + // accidentally aborts approval flows on cancel. The dev-2 design + // keeps them independent — the client is the source of truth for + // the approval outcome — and we want a regression that fails if + // that changes silently. + const sessionId = 'sess-cancel-independent-approval'; + const handle = makeCancellableApprovalSession(sessionId); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ParkingPermissionClient(); + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const pending = clientConn.prompt({ + sessionId, + prompt: [textBlock('hi')], + }); + await new Promise((r) => setTimeout(r, 5)); + + handle.emit({ + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId: 1, + toolCallId: 'tc-ind', + name: 'Bash', + args: { command: 'echo hi' }, + } as Event); + + const approvalPromise = Promise.resolve( + handle.invokeHandler({ + toolCallId: 'tc-ind', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo hi' }, + }), + ); + + await client.received; + await clientConn.cancel({ sessionId }); + await new Promise((r) => setTimeout(r, 10)); + expect(handle.cancelCalls()).toBe(1); + + // Client decides to approve anyway. The bridge does not unilaterally + // re-interpret the outcome — `approved` round-trips through verbatim. + client.respond({ + outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID }, + }); + const decision = await approvalPromise; + expect(decision.decision).toBe('approved'); + + handle.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'cancelled', + } as Event); + handle.resolvePrompt(); + const promptResp = await pending; + expect(promptResp.stopReason).toBe('cancelled'); + }); +}); diff --git a/packages/acp-adapter/test/approval-display.test.ts b/packages/acp-adapter/test/approval-display.test.ts new file mode 100644 index 000000000..bbedb07e5 --- /dev/null +++ b/packages/acp-adapter/test/approval-display.test.ts @@ -0,0 +1,367 @@ +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type ToolCallContent, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + ApprovalRequest, + ApprovalResponse, + Event, + KimiHarness, + Session, + ToolInputDisplay, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + APPROVE_ALWAYS_OPTION_ID, + APPROVE_ONCE_OPTION_ID, + REJECT_OPTION_ID, + attachSelectedLabel, + approvalRequestToPermissionOptions, + buildPermissionToolCallUpdate, +} from '../src/approval'; +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeApprovalSession(sessionId: string): { + session: Session; + emit: (event: Event) => void; + invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; + resolvePrompt: () => void; +} { + const listeners = new Set<(event: Event) => void>(); + let approvalHandler: ApprovalHandler | undefined; + let releasePrompt: (() => void) | undefined; + + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + setApprovalHandler: (handler: ApprovalHandler | undefined) => { + approvalHandler = handler; + }, + } as unknown as Session; + + return { + session, + emit: (event: Event) => { + for (const fn of listeners) fn(event); + }, + invokeHandler: (req: ApprovalRequest) => { + if (!approvalHandler) { + throw new Error('approval handler was not registered by AcpSession'); + } + return approvalHandler(req); + }, + resolvePrompt: () => releasePrompt?.(), + }; +} + +class ApprovalDisplayClient implements Client { + readonly updates: SessionNotification[] = []; + readonly permissionRequests: RequestPermissionRequest[] = []; + reply: RequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID }, + }; + + async requestPermission( + p: RequestPermissionRequest, + ): Promise { + this.permissionRequests.push(p); + return this.reply; + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('not used in approval-display test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('not used in approval-display test'); + } +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('buildPermissionToolCallUpdate (Phase 5.2 content shape)', () => { + const baseReq = (display: ToolInputDisplay): ApprovalRequest => ({ + toolCallId: 'tc-1', + toolName: 'Edit', + action: 'edit file', + display, + }); + + it('includes a diff entry + action summary when display.kind === "diff"', () => { + const update = buildPermissionToolCallUpdate( + 3, + baseReq({ + kind: 'diff', + path: '/tmp/x.ts', + before: 'old', + after: 'new', + }), + ); + expect(update.toolCallId).toBe('3:tc-1'); + expect(update.title).toBe('Edit'); + expect(update.content).toHaveLength(2); + const [diff, action] = update.content as [ToolCallContent, ToolCallContent]; + expect(diff).toEqual({ + type: 'diff', + path: '/tmp/x.ts', + oldText: 'old', + newText: 'new', + }); + expect(action).toEqual({ + type: 'content', + content: { type: 'text', text: 'Requesting approval to edit file' }, + }); + }); + + it('includes a diff entry for file_io with both before+after (Edit/Write payload)', () => { + const update = buildPermissionToolCallUpdate( + 4, + baseReq({ + kind: 'file_io', + operation: 'edit', + path: '/tmp/y.ts', + before: 'before', + after: 'after', + }), + ); + expect(update.content).toHaveLength(2); + const [diff] = update.content as [ToolCallContent, ToolCallContent]; + expect(diff).toEqual({ + type: 'diff', + path: '/tmp/y.ts', + oldText: 'before', + newText: 'after', + }); + }); + + it('emits only the action summary for non-diff display kinds (e.g. command)', () => { + const update = buildPermissionToolCallUpdate( + 5, + { + toolCallId: 'tc-cmd', + toolName: 'Bash', + action: 'run shell command', + display: { kind: 'command', command: 'ls -la' }, + }, + ); + expect(update.content).toHaveLength(1); + const [only] = update.content as [ToolCallContent]; + expect(only).toEqual({ + type: 'content', + content: { type: 'text', text: 'Requesting approval to run shell command' }, + }); + }); + + it('drops the diff for file_io without both before and after', () => { + // Read-only file_io (e.g. Read tool) doesn't carry a diff hunk — + // the display block has only `content`, not `before`/`after`. The + // approval prompt should fall back to the action summary alone. + const update = buildPermissionToolCallUpdate( + 6, + { + toolCallId: 'tc-read', + toolName: 'Read', + action: 'read file', + display: { + kind: 'file_io', + operation: 'read', + path: '/tmp/z.ts', + content: 'file contents...', + }, + }, + ); + expect(update.content).toHaveLength(1); + }); +}); + +describe('attachSelectedLabel', () => { + const options = approvalRequestToPermissionOptions(); + + it('returns the input unchanged when the outcome is cancelled', () => { + const approval: ApprovalResponse = { decision: 'cancelled' }; + const result = attachSelectedLabel( + { outcome: { outcome: 'cancelled' } }, + approval, + options, + ); + expect(result).toEqual({ decision: 'cancelled' }); + expect(result.selectedLabel).toBeUndefined(); + }); + + it('attaches "Approve once" when approve_once is selected', () => { + const approval: ApprovalResponse = { decision: 'approved' }; + const result = attachSelectedLabel( + { outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID } }, + approval, + options, + ); + expect(result).toEqual({ decision: 'approved', selectedLabel: 'Approve once' }); + }); + + it('attaches "Approve for this session" when approve_always is selected', () => { + const approval: ApprovalResponse = { decision: 'approved', scope: 'session' }; + const result = attachSelectedLabel( + { outcome: { outcome: 'selected', optionId: APPROVE_ALWAYS_OPTION_ID } }, + approval, + options, + ); + expect(result).toEqual({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }); + }); + + it('attaches "Reject" when reject is selected', () => { + const approval: ApprovalResponse = { decision: 'rejected' }; + const result = attachSelectedLabel( + { outcome: { outcome: 'selected', optionId: REJECT_OPTION_ID } }, + approval, + options, + ); + expect(result).toEqual({ decision: 'rejected', selectedLabel: 'Reject' }); + }); + + it('returns the input unchanged when the optionId is unknown', () => { + const approval: ApprovalResponse = { decision: 'rejected' }; + const result = attachSelectedLabel( + { outcome: { outcome: 'selected', optionId: 'never-heard-of-it' } }, + approval, + options, + ); + expect(result).toEqual({ decision: 'rejected' }); + expect(result.selectedLabel).toBeUndefined(); + }); + + it('does not mutate the input approval object', () => { + const approval: ApprovalResponse = { decision: 'approved' }; + attachSelectedLabel( + { outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID } }, + approval, + options, + ); + expect(approval.selectedLabel).toBeUndefined(); + }); +}); + +describe('AcpSession ↔ requestPermission bridge (selectedLabel end-to-end)', () => { + it('attaches the matched option name as ApprovalResponse.selectedLabel and forwards a diff entry in toolCall.content', async () => { + const sessionId = 'sess-approval-display'; + const turnId = 11; + const handle = makeApprovalSession(sessionId); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ApprovalDisplayClient(); + client.reply = { + outcome: { outcome: 'selected', optionId: APPROVE_ALWAYS_OPTION_ID }, + }; + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const pending = clientConn.prompt({ + sessionId, + prompt: [textBlock('approve me')], + }); + // Let the agent-side subscribe before we emit events. + await new Promise((r) => setTimeout(r, 5)); + + handle.emit({ + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId: 'edit-1', + name: 'Edit', + args: { path: '/tmp/x.ts' }, + } as Event); + + const decision = await handle.invokeHandler({ + toolCallId: 'edit-1', + toolName: 'Edit', + action: 'edit file', + display: { + kind: 'diff', + path: '/tmp/x.ts', + before: 'old', + after: 'new', + }, + }); + + expect(decision).toEqual({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }); + + expect(client.permissionRequests).toHaveLength(1); + const req = client.permissionRequests[0]!; + expect(req.toolCall.toolCallId).toBe(`${turnId}:edit-1`); + expect(req.toolCall.title).toBe('Edit'); + // Content carries the diff entry first then the action summary. + expect(req.toolCall.content).toHaveLength(2); + const [diff, action] = req.toolCall.content as [ToolCallContent, ToolCallContent]; + expect(diff).toEqual({ + type: 'diff', + path: '/tmp/x.ts', + oldText: 'old', + newText: 'new', + }); + expect(action).toEqual({ + type: 'content', + content: { type: 'text', text: 'Requesting approval to edit file' }, + }); + + handle.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event); + handle.resolvePrompt(); + await pending; + }); +}); diff --git a/packages/acp-adapter/test/approval-plan-review.test.ts b/packages/acp-adapter/test/approval-plan-review.test.ts new file mode 100644 index 000000000..084ac268c --- /dev/null +++ b/packages/acp-adapter/test/approval-plan-review.test.ts @@ -0,0 +1,225 @@ +/** + * Phase 13.2 tests for the plan_review approval branch: + * + * - `approvalRequestToPermissionOptions(req)` expands to A/B/C + + * Revise + Reject and Exit (or `plan_approve` + the two rejects + * when `display.options` is missing). + * - `displayBlockToAcpContent(display)` surfaces the plan markdown + * (and optional `Plan saved to:` prefix) at the headline of the + * approval card. + * - `permissionResponseToApprovalResponse(req, response)` round-trips + * each optionId back to the SDK approval discriminator, attaching + * `selectedLabel` on the plan_opt_ / plan_revise / + * plan_reject_and_exit paths. + * + * Non-plan_review behaviour stays in `approval.test.ts` — this file + * exercises ONLY the plan_review branch. + */ +import type { RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import type { ApprovalRequest, ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + PLAN_APPROVE_OPTION_ID, + PLAN_REJECT_AND_EXIT_OPTION_ID, + PLAN_REVISE_OPTION_ID, + approvalRequestToPermissionOptions, + permissionResponseToApprovalResponse, +} from '../src/approval'; +import { displayBlockToAcpContent } from '../src/convert'; + +const planMd = '## Plan\n\n1. Land the bridge\n2. Cut a release'; + +function makePlanReviewRequest( + opts: { + options?: ReadonlyArray<{ label: string; description: string }>; + plan?: string; + path?: string; + } = {}, +): ApprovalRequest { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: opts.plan ?? planMd, + ...(opts.path !== undefined ? { path: opts.path } : {}), + ...(opts.options !== undefined ? { options: opts.options } : {}), + }; + return { + toolCallId: 'tc-plan', + toolName: 'ExitPlanMode', + action: 'Present the plan and exit plan mode', + display, + }; +} + +const threeOptions = [ + { label: 'Option A: Ship it', description: 'Land what we have.' }, + { label: 'Option B: Robustness', description: 'Add the guard rails first.' }, + { label: 'Option C: Pivot', description: 'Drop the surface entirely.' }, +] as const; + +function selectedResponse(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +describe('approvalRequestToPermissionOptions — plan_review branch', () => { + it('emits one allow_once per display.option plus Revise + Reject and Exit', () => { + const req = makePlanReviewRequest({ options: threeOptions, path: '/tmp/plan.md' }); + const out = approvalRequestToPermissionOptions(req); + expect(out).toHaveLength(5); + expect(out[0]).toEqual({ + optionId: 'plan_opt_0', + name: 'Option A: Ship it', + kind: 'allow_once', + }); + expect(out[1]).toEqual({ + optionId: 'plan_opt_1', + name: 'Option B: Robustness', + kind: 'allow_once', + }); + expect(out[2]).toEqual({ + optionId: 'plan_opt_2', + name: 'Option C: Pivot', + kind: 'allow_once', + }); + expect(out[3]).toEqual({ + optionId: PLAN_REVISE_OPTION_ID, + name: 'Revise', + kind: 'reject_once', + }); + expect(out[4]).toEqual({ + optionId: PLAN_REJECT_AND_EXIT_OPTION_ID, + name: 'Reject and Exit', + kind: 'reject_once', + }); + }); + + it('falls back to a single plan_approve when display.options is undefined', () => { + const req = makePlanReviewRequest({ options: undefined }); + const out = approvalRequestToPermissionOptions(req); + expect(out).toHaveLength(3); + expect(out[0]).toEqual({ + optionId: PLAN_APPROVE_OPTION_ID, + name: 'Approve', + kind: 'allow_once', + }); + expect(out[1]?.optionId).toBe(PLAN_REVISE_OPTION_ID); + expect(out[2]?.optionId).toBe(PLAN_REJECT_AND_EXIT_OPTION_ID); + }); + + it('falls back to plan_approve when display.options.length === 1 (below the 2-option threshold)', () => { + const req = makePlanReviewRequest({ + options: [{ label: 'Only choice', description: 'sole option' }], + }); + const out = approvalRequestToPermissionOptions(req); + expect(out).toHaveLength(3); + expect(out[0]?.optionId).toBe(PLAN_APPROVE_OPTION_ID); + }); + + it('preserves Phase 5 canonical behaviour when display.kind is not plan_review', () => { + const req: ApprovalRequest = { + toolCallId: 'tc-cmd', + toolName: 'Bash', + action: 'run', + display: { kind: 'command', command: 'echo hi' }, + }; + const out = approvalRequestToPermissionOptions(req); + expect(out).toHaveLength(3); + expect(out.map((o) => o.optionId)).toEqual([ + 'approve_once', + 'approve_always', + 'reject', + ]); + }); +}); + +describe('displayBlockToAcpContent — plan_review (re-exercise via approval fixture)', () => { + it('renders Plan saved to: prefix + plan body when path is set', () => { + const req = makePlanReviewRequest({ options: threeOptions, path: '/tmp/plan.md' }); + const out = displayBlockToAcpContent(req.display); + expect(out).toEqual({ + type: 'content', + content: { + type: 'text', + text: `Plan saved to: /tmp/plan.md\n\n${planMd}`, + }, + }); + }); + + it('renders the plan body alone when path is absent', () => { + const req = makePlanReviewRequest({ options: threeOptions }); + const out = displayBlockToAcpContent(req.display); + expect(out).toEqual({ + type: 'content', + content: { type: 'text', text: planMd }, + }); + }); +}); + +describe('permissionResponseToApprovalResponse — plan_review branch', () => { + const req = makePlanReviewRequest({ options: threeOptions, path: '/tmp/plan.md' }); + + it('maps plan_opt_ → { decision: approved, selectedLabel: options[i].label }', () => { + const result = permissionResponseToApprovalResponse(req, selectedResponse('plan_opt_1')); + expect(result).toEqual({ + decision: 'approved', + selectedLabel: 'Option B: Robustness', + }); + }); + + it('maps plan_opt_0 to the first label (boundary)', () => { + const result = permissionResponseToApprovalResponse(req, selectedResponse('plan_opt_0')); + expect(result).toEqual({ + decision: 'approved', + selectedLabel: 'Option A: Ship it', + }); + }); + + it('maps plan_revise → { decision: rejected, selectedLabel: "Revise" }', () => { + const result = permissionResponseToApprovalResponse( + req, + selectedResponse(PLAN_REVISE_OPTION_ID), + ); + expect(result).toEqual({ decision: 'rejected', selectedLabel: 'Revise' }); + }); + + it('maps plan_reject_and_exit → { decision: rejected, selectedLabel: "Reject and Exit" }', () => { + const result = permissionResponseToApprovalResponse( + req, + selectedResponse(PLAN_REJECT_AND_EXIT_OPTION_ID), + ); + expect(result).toEqual({ + decision: 'rejected', + selectedLabel: 'Reject and Exit', + }); + }); + + it('maps plan_approve → { decision: approved } with no selectedLabel', () => { + const noOptionsReq = makePlanReviewRequest({ options: undefined }); + const result = permissionResponseToApprovalResponse( + noOptionsReq, + selectedResponse(PLAN_APPROVE_OPTION_ID), + ); + expect(result).toEqual({ decision: 'approved' }); + expect(result.selectedLabel).toBeUndefined(); + }); + + it('defensively maps plan_opt_99 (out of bounds) → { decision: rejected }', () => { + const result = permissionResponseToApprovalResponse(req, selectedResponse('plan_opt_99')); + expect(result).toEqual({ decision: 'rejected' }); + }); + + it('defensively maps an unknown plan_* optionId → { decision: rejected }', () => { + const result = permissionResponseToApprovalResponse( + req, + selectedResponse('plan_unknown'), + ); + expect(result).toEqual({ decision: 'rejected' }); + }); + + it('maps cancelled → { decision: cancelled } even in plan_review context', () => { + const result = permissionResponseToApprovalResponse(req, { + outcome: { outcome: 'cancelled' }, + }); + expect(result).toEqual({ decision: 'cancelled' }); + }); +}); diff --git a/packages/acp-adapter/test/approval.test.ts b/packages/acp-adapter/test/approval.test.ts new file mode 100644 index 000000000..309466697 --- /dev/null +++ b/packages/acp-adapter/test/approval.test.ts @@ -0,0 +1,341 @@ +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + ApprovalRequest, + ApprovalResponse, + Event, + KimiHarness, + Session, + ToolInputDisplay, +} from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { + APPROVE_ALWAYS_OPTION_ID, + APPROVE_ONCE_OPTION_ID, + REJECT_OPTION_ID, + approvalRequestToPermissionOptions, + buildPermissionToolCallUpdate, + permissionResponseToApprovalResponse, +} from '../src/approval'; +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * Stub Session that captures the registered approval handler and lets + * the test fire arbitrary events through any `onEvent` subscriber. + * + * Mirrors the pattern from `session-prompt.test.ts` but exposes the + * captured handler so the test can drive the reverse-RPC end-to-end. + */ +function makeApprovalSession(sessionId: string): { + session: Session; + emit: (event: Event) => void; + invokeHandler: (req: ApprovalRequest) => Promise | ApprovalResponse; + promptStarted: () => boolean; + resolvePrompt: () => void; +} { + const listeners = new Set<(event: Event) => void>(); + let approvalHandler: ApprovalHandler | undefined; + let started = false; + let releasePrompt: (() => void) | undefined; + + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + started = true; + // Park the prompt so the test can drive events and invoke the + // approval handler before the turn settles. The test resolves + // this promise explicitly via `resolvePrompt`. + await new Promise((resolve) => { + releasePrompt = resolve; + }); + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + setApprovalHandler: (handler: ApprovalHandler | undefined) => { + approvalHandler = handler; + }, + } as unknown as Session; + + return { + session, + emit: (event: Event) => { + for (const fn of listeners) fn(event); + }, + invokeHandler: (req: ApprovalRequest) => { + if (!approvalHandler) { + throw new Error('approval handler was not registered by AcpSession'); + } + return approvalHandler(req); + }, + promptStarted: () => started, + resolvePrompt: () => releasePrompt?.(), + }; +} + +class ApprovalClient implements Client { + readonly updates: SessionNotification[] = []; + readonly permissionRequests: RequestPermissionRequest[] = []; + reply: RequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID }, + }; + + async requestPermission( + p: RequestPermissionRequest, + ): Promise { + this.permissionRequests.push(p); + return this.reply; + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('not used in approval test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('not used in approval test'); + } +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('approvalRequestToPermissionOptions', () => { + it('returns three options in the canonical order with documented kinds', () => { + const options = approvalRequestToPermissionOptions(); + expect(options).toHaveLength(3); + + expect(options[0]).toEqual({ + optionId: APPROVE_ONCE_OPTION_ID, + name: 'Approve once', + kind: 'allow_once', + }); + expect(options[1]).toEqual({ + optionId: APPROVE_ALWAYS_OPTION_ID, + name: 'Approve for this session', + kind: 'allow_always', + }); + expect(options[2]).toEqual({ + optionId: REJECT_OPTION_ID, + name: 'Reject', + kind: 'reject_once', + }); + }); +}); + +describe('permissionResponseToApprovalResponse', () => { + it('maps approve_once → { decision: approved } with no scope', () => { + const result = permissionResponseToApprovalResponse(undefined, { + outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID }, + }); + expect(result).toEqual({ decision: 'approved' }); + expect(result.scope).toBeUndefined(); + }); + + it('maps approve_always → { decision: approved, scope: session }', () => { + const result = permissionResponseToApprovalResponse(undefined, { + outcome: { outcome: 'selected', optionId: APPROVE_ALWAYS_OPTION_ID }, + }); + expect(result).toEqual({ decision: 'approved', scope: 'session' }); + }); + + it('maps reject → { decision: rejected }', () => { + const result = permissionResponseToApprovalResponse(undefined, { + outcome: { outcome: 'selected', optionId: REJECT_OPTION_ID }, + }); + expect(result).toEqual({ decision: 'rejected' }); + }); + + it('defensively maps an unknown optionId to { decision: rejected }', () => { + const result = permissionResponseToApprovalResponse(undefined, { + outcome: { outcome: 'selected', optionId: 'unknown_option_id' }, + }); + expect(result).toEqual({ decision: 'rejected' }); + }); + + it('maps cancelled → { decision: cancelled }', () => { + const result = permissionResponseToApprovalResponse(undefined, { + outcome: { outcome: 'cancelled' }, + }); + expect(result).toEqual({ decision: 'cancelled' }); + }); +}); + +describe('buildPermissionToolCallUpdate (Phase 5.1 minimal shape)', () => { + const fakeDisplay: ToolInputDisplay = { kind: 'command', command: 'ls -la' }; + const baseReq: ApprovalRequest = { + toolCallId: 'abc', + toolName: 'Bash', + action: 'run command', + display: fakeDisplay, + }; + + it('prefixes the toolCallId with the turnId when one is known', () => { + const update = buildPermissionToolCallUpdate(42, baseReq); + expect(update.toolCallId).toBe('42:abc'); + expect(update.title).toBe('Bash'); + }); + + it('falls back to the raw SDK toolCallId when no turnId is tracked yet', () => { + const update = buildPermissionToolCallUpdate(undefined, baseReq); + expect(update.toolCallId).toBe('abc'); + expect(update.title).toBe('Bash'); + }); +}); + +describe('AcpSession ↔ requestPermission bridge (end-to-end via wire)', () => { + it('emits a request_permission with options length 3 and prefixed toolCallId when the SDK invokes the registered handler, and resolves it to { decision: approved }', async () => { + const sessionId = 'sess-approval-wire'; + const turnId = 7; + const handle = makeApprovalSession(sessionId); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ApprovalClient(); + client.reply = { + outcome: { outcome: 'selected', optionId: APPROVE_ONCE_OPTION_ID }, + }; + const clientConn = new ClientSideConnection(() => client, clientStream); + + // Open the session so AcpServer constructs the AcpSession (which + // registers our approval handler). + await clientConn.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + // Kick off a prompt so the in-prompt onEvent subscription is live. + // The scripted session's `prompt()` parks until we call + // `resolvePrompt`, giving us a window to drive events + approval. + const pending = clientConn.prompt({ + sessionId, + prompt: [textBlock('hi')], + }); + + // Wait one tick for prompt() to subscribe via onEvent. + await new Promise((r) => setTimeout(r, 5)); + + // Fire a tool-call-started event so the adapter learns the + // current turnId (any event with `turnId` advances it). + handle.emit({ + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId: 'tc-1', + name: 'Bash', + args: { command: 'echo hi' }, + } as Event); + + // Now invoke the captured approval handler exactly as the SDK + // reverse-RPC layer would. + const approvalReq: ApprovalRequest = { + toolCallId: 'tc-1', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo hi' }, + }; + const decision = await handle.invokeHandler(approvalReq); + + // Phase 5.2 lifts `selectedLabel` from the matched option name. + // The 5.1 contract (decision discriminator) is preserved. + expect(decision.decision).toBe('approved'); + expect(decision.scope).toBeUndefined(); + expect(client.permissionRequests).toHaveLength(1); + const req = client.permissionRequests[0]!; + expect(req.sessionId).toBe(sessionId); + expect(req.options).toHaveLength(3); + expect(req.options.map((o) => o.optionId)).toEqual([ + APPROVE_ONCE_OPTION_ID, + APPROVE_ALWAYS_OPTION_ID, + REJECT_OPTION_ID, + ]); + expect(req.toolCall.toolCallId).toBe(`${turnId}:tc-1`); + expect(req.toolCall.title).toBe('Bash'); + + // Settle the parked prompt with a turn.ended so the test exits + // cleanly. + handle.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId, + reason: 'completed', + } as Event); + handle.resolvePrompt(); + await pending; + }); + + it('returns { decision: rejected } when the client throws', async () => { + const sessionId = 'sess-approval-fail'; + const handle = makeApprovalSession(sessionId); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ApprovalClient(); + // Override to throw so the bridge falls into the catch branch. + client.requestPermission = async (_p: RequestPermissionRequest) => { + throw new Error('client unreachable'); + }; + const clientConn = new ClientSideConnection(() => client, clientStream); + + await clientConn.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const pending = clientConn.prompt({ + sessionId, + prompt: [textBlock('x')], + }); + await new Promise((r) => setTimeout(r, 5)); + + const decision = await handle.invokeHandler({ + toolCallId: 'tc-x', + toolName: 'Bash', + action: 'run command', + display: { kind: 'command', command: 'echo x' }, + }); + expect(decision).toEqual({ decision: 'rejected' }); + + handle.emit({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'completed', + } as Event); + handle.resolvePrompt(); + await pending; + }); +}); diff --git a/packages/acp-adapter/test/auth-gate.test.ts b/packages/acp-adapter/test/auth-gate.test.ts new file mode 100644 index 000000000..f1180bb31 --- /dev/null +++ b/packages/acp-adapter/test/auth-gate.test.ts @@ -0,0 +1,134 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type NewSessionRequest, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, UNAUTHED_STATUS } from './_helpers/harness-stubs'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in auth-gate test'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in auth-gate test'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in auth-gate test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in auth-gate test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeHarnessWithToken(hasToken: boolean): KimiHarness { + return { + auth: { + status: async () => (hasToken ? AUTHED_STATUS : UNAUTHED_STATUS), + }, + } as unknown as KimiHarness; +} + +describe('AcpServer auth gate', () => { + it('rejects session/new with auth_required (-32000) when no token', async () => { + const harness = makeHarnessWithToken(false); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const request: NewSessionRequest = { + cwd: '/tmp/x', + mcpServers: [], + }; + + await expect(client.newSession(request)).rejects.toMatchObject({ + code: -32000, + }); + }); + + it('does not call createSession when the auth gate fails', async () => { + let createCalled = false; + const harness = { + auth: { + status: async () => UNAUTHED_STATUS, + }, + createSession: async (_opts: unknown) => { + createCalled = true; + return { id: 'should-not-be-reached' }; + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect( + client.newSession({ cwd: '/tmp/x', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32000 }); + expect(createCalled).toBe(false); + }); +}); + +describe('AcpServer.authenticate', () => { + it('rejects unknown methodId with invalidParams (-32602)', async () => { + const harness = makeHarnessWithToken(true); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.authenticate({ methodId: 'unknown' })).rejects.toMatchObject({ + code: -32602, + }); + }); + + it('returns void on valid token', async () => { + const harness = makeHarnessWithToken(true); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const result = await client.authenticate({ methodId: 'login' }); + // ACP allows `AuthenticateResponse | void`; either `null`/`undefined` + // or an empty body `{}` is considered a successful ack. + expect(result ?? {}).toEqual({}); + }); + + it('throws authRequired (-32000) when harness has no token', async () => { + const harness = makeHarnessWithToken(false); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.authenticate({ methodId: 'login' })).rejects.toMatchObject({ + code: -32000, + }); + }); +}); diff --git a/packages/acp-adapter/test/cancel.test.ts b/packages/acp-adapter/test/cancel.test.ts new file mode 100644 index 000000000..81f9bd4fc --- /dev/null +++ b/packages/acp-adapter/test/cancel.test.ts @@ -0,0 +1,142 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { log, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in cancel test'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in cancel test'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in cancel test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in cancel test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +describe('AcpServer cancel', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('forwards session/cancel to the underlying Session.cancel() for a known sessionId', async () => { + let cancelCalls = 0; + const fakeSession = { + id: 'sess-known', + prompt: async () => undefined, + cancel: async () => { + cancelCalls += 1; + }, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + // session/cancel is a notification — `client.cancel` is fire-and-forget. + await client.cancel({ sessionId: 'sess-known' }); + + // Give the agent side a tick to process the notification. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(cancelCalls).toBe(1); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('does not throw and logs a warning when sessionId is unknown', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => { + throw new Error('createSession should not be called when no session is created'); + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + // Notification: no response, no throw. + await client.cancel({ sessionId: 'sess-unknown' }); + + // Give the agent side a tick to process the notification. + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('cancel for unknown sessionId'), + expect.objectContaining({ sessionId: 'sess-unknown' }), + ); + }); + + it('swallows and warns when Session.cancel() throws (notifications must not error)', async () => { + const fakeSession = { + id: 'sess-erroring', + prompt: async () => undefined, + cancel: async () => { + throw new Error('boom inside cancel'); + }, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => fakeSession, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.cancel({ sessionId: 'sess-erroring' }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('error while cancelling'), + expect.objectContaining({ sessionId: 'sess-erroring' }), + ); + }); +}); diff --git a/packages/acp-adapter/test/config-options.test.ts b/packages/acp-adapter/test/config-options.test.ts new file mode 100644 index 000000000..89e73717b --- /dev/null +++ b/packages/acp-adapter/test/config-options.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { + buildModelOption, + buildModeOption, + buildSessionConfigOptions, + buildThinkingOption, +} from '../src/config-options'; +import type { AcpModelEntry } from '../src/model-catalog'; + +function makeHarnessWithModels( + entries: ReadonlyArray<{ id: string; model?: string; displayName?: string; capabilities?: readonly string[] }>, +): { harness: KimiHarness; getConfig: ReturnType } { + // Mirror the `listAvailableModels` derivation: `id` is the config map + // key, `model` defaults to id, `displayName` to model. The test fixtures + // below pick names that exercise the three thinkingSupported triggers + // (name regex, capabilities array, toggleable allow-list). + const models: Record = {}; + for (const entry of entries) { + models[entry.id] = { + model: entry.model ?? entry.id, + ...(entry.displayName !== undefined ? { displayName: entry.displayName } : {}), + ...(entry.capabilities !== undefined ? { capabilities: entry.capabilities } : {}), + }; + } + const getConfig = vi.fn(async () => ({ models })); + return { harness: { getConfig } as unknown as KimiHarness, getConfig }; +} + +describe('buildModelOption', () => { + it('emits exactly one option per catalog row (Phase 15: no inlined `,thinking` variant rows)', () => { + const models: readonly AcpModelEntry[] = [ + { id: 'alpha', name: 'Alpha', thinkingSupported: true }, + { id: 'beta', name: 'Beta', thinkingSupported: false }, + ]; + + const option = buildModelOption(models, 'alpha'); + + expect(option.id).toBe('model'); + expect(option.category).toBe('model'); + expect(option.name).toBe('Model'); + if (option.type !== 'select') { + throw new Error('expected a SessionConfigSelect option'); + } + expect(option.currentValue).toBe('alpha'); + expect(option.options).toHaveLength(2); + const projected = option.options.map((entry) => + 'value' in entry ? { value: entry.value, name: entry.name } : null, + ); + expect(projected).toEqual([ + { value: 'alpha', name: 'Alpha' }, + { value: 'beta', name: 'Beta' }, + ]); + }); + + it('treats `currentValue` as the bare base model id — Phase 15 keeps the snapshot suffix-free', () => { + const models: readonly AcpModelEntry[] = [ + { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: true }, + ]; + + const option = buildModelOption(models, 'kimi-v2'); + if (option.type !== 'select') { + throw new Error('expected a SessionConfigSelect option'); + } + expect(option.currentValue).toBe('kimi-v2'); + expect(option.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['kimi-v2']); + }); + + it('handles an empty catalog without emitting any options', () => { + const option = buildModelOption([], ''); + if (option.type !== 'select') { + throw new Error('expected a SessionConfigSelect option'); + } + expect(option.options).toHaveLength(0); + expect(option.currentValue).toBe(''); + }); +}); + +describe('buildThinkingOption', () => { + it('produces a `type:"select"` `category:"thought_level"` option with `off`/`on` entries carrying the toggle value', () => { + const on = buildThinkingOption(true); + expect(on.type).toBe('select'); + expect(on.id).toBe('thinking'); + expect(on.category).toBe('thought_level'); + expect(on.name).toBe('Thinking'); + if (on.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(on.currentValue).toBe('on'); + expect(on.options.map((o) => ('value' in o ? o.value : ''))).toEqual(['off', 'on']); + expect(on.options.map((o) => ('name' in o ? o.name : ''))).toEqual(['Thinking Off', 'Thinking On']); + + const off = buildThinkingOption(false); + if (off.type !== 'select') throw new Error('expected SessionConfigSelect'); + expect(off.currentValue).toBe('off'); + }); +}); + +describe('buildModeOption', () => { + it('returns the locked 4-mode taxonomy in order (default → plan → auto → yolo) with description carried through', () => { + const option = buildModeOption('plan'); + + expect(option.id).toBe('mode'); + expect(option.category).toBe('mode'); + expect(option.name).toBe('Mode'); + if (option.type !== 'select') { + throw new Error('expected a SessionConfigSelect option'); + } + expect(option.currentValue).toBe('plan'); + expect(option.options).toHaveLength(4); + const ids = option.options.map((o) => ('value' in o ? o.value : '')); + expect(ids).toEqual(['default', 'plan', 'auto', 'yolo']); + for (const entry of option.options) { + if ('value' in entry) { + expect(typeof entry.name).toBe('string'); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.description).toBe('string'); + expect((entry.description ?? '').length).toBeGreaterThan(0); + } + } + }); +}); + +describe('buildSessionConfigOptions', () => { + it('composes [model, thinking, mode] when current model supports thinking and calls getConfig exactly once', async () => { + // `kimi-for-coding` is on the toggleable allow-list so its derived + // thinkingSupported is true even without explicit capabilities. + const { harness, getConfig } = makeHarnessWithModels([ + { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, + ]); + + const result = await buildSessionConfigOptions(harness, 'kimi-coder', false, 'default'); + + expect(getConfig).toHaveBeenCalledTimes(1); + expect(result).toHaveLength(3); + expect(result.map((o) => o.id)).toEqual(['model', 'thinking', 'mode']); + + if (result[0]!.type === 'select') { + expect(result[0]!.currentValue).toBe('kimi-coder'); + } + if (result[1]!.type === 'select' && result[1]!.id === 'thinking') { + expect(result[1]!.currentValue).toBe('off'); + expect(result[1]!.category).toBe('thought_level'); + } else { + throw new Error('expected thinking select at index 1'); + } + if (result[2]!.type === 'select') { + expect(result[2]!.currentValue).toBe('default'); + } + }); + + it('omits the thinking toggle when current model is non-thinking-supported', async () => { + const { harness } = makeHarnessWithModels([ + { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, + { id: 'kimi-plain', model: 'qwen-2.5-coder', displayName: 'Kimi Plain' }, + ]); + + const result = await buildSessionConfigOptions(harness, 'kimi-plain', false, 'default'); + + expect(result.map((o) => o.id)).toEqual(['model', 'mode']); + }); + + it('reflects the thinking toggle currentValue from the explicit argument', async () => { + const { harness } = makeHarnessWithModels([ + { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, + ]); + + const result = await buildSessionConfigOptions(harness, 'kimi-coder', true, 'default'); + const toggle = result.find((o) => o.id === 'thinking'); + if (!toggle || toggle.type !== 'select') throw new Error('expected thinking select toggle'); + expect(toggle.currentValue).toBe('on'); + }); + + it('omits the thinking toggle when the current base model id is not in the catalog (defensive)', async () => { + const { harness } = makeHarnessWithModels([ + { id: 'kimi-coder', model: 'kimi-for-coding', displayName: 'Kimi Coder' }, + ]); + + const result = await buildSessionConfigOptions(harness, 'unknown-model', true, 'default'); + expect(result.map((o) => o.id)).toEqual(['model', 'mode']); + }); + + it('handles missing getConfig (partial-stub harness) by suppressing the toggle and shipping an empty model picker', async () => { + const harness = {} as unknown as KimiHarness; + + const result = await buildSessionConfigOptions(harness, '', false, 'default'); + + expect(result.map((o) => o.id)).toEqual(['model', 'mode']); + const modelOpt = result.find((o) => o.id === 'model'); + if (!modelOpt || modelOpt.type !== 'select') throw new Error('expected select'); + expect(modelOpt.options).toHaveLength(0); + }); +}); diff --git a/packages/acp-adapter/test/convert.test.ts b/packages/acp-adapter/test/convert.test.ts new file mode 100644 index 000000000..c1fa4c7fc --- /dev/null +++ b/packages/acp-adapter/test/convert.test.ts @@ -0,0 +1,232 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ContentBlock } from '@agentclientprotocol/sdk'; + +import { log, type ToolInputDisplay } from '@moonshot-ai/kimi-code-sdk'; + +import { acpBlocksToPromptParts, displayBlockToAcpContent } from '../src/convert'; + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); +const imageBlock = (data: string, mimeType: string): ContentBlock => ({ + type: 'image', + data, + mimeType, +}); +const audioBlock = (data: string, mimeType: string): ContentBlock => ({ + type: 'audio', + data, + mimeType, +}); +const resourceLinkBlock = (uri: string, name: string): ContentBlock => ({ + type: 'resource_link', + uri, + name, +}); +const textResourceBlock = (uri: string, text: string, mimeType?: string): ContentBlock => ({ + type: 'resource', + resource: mimeType !== undefined ? { uri, text, mimeType } : { uri, text }, +}); +const blobResourceBlock = (uri: string, blob: string, mimeType?: string): ContentBlock => ({ + type: 'resource', + resource: mimeType !== undefined ? { uri, blob, mimeType } : { uri, blob }, +}); + +describe('acpBlocksToPromptParts', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns an empty array for an empty input', () => { + expect(acpBlocksToPromptParts([])).toEqual([]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('passes text blocks through as { type: text, text }', () => { + const out = acpBlocksToPromptParts([textBlock('hello'), textBlock('world')]); + expect(out).toEqual([ + { type: 'text', text: 'hello' }, + { type: 'text', text: 'world' }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('lifts image blocks into image_url parts with a data URL', () => { + const out = acpBlocksToPromptParts([ + textBlock('caption'), + imageBlock('iVBORw0KGgoAAAA', 'image/png'), + ]); + expect(out).toEqual([ + { type: 'text', text: 'caption' }, + { + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,iVBORw0KGgoAAAA' }, + }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('emits image and text parts in input order', () => { + const out = acpBlocksToPromptParts([ + imageBlock('AAAA', 'image/jpeg'), + textBlock('what is this?'), + ]); + expect(out).toEqual([ + { + type: 'image_url', + imageUrl: { url: 'data:image/jpeg;base64,AAAA' }, + }, + { type: 'text', text: 'what is this?' }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('treats raw base64 as opaque — does not strip data: prefixes (documented limitation)', () => { + // Defensive behavior: a caller that pre-wraps the payload as a data URL + // will end up double-wrapped. The ACP spec says `data` is base64, so this + // only affects non-conforming callers. + const out = acpBlocksToPromptParts([ + imageBlock('data:image/png;base64,XXXX', 'image/png'), + ]); + expect(out).toEqual([ + { + type: 'image_url', + imageUrl: { url: 'data:image/png;base64,data:image/png;base64,XXXX' }, + }, + ]); + }); + + it('drops audio blocks but warns with the dedicated message', () => { + const out = acpBlocksToPromptParts([ + textBlock('hi'), + audioBlock('AAAA', 'audio/mpeg'), + ]); + expect(out).toEqual([{ type: 'text', text: 'hi' }]); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('dropping unsupported audio prompt block'), + expect.objectContaining({ mimeType: 'audio/mpeg' }), + ); + }); + + it('inlines resource_link blocks as text', () => { + const out = acpBlocksToPromptParts([ + resourceLinkBlock('file:///a.txt', 'a'), + textBlock('see linked file'), + resourceLinkBlock('file:///b.txt', 'b'), + ]); + expect(out).toEqual([ + { type: 'text', text: '' }, + { type: 'text', text: 'see linked file' }, + { type: 'text', text: '' }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('escapes XML-special characters in resource_link attributes', () => { + const out = acpBlocksToPromptParts([ + resourceLinkBlock('file:///a&b.txt', 'name with "quotes" & '), + ]); + expect(out).toEqual([ + { + type: 'text', + text: + '', + }, + ]); + }); + + it('inlines TextResourceContents as text', () => { + const out = acpBlocksToPromptParts([ + textResourceBlock('file:///hello.md', '# Hello\nworld', 'text/markdown'), + ]); + expect(out).toEqual([ + { + type: 'text', + text: '# Hello\nworld', + }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('drops BlobResourceContents with a dedicated warn', () => { + const out = acpBlocksToPromptParts([ + blobResourceBlock('file:///pic.bin', 'AAAA', 'application/octet-stream'), + ]); + expect(out).toEqual([]); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('dropping blob embedded resource'), + expect.objectContaining({ + uri: 'file:///pic.bin', + mimeType: 'application/octet-stream', + }), + ); + }); + + it('emits mixed text + resource_link + embedded text resource in input order', () => { + const out = acpBlocksToPromptParts([ + textBlock('header'), + resourceLinkBlock('file:///x', 'x'), + textResourceBlock('file:///y.txt', 'body'), + ]); + expect(out).toEqual([ + { type: 'text', text: 'header' }, + { type: 'text', text: '' }, + { type: 'text', text: 'body' }, + ]); + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); + +describe('displayBlockToAcpContent — plan_review branch (Phase 13.2)', () => { + const planMd = '## Goal\n\nShip the plan_review surface so Zed sees the markdown body.'; + + it('returns null when block.plan is empty after trimming', () => { + const block: ToolInputDisplay = { kind: 'plan_review', plan: ' \n\t ' }; + expect(displayBlockToAcpContent(block)).toBeNull(); + }); + + it('renders the plan markdown alone when no path is set', () => { + const block: ToolInputDisplay = { kind: 'plan_review', plan: planMd }; + expect(displayBlockToAcpContent(block)).toEqual({ + type: 'content', + content: { type: 'text', text: planMd }, + }); + }); + + it('prefixes "Plan saved to: " when block.path is set', () => { + const block: ToolInputDisplay = { + kind: 'plan_review', + plan: planMd, + path: '/tmp/plan.md', + }; + expect(displayBlockToAcpContent(block)).toEqual({ + type: 'content', + content: { + type: 'text', + text: `Plan saved to: /tmp/plan.md\n\n${planMd}`, + }, + }); + }); + + it('preserves the plan body verbatim — no markdown escaping or normalisation', () => { + const richMd = '**bold** & with "quotes"'; + const block: ToolInputDisplay = { kind: 'plan_review', plan: richMd }; + const out = displayBlockToAcpContent(block); + expect(out).toEqual({ + type: 'content', + content: { type: 'text', text: richMd }, + }); + }); + + it('still returns null for an unmapped kind (Phase 5 invariant)', () => { + const cmd: ToolInputDisplay = { kind: 'command', command: 'ls' }; + expect(displayBlockToAcpContent(cmd)).toBeNull(); + }); +}); diff --git a/packages/acp-adapter/test/e2e-fs.test.ts b/packages/acp-adapter/test/e2e-fs.test.ts new file mode 100644 index 000000000..f17eae7db --- /dev/null +++ b/packages/acp-adapter/test/e2e-fs.test.ts @@ -0,0 +1,263 @@ +/** + * End-to-end test for the FS reverse-RPC bridge. + * + * Wire shape under test (the integration that Phases 6.1 + 6.2 unlock): + * + * ┌────────┐ fs/readTextFile (RPC) ┌────────┐ + * │ client │ ───────────────────────► │ agent │ + * │ │ │ │ │ + * │ │ ◄──── { content: ... } ──│ ▼ tool │ + * └────────┘ │ uses │ + * │ kaos │ + * └────────┘ + * + * The test drives a real `ClientSideConnection`+`AgentSideConnection` + * pair over an in-memory ndjson stream, advertising + * `clientCapabilities.fs.readTextFile = true` so the agent activates + * `AcpKaos`. A mock harness's `Session.prompt` calls + * `getCurrentKaos().readText('/path/x.ts')` inside its body — exactly + * what a real Read tool would do — and emits the returned content as + * an `assistant.delta`. We assert that the client's `readTextFile` + * handler was invoked with the expected path AND that the assistant + * chunk carrying the unsaved-buffer content reached the client. + */ + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { getCurrentKaos } from '@moonshot-ai/kaos'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * A `Client` that: + * - Records every `readTextFile` request the agent sends. + * - Returns `unsavedContent` for those requests (the "unsaved buffer" + * payload). + * - Captures every `sessionUpdate` so the test can verify the + * assistant chunks carrying the content reached the client. + */ +class UnsavedBufferClient implements Client { + readonly readRequests: ReadTextFileRequest[] = []; + readonly updates: SessionNotification[] = []; + unsavedContent = 'UNSAVED BUFFER CONTENT'; + + async readTextFile(p: ReadTextFileRequest): Promise { + this.readRequests.push(p); + return { content: this.unsavedContent }; + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('writeTextFile not exercised in this e2e test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('requestPermission not exercised in this e2e test'); + } +} + +/** + * Build a fake `Session` whose `prompt(parts)` performs a tool-shaped + * action: it pulls `getCurrentKaos()` (the `AcpKaos` the agent wired + * for this prompt), reads `targetPath`, emits the contents as an + * assistant delta, and then ends the turn. This stands in for the + * Read tool inside the SDK loop without dragging the full SDK harness + * into the test. + */ +function makeReadingSession(sessionId: string, targetPath: string): Session { + const listeners = new Set<(event: Event) => void>(); + return { + id: sessionId, + prompt: async (_input: unknown) => { + // This call is the FS reverse-RPC trigger — it's what a real + // file-read tool would invoke. The `AcpKaos` activated by + // `AcpSession.prompt` makes this hit the client's + // `readTextFile` handler over the wire. + const content = await getCurrentKaos().readText(targetPath); + + for (const fn of listeners) { + fn({ + type: 'assistant.delta', + sessionId, + agentId: 'main', + turnId: 1, + delta: content, + } as Event); + } + for (const fn of listeners) { + fn({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'completed', + } as Event); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('end-to-end FS reverse-RPC', () => { + it('routes a tool-time readText through the client when fs.readTextFile is advertised', async () => { + const sessionId = 'sess-fs-e2e'; + const targetPath = '/Users/test/x.ts'; + const session = makeReadingSession(sessionId, targetPath); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const bufferClient = new UnsavedBufferClient(); + const client = new ClientSideConnection(() => bufferClient, clientStream); + + // Initialize with the FS read capability advertised — this is the + // wire signal that switches the agent to `AcpKaos`. + await client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: false, + }, + }); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const response = await client.prompt({ + sessionId, + prompt: [textBlock('read the unsaved file please')], + }); + + expect(response.stopReason).toBe('end_turn'); + + // ── Assertion 1: the client saw exactly one fs/readTextFile + // request with the expected path and matching sessionId. + expect(bufferClient.readRequests).toHaveLength(1); + expect(bufferClient.readRequests[0]).toMatchObject({ + sessionId, + path: targetPath, + }); + + // Give the agent a tick to flush the queued sessionUpdate write + // through the ndjson stream (assistant chunks are fire-and-forget + // — see `session.ts` comments). + await new Promise((resolve) => setTimeout(resolve, 20)); + + // ── Assertion 2: the assistant chunk carrying the unsaved-buffer + // content reached the client, proving end-to-end plumbing. + const chunkUpdate = bufferClient.updates.find( + (u) => u.update.sessionUpdate === 'agent_message_chunk', + ); + expect(chunkUpdate).toBeDefined(); + expect(chunkUpdate?.update).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'UNSAVED BUFFER CONTENT' }, + }); + }); + + it('does NOT route through the client when no FS capability is advertised', async () => { + // Sanity counterpart: the same wiring without the FS capability + // must fall back to local FS (which would attempt to actually read + // /Users/test/x.ts and fail). We avoid the filesystem touch by + // probing the session-side outcome differently: the prompt body + // now reads a path that DOES exist transiently — we just verify + // that the client never saw a readTextFile request. + const sessionId = 'sess-no-fs-e2e'; + + const session: Session = { + id: sessionId, + // `prompt` here does NOT call getCurrentKaos — that path would + // throw / hit local FS, which we don't want in this test. We + // simply end the turn immediately. The point is: with no FS + // capability, the agent must NOT have built an AcpKaos and the + // client must NOT see any readTextFile RPC even if it had been + // called. + prompt: async () => { + // Emit turn.ended directly through the listener that + // session.onEvent registered. + for (const fn of listeners) { + fn({ + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'completed', + } as Event); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; + const listeners = new Set<(event: Event) => void>(); + + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const bufferClient = new UnsavedBufferClient(); + const client = new ClientSideConnection(() => bufferClient, clientStream); + + await client.initialize({ + protocolVersion: 1, + clientCapabilities: { + // Both flags absent — agent must not activate AcpKaos. + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const response = await client.prompt({ + sessionId, + prompt: [textBlock('hi')], + }); + + expect(response.stopReason).toBe('end_turn'); + expect(bufferClient.readRequests).toEqual([]); + }); +}); diff --git a/packages/acp-adapter/test/e2e-happy-path.test.ts b/packages/acp-adapter/test/e2e-happy-path.test.ts new file mode 100644 index 000000000..5d2065b38 --- /dev/null +++ b/packages/acp-adapter/test/e2e-happy-path.test.ts @@ -0,0 +1,295 @@ +/** + * End-to-end "happy path" exercise: + * + * initialize → session/new → session/prompt → end_turn + * + * The test wires an `AgentSideConnection` and a `ClientSideConnection` + * over an in-memory NDJSON pipe (matching `test/e2e-fs.test.ts`'s + * Phase 6 pattern), drives the full ACP handshake from the client + * side, and asserts: + * + * 1. `initialize` returns the documented capability matrix + * (PLAN D4: image=true, audio=false, embeddedContext=false, + * mcp.http=true, mcp.sse=false, loadSession=true, + * sessionCapabilities.list={}). + * 2. `session/new` returns a non-empty sessionId. + * 3. `session/prompt` streams at least one `agent_message_chunk` + * update and resolves with `stopReason: 'end_turn'`. + * 4. `session/cancel` mid-stream resolves the prompt with + * `stopReason: 'cancelled'` and does not throw. + * + * The `promptUpdates` getter filters out the `available_commands_update` + * one-shot that `newSession` emits (Phase 9), matching the pattern + * established in `test/session-prompt.test.ts:24-37`. + */ + +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + + /** + * Filters out the `available_commands_update` one-shot that + * `session/new` emits (Phase 9), so prompt-update assertions only + * see chunks produced by the actual turn. + */ + get promptUpdates(): readonly SessionNotification[] { + return this.updates.filter( + (n) => + (n.update as { sessionUpdate?: string }).sessionUpdate !== + 'available_commands_update', + ); + } + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CollectingClient.requestPermission should not be called in happy-path test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CollectingClient.writeTextFile should not be called in happy-path test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CollectingClient.readTextFile should not be called in happy-path test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * Build a scripted Session whose `prompt()` synchronously emits a + * pre-recorded sequence of `Event`s through any subscribed listener. + * `onEvent` tracks listener registrations so the test can assert + * the AcpSession unsubscribes after `turn.ended`. + */ +function makeScriptedSession( + sessionId: string, + script: readonly Event[], +): { + session: Session; + unsubscribeCount: () => number; +} { + const listeners = new Set<(event: Event) => void>(); + let unsubCount = 0; + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + for (const ev of script) { + for (const fn of listeners) fn(ev); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + unsubCount += 1; + listeners.delete(fn); + }; + }, + } as unknown as Session; + return { session, unsubscribeCount: () => unsubCount }; +} + +function makeHarness(session: Session): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + // Phase 14: server.newSession reads these for configOptions. + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([{ id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }]), + }), + } as unknown as KimiHarness; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('AcpServer end-to-end happy path', () => { + it('initialize advertises the documented capability matrix (PLAN D4)', async () => { + // No session-side work here — just exercise the `initialize` + // handshake to lock the capability surface. `createSession` would + // throw if it were ever called. + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => { + throw new Error('createSession should not be called from initialize-only test'); + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + const response = await client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + }, + }); + + // ACP `protocolVersion` is the integer the server agreed on; we + // just assert it is a number — Phase 1 already pins the exact + // negotiated value in version.test.ts. + expect(typeof response.protocolVersion).toBe('number'); + + expect(response.agentCapabilities).toMatchObject({ + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: false, + }, + mcpCapabilities: { + http: true, + sse: false, + }, + sessionCapabilities: { + list: {}, + resume: {}, + }, + }); + + // Phase 10 does not supply agentInfo; authMethods advertises terminal-auth. + expect(response.agentInfo).toBeUndefined(); + expect(response.authMethods).toHaveLength(1); + expect(response.authMethods?.[0]).toMatchObject({ + id: 'login', + type: 'terminal', + args: ['--login'], + }); + }); + + it('drives the full happy path: initialize → newSession → prompt(end_turn)', async () => { + const sessionId = 'sess-e2e-happy'; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, [ + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'echo ' } as Event, + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'hi' } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event, + ]); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + // 1. initialize + const init = await client.initialize({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }); + expect(init.agentCapabilities?.mcpCapabilities?.http).toBe(true); + + // 2. session/new + const newRes = await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + expect(newRes.sessionId).toBe(sessionId); + expect(typeof newRes.sessionId).toBe('string'); + expect(newRes.sessionId.length).toBeGreaterThan(0); + // Phase 14 (PLAN D11) configOptions advertisement — replaces + // Phase 12.1's dedicated `modes:` field on NewSessionResponse with + // the spec's generic `configOptions:` surface. The dedicated field + // must be gone, and the mode picker still reports `currentValue: + // 'default'` (Phase 12.1 default mode). + expect(newRes.modes).toBeUndefined(); + expect( + newRes.configOptions?.find((o) => o.id === 'mode')?.currentValue, + ).toBe('default'); + expect(newRes.configOptions?.length).toBe(2); + + // 3. session/prompt + const promptRes = await client.prompt({ + sessionId, + prompt: [textBlock('echo hi')], + }); + expect(promptRes.stopReason).toBe('end_turn'); + + // Give the agent side a tick to flush queued sessionUpdate writes + // through the ndjson stream (matching session-prompt.test.ts:128). + await new Promise((resolve) => setTimeout(resolve, 20)); + + const promptOnlyUpdates = collecting.promptUpdates; + expect(promptOnlyUpdates.length).toBeGreaterThanOrEqual(1); + + // At least one chunk must be non-empty text on this session id. + const firstChunk = promptOnlyUpdates[0]?.update as { + sessionUpdate?: string; + content?: { type?: string; text?: string }; + }; + expect(firstChunk.sessionUpdate).toBe('agent_message_chunk'); + expect(firstChunk.content?.type).toBe('text'); + expect(firstChunk.content?.text).toBeTruthy(); + for (const note of promptOnlyUpdates) { + expect(note.sessionId).toBe(sessionId); + } + + // Listener was unsubscribed when turn.ended landed. + expect(unsubscribeCount()).toBe(1); + }); + + it('cancel mid-stream resolves with stopReason cancelled', async () => { + const sessionId = 'sess-e2e-cancel'; + // Scripted session that emits one delta, then a cancelled + // turn.ended. The ACP `cancel` notification flows through the + // adapter; we assert the prompt resolves with `cancelled` and + // does not throw. + const { session } = makeScriptedSession(sessionId, [ + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'partial' } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'cancelled' } as Event, + ]); + const harness = makeHarness(session); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.initialize({ + protocolVersion: 1, + clientCapabilities: { fs: { readTextFile: false, writeTextFile: false } }, + }); + await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + // Fire-and-forget the cancel notification before awaiting prompt. + // The scripted session emits turn.ended(cancelled) regardless; + // this verifies the cancel notification does not throw when the + // session is known (sessionId resolves to the registered + // AcpSession in `AcpServer.cancel`). + const promptPromise = client.prompt({ + sessionId, + prompt: [textBlock('long task')], + }); + await client.cancel({ sessionId }); + const promptRes = await promptPromise; + expect(promptRes.stopReason).toBe('cancelled'); + }); +}); diff --git a/packages/acp-adapter/test/error-mapping.test.ts b/packages/acp-adapter/test/error-mapping.test.ts new file mode 100644 index 000000000..ab08d04be --- /dev/null +++ b/packages/acp-adapter/test/error-mapping.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { + ErrorCodes, + KimiError, + type Event, + type KimiErrorPayload, + type KimiHarness, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in error-mapping test'); + } + // Notifications are best-effort; let them no-op so the agent side + // doesn't backpressure on a missing handler. + async sessionUpdate(_n: SessionNotification): Promise {} + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in error-mapping test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in error-mapping test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface ScriptedSession { + session: Session; + unsubscribeCount: () => number; +} + +/** + * Build a fake `Session` whose `prompt()` either rejects with a + * caller-supplied error OR fans out a pre-recorded event sequence + * through any subscribed listener — covering the two distinct error + * paths that {@link AcpSession.prompt} routes through + * `mapPromptError` / `authRequiredFromPayload`. + */ +function makeScriptedSession( + sessionId: string, + opts: { script?: readonly Event[]; rejectWith?: Error }, +): ScriptedSession { + const listeners = new Set<(event: Event) => void>(); + let unsubCount = 0; + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + if (opts.rejectWith) throw opts.rejectWith; + if (opts.script) { + for (const ev of opts.script) { + for (const fn of listeners) fn(ev); + } + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + unsubCount += 1; + listeners.delete(fn); + }; + }, + } as unknown as Session; + return { session, unsubscribeCount: () => unsubCount }; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +function makeHarnessWithSession(session: Session): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; +} + +describe('AcpServer error mapping', () => { + it('maps a turn.ended failed event with auth.login_required to authRequired (-32000)', async () => { + const sessionId = 'sess-auth-payload'; + const errorPayload: KimiErrorPayload = { + code: ErrorCodes.AUTH_LOGIN_REQUIRED, + message: 'Login required', + retryable: false, + }; + const { session } = makeScriptedSession(sessionId, { + script: [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'failed', + error: errorPayload, + } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await expect( + client.prompt({ sessionId, prompt: [textBlock('hi')] }), + ).rejects.toMatchObject({ code: -32000 }); + }); + + it('maps a turn.ended failed event with provider.auth_error to authRequired (-32000)', async () => { + const sessionId = 'sess-provider-auth'; + const errorPayload: KimiErrorPayload = { + code: ErrorCodes.PROVIDER_AUTH_ERROR, + message: 'Provider returned 401', + retryable: false, + }; + const { session } = makeScriptedSession(sessionId, { + script: [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'failed', + error: errorPayload, + } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await expect( + client.prompt({ sessionId, prompt: [textBlock('hi')] }), + ).rejects.toMatchObject({ code: -32000 }); + }); + + it('resolves with end_turn when turn.ended fails with a non-auth code (log-only path)', async () => { + // Non-auth failures stay on the existing log-and-resolve path so + // the client is unblocked. The error appears in the agent log; + // `stopReason` does not signal it (ACP spec discourages errors-via-stopReason). + const sessionId = 'sess-context-overflow'; + const errorPayload: KimiErrorPayload = { + code: ErrorCodes.CONTEXT_OVERFLOW, + message: 'Context window exceeded', + retryable: true, + }; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, { + script: [ + { + type: 'turn.ended', + sessionId, + agentId: 'main', + turnId: 1, + reason: 'failed', + error: errorPayload, + } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + expect(response.stopReason).toBe('end_turn'); + expect(unsubscribeCount()).toBe(1); + }); + + it('maps a synchronous session.prompt rejection carrying an auth code to authRequired (-32000)', async () => { + const sessionId = 'sess-prompt-rejects-auth'; + const { session } = makeScriptedSession(sessionId, { + rejectWith: new KimiError(ErrorCodes.PROVIDER_AUTH_ERROR, 'Provider 401'), + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await expect( + client.prompt({ sessionId, prompt: [textBlock('hi')] }), + ).rejects.toMatchObject({ code: -32000 }); + }); + + it('maps a generic session.prompt rejection to internalError (-32603) without leaking the stack', async () => { + const sessionId = 'sess-generic-error'; + const stackTip = 'super-secret-stack-frame-do-not-leak'; + const generic = new Error('boom internal'); + generic.stack = `Error: boom internal\n at ${stackTip} (secret.ts:1:1)`; + const { session } = makeScriptedSession(sessionId, { rejectWith: generic }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + let captured: unknown; + try { + await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + } catch (err) { + captured = err; + } + expect(captured).toMatchObject({ code: -32603 }); + // Privacy guarantee: the JSON-RPC error response carries only the + // `code` (and optionally a structured `data`); neither the + // original stack nor the raw message crosses the wire. We assert + // negatively rather than on the canonical message because the + // ACP SDK strips the message from the deserialized client-side + // error and only retains the code. + const serialized = JSON.stringify(captured); + expect(serialized).not.toContain(stackTip); + expect(serialized).not.toContain('boom internal'); + }); + + it('still maps reason: cancelled to stop_reason: cancelled (Phase 3/4 regression guard)', async () => { + const sessionId = 'sess-cancel-regression'; + const { session } = makeScriptedSession(sessionId, { + script: [ + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'cancelled' } as Event, + ], + }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(makeHarnessWithSession(session), c), agentStream); + const client = new ClientSideConnection(() => new StubClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('hi')] }); + expect(response.stopReason).toBe('cancelled'); + }); +}); diff --git a/packages/acp-adapter/test/ext-methods.test.ts b/packages/acp-adapter/test/ext-methods.test.ts new file mode 100644 index 000000000..3411e67ef --- /dev/null +++ b/packages/acp-adapter/test/ext-methods.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in ext-methods test'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in ext-methods test'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in ext-methods test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in ext-methods test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeMinimalHarness(): KimiHarness { + // ext_method does not touch the harness; the auth/session surface + // is irrelevant for these tests so the stub keeps the harness flat. + return {} as unknown as KimiHarness; +} + +describe('AcpServer ext method surface', () => { + it('unit-level extMethod throws RequestError.methodNotFound with the method name', async () => { + const server = new AcpServer(makeMinimalHarness()); + await expect(server.extMethod('myorg.foo', {})).rejects.toMatchObject({ + // JSON-RPC method-not-found code per ACP SDK RequestError.methodNotFound. + code: -32601, + // RequestError stamps the requested method name into the message + // so clients can distinguish "ext/foo" from "ext/bar". + message: expect.stringContaining('myorg.foo'), + }); + }); + + it('unit-level extNotification throws RequestError.methodNotFound with the method name', async () => { + const server = new AcpServer(makeMinimalHarness()); + await expect(server.extNotification('myorg.bar', {})).rejects.toMatchObject({ + code: -32601, + message: expect.stringContaining('myorg.bar'), + }); + }); + + it('over-the-wire extMethod surfaces -32601 to a remote ACP client', async () => { + const harness = makeMinimalHarness(); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + await expect(client.extMethod('myorg.unsupported', {})).rejects.toMatchObject({ + code: -32601, + }); + }); +}); diff --git a/packages/acp-adapter/test/hide-output.test.ts b/packages/acp-adapter/test/hide-output.test.ts new file mode 100644 index 000000000..74b6d13f4 --- /dev/null +++ b/packages/acp-adapter/test/hide-output.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest'; + +import { toolResultToAcpContent } from '../src/convert'; +import { HideOutputMarker, isHideOutputMarker } from '../src/marker'; + +/** + * Phase 4.3 — `HideOutputMarker` lets a tool implementation tell the + * ACP adapter "I own my own UI surface, don't render my textual + * output as a `tool_call_update` content entry". The chosen detection + * mechanism (A) inspects `ToolResultEvent.output`: if `output` is an + * array and any element matches the marker, the adapter returns an + * empty content array. + */ +describe('HideOutputMarker', () => { + it('isHideOutputMarker returns true for the exported marker (reference identity)', () => { + expect(isHideOutputMarker(HideOutputMarker)).toBe(true); + }); + + it('isHideOutputMarker accepts a structural twin (same __kind tag)', () => { + // Defensive escape hatch — a structural clone (e.g. crossing a + // worker_threads boundary) loses identity but preserves the tag. + expect(isHideOutputMarker({ __kind: 'acp-hide-output' })).toBe(true); + }); + + it('isHideOutputMarker rejects null / undefined / primitives', () => { + expect(isHideOutputMarker(null)).toBe(false); + expect(isHideOutputMarker(undefined)).toBe(false); + expect(isHideOutputMarker('x')).toBe(false); + expect(isHideOutputMarker(0)).toBe(false); + expect(isHideOutputMarker(false)).toBe(false); + }); + + it('isHideOutputMarker rejects objects without the __kind tag', () => { + expect(isHideOutputMarker({})).toBe(false); + expect(isHideOutputMarker({ kind: 'acp-hide-output' })).toBe(false); + expect(isHideOutputMarker({ __kind: 'something-else' })).toBe(false); + }); +}); + +describe('toolResultToAcpContent + HideOutputMarker', () => { + it('returns [] when output array contains the marker (reference identity)', () => { + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: [HideOutputMarker, 'fallback text we should NOT see'], + } as never); + expect(content).toEqual([]); + }); + + it('returns [] when output array contains a structural twin of the marker', () => { + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: [{ __kind: 'acp-hide-output' }, 'fallback'], + } as never); + expect(content).toEqual([]); + }); + + it('returns content normally when output array does NOT contain the marker', () => { + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: ['just', 'a', 'normal', 'array'], + } as never); + // Array outputs are JSON-stringified into a single text block. + expect(content).toEqual([ + { + type: 'content', + content: { type: 'text', text: JSON.stringify(['just', 'a', 'normal', 'array']) }, + }, + ]); + }); + + it('does NOT trigger on string output containing the marker tag as substring', () => { + // Reference / __kind identity ONLY — substring match would be a + // false-positive denial of legitimate stdout text. + const text = 'stdout contains __kind:acp-hide-output literal somewhere'; + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: text, + } as never); + expect(content).toEqual([ + { type: 'content', content: { type: 'text', text } }, + ]); + }); +}); diff --git a/packages/acp-adapter/test/kaos-acp.test.ts b/packages/acp-adapter/test/kaos-acp.test.ts new file mode 100644 index 000000000..ea3de3e3c --- /dev/null +++ b/packages/acp-adapter/test/kaos-acp.test.ts @@ -0,0 +1,442 @@ +/** + * Unit tests for {@link AcpKaos}. Uses a hand-rolled mock of + * {@link AgentSideConnection} that records calls and lets each test + * stub `readTextFile` / `writeTextFile` independently — much cheaper + * than spinning up the full ndjson pipe for the per-method assertions + * (we already have an end-to-end test in `e2e-fs.test.ts` for the wire + * round-trip). + */ + +import type { + AgentSideConnection, + ReadTextFileRequest, + ReadTextFileResponse, + WriteTextFileRequest, + WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { KaosError, type Environment, type Kaos, type KaosProcess, type StatResult } from '@moonshot-ai/kaos'; +import { describe, expect, it } from 'vitest'; + +import { AcpKaos } from '../src/kaos-acp'; + +interface MockConn { + readCalls: ReadTextFileRequest[]; + writeCalls: WriteTextFileRequest[]; + readHandler: (req: ReadTextFileRequest) => Promise; + writeHandler: (req: WriteTextFileRequest) => Promise; + asConn(): AgentSideConnection; +} + +function makeMockConn(opts: { + readHandler?: (req: ReadTextFileRequest) => Promise; + writeHandler?: (req: WriteTextFileRequest) => Promise; +}): MockConn { + const readCalls: ReadTextFileRequest[] = []; + const writeCalls: WriteTextFileRequest[] = []; + const readHandler = + opts.readHandler ?? (async () => ({ content: '' } as ReadTextFileResponse)); + const writeHandler = + opts.writeHandler ?? (async () => ({} as WriteTextFileResponse)); + const conn = { + readTextFile: async (req: ReadTextFileRequest) => { + readCalls.push(req); + return readHandler(req); + }, + writeTextFile: async (req: WriteTextFileRequest) => { + writeCalls.push(req); + return writeHandler(req); + }, + } as unknown as AgentSideConnection; + return { + readCalls, + writeCalls, + readHandler, + writeHandler, + asConn: () => conn, + }; +} + +/** + * Minimal stub of an inner {@link Kaos}. Records delegation; throws if + * a non-pass-through method is called (defensive — those should never + * land here in the bridging layer). + */ +interface MockInnerKaos extends Kaos { + __spy: { + pathClassCalls: number; + normpathCalls: string[]; + gethomeCalls: number; + getcwdCalls: number; + chdirCalls: string[]; + withCwdCalls: string[]; + statCalls: Array<{ path: string; options?: { followSymlinks?: boolean } }>; + iterdirCalls: string[]; + globCalls: Array<{ path: string; pattern: string; options?: { caseSensitive?: boolean } }>; + mkdirCalls: Array<{ path: string; options?: { parents?: boolean; existOk?: boolean } }>; + execCalls: string[][]; + execWithEnvCalls: Array<{ args: string[]; env?: Record }>; + readTextCalls: string[]; + writeTextCalls: Array<{ path: string; data: string }>; + }; +} + +function makeMockInner(): MockInnerKaos { + const spy = { + pathClassCalls: 0, + normpathCalls: [] as string[], + gethomeCalls: 0, + getcwdCalls: 0, + chdirCalls: [] as string[], + withCwdCalls: [] as string[], + statCalls: [] as Array<{ path: string; options?: { followSymlinks?: boolean } }>, + iterdirCalls: [] as string[], + globCalls: [] as Array<{ path: string; pattern: string; options?: { caseSensitive?: boolean } }>, + mkdirCalls: [] as Array<{ path: string; options?: { parents?: boolean; existOk?: boolean } }>, + execCalls: [] as string[][], + execWithEnvCalls: [] as Array<{ args: string[]; env?: Record }>, + readTextCalls: [] as string[], + writeTextCalls: [] as Array<{ path: string; data: string }>, + }; + + const inner: MockInnerKaos = { + __spy: spy, + name: 'mock-inner', + osEnv: { os: 'linux', shell: 'bash' } as unknown as Environment, + pathClass: () => { + spy.pathClassCalls += 1; + return 'posix'; + }, + normpath: (p: string) => { + spy.normpathCalls.push(p); + return p; + }, + gethome: () => { + spy.gethomeCalls += 1; + return '/home/mock'; + }, + getcwd: () => { + spy.getcwdCalls += 1; + return '/cwd'; + }, + chdir: async (p: string) => { + spy.chdirCalls.push(p); + }, + withCwd: (cwd: string) => { + spy.withCwdCalls.push(cwd); + // Return a fresh inner stub so the wrapper test can verify the + // returned AcpKaos still bridges through the same conn. + const child = makeMockInner(); + return child; + }, + stat: async (path: string, options?: { followSymlinks?: boolean }) => { + spy.statCalls.push({ path, options }); + return { + stMode: 0o100644, + stIno: 1, + stDev: 1, + stNlink: 1, + stUid: 0, + stGid: 0, + stSize: 0, + stAtime: 0, + stMtime: 0, + stCtime: 0, + } as StatResult; + }, + // eslint-disable-next-line require-yield + iterdir: async function* (path: string) { + spy.iterdirCalls.push(path); + }, + // eslint-disable-next-line require-yield + glob: async function* ( + path: string, + pattern: string, + options?: { caseSensitive?: boolean }, + ) { + spy.globCalls.push({ path, pattern, options }); + }, + mkdir: async (path: string, options?: { parents?: boolean; existOk?: boolean }) => { + spy.mkdirCalls.push({ path, options }); + }, + exec: async (...args: string[]) => { + spy.execCalls.push(args); + return {} as KaosProcess; + }, + execWithEnv: async (args: string[], env?: Record) => { + spy.execWithEnvCalls.push({ args, env }); + return {} as KaosProcess; + }, + readBytes: async () => Buffer.alloc(0), + readText: async (path: string) => { + // Used to verify that AcpKaos.readText does NOT fall back to inner. + spy.readTextCalls.push(path); + return 'INNER'; + }, + readLines: async function* () {}, + writeBytes: async () => 0, + writeText: async (path: string, data: string) => { + spy.writeTextCalls.push({ path, data }); + return data.length; + }, + }; + return inner; +} + +describe('AcpKaos', () => { + describe('readText', () => { + it('forwards path and sessionId to conn.readTextFile, returning response.content', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'HELLO' }), + }); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + const result = await kaos.readText('/a.ts'); + + expect(result).toBe('HELLO'); + expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/a.ts' }]); + // Crucially: inner.readText must NOT be called — we bridge through ACP. + expect(inner.__spy.readTextCalls).toEqual([]); + }); + + it('wraps RPC errors in KaosError with cause set', async () => { + const rpcErr = new Error('rpc died'); + const conn = makeMockConn({ + readHandler: async () => { + throw rpcErr; + }, + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + + await expect(kaos.readText('/x.ts')).rejects.toMatchObject({ + name: 'KaosError', + }); + await expect(kaos.readText('/x.ts')).rejects.toBeInstanceOf(KaosError); + // Verify cause is preserved. + try { + await kaos.readText('/x.ts'); + throw new Error('should have thrown'); + } catch (err) { + expect((err as Error & { cause?: unknown }).cause).toBe(rpcErr); + expect((err as Error).message).toContain('acp: readTextFile failed for /x.ts'); + expect((err as Error).message).toContain('rpc died'); + } + }); + }); + + describe('readBytes', () => { + it('returns the first N utf8 bytes of the file content', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'abcdef' }), + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + + const buf = await kaos.readBytes('/a.ts', 3); + expect(buf).toBeInstanceOf(Buffer); + expect(buf.toString('utf8')).toBe('abc'); + }); + + it('returns the full buffer when n is omitted', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'abcdef' }), + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + + const buf = await kaos.readBytes('/a.ts'); + expect(buf.toString('utf8')).toBe('abcdef'); + }); + }); + + describe('readLines', () => { + async function collect(gen: AsyncGenerator): Promise { + const out: string[] = []; + for await (const line of gen) out.push(line); + return out; + } + + it('yields each line of "a\\nb\\nc"', async () => { + const conn = makeMockConn({ readHandler: async () => ({ content: 'a\nb\nc' }) }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + expect(await collect(kaos.readLines('/a.ts'))).toEqual(['a', 'b', 'c']); + }); + + it('drops the trailing empty token when the file ends with a newline', async () => { + // "a\nb\n" → ['a', 'b'] (NOT ['a', 'b', '']) + const conn = makeMockConn({ readHandler: async () => ({ content: 'a\nb\n' }) }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + expect(await collect(kaos.readLines('/a.ts'))).toEqual(['a', 'b']); + }); + + it('yields the final line without a trailing newline', async () => { + const conn = makeMockConn({ readHandler: async () => ({ content: 'a\nb' }) }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + expect(await collect(kaos.readLines('/a.ts'))).toEqual(['a', 'b']); + }); + + it('yields nothing for an empty file', async () => { + const conn = makeMockConn({ readHandler: async () => ({ content: '' }) }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + expect(await collect(kaos.readLines('/a.ts'))).toEqual([]); + }); + }); + + describe('writeText', () => { + it('forwards content to conn.writeTextFile and returns char count', async () => { + const conn = makeMockConn({}); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + const n = await kaos.writeText('/a.ts', 'hello'); + expect(n).toBe(5); + expect(conn.writeCalls).toEqual([{ sessionId: 's1', path: '/a.ts', content: 'hello' }]); + }); + + it('append mode merges with existing content', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'old:' }), + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + const n = await kaos.writeText('/a.ts', 'new', { mode: 'a' }); + // Return value is the size of the appended data, not the merged size. + expect(n).toBe(3); + // First a read, then a write with the merged content. + expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/a.ts' }]); + expect(conn.writeCalls).toEqual([ + { sessionId: 's1', path: '/a.ts', content: 'old:new' }, + ]); + }); + + it('append mode treats a missing file (read error) as empty existing content', async () => { + const conn = makeMockConn({ + readHandler: async () => { + throw new Error('ENOENT'); + }, + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + const n = await kaos.writeText('/missing.ts', 'fresh', { mode: 'a' }); + expect(n).toBe(5); + expect(conn.writeCalls).toEqual([ + { sessionId: 's1', path: '/missing.ts', content: 'fresh' }, + ]); + }); + + it('wraps writeTextFile RPC errors in KaosError with cause set', async () => { + const rpcErr = new Error('write rpc died'); + const conn = makeMockConn({ + writeHandler: async () => { + throw rpcErr; + }, + }); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + + await expect(kaos.writeText('/a.ts', 'hello')).rejects.toBeInstanceOf(KaosError); + try { + await kaos.writeText('/a.ts', 'hello'); + } catch (err) { + expect((err as Error & { cause?: unknown }).cause).toBe(rpcErr); + expect((err as Error).message).toContain('acp: writeTextFile failed for /a.ts'); + expect((err as Error).message).toContain('write rpc died'); + } + }); + }); + + describe('writeBytes', () => { + it('forwards utf8-decoded content via conn.writeTextFile, returns byte count', async () => { + const conn = makeMockConn({}); + const kaos = new AcpKaos(conn.asConn(), 's1', makeMockInner()); + const n = await kaos.writeBytes('/a.ts', Buffer.from('hi')); + expect(n).toBe(2); + expect(conn.writeCalls).toEqual([{ sessionId: 's1', path: '/a.ts', content: 'hi' }]); + }); + }); + + describe('withCwd', () => { + it('returns an AcpKaos that still bridges through the same conn', async () => { + const conn = makeMockConn({ + readHandler: async () => ({ content: 'BRIDGED' }), + }); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + const child = kaos.withCwd('/new/cwd'); + + expect(child).toBeInstanceOf(AcpKaos); + // Reading on the wrapped child must still hit the mocked ACP conn, + // NOT the inner Kaos's local readText. + const text = await child.readText('/foo.ts'); + expect(text).toBe('BRIDGED'); + expect(conn.readCalls).toEqual([{ sessionId: 's1', path: '/foo.ts' }]); + expect(inner.__spy.withCwdCalls).toEqual(['/new/cwd']); + }); + }); + + describe('pass-through delegation', () => { + it('delegates pathClass, normpath, gethome, getcwd to inner', () => { + const conn = makeMockConn({}); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + expect(kaos.pathClass()).toBe('posix'); + expect(kaos.normpath('/foo')).toBe('/foo'); + expect(kaos.gethome()).toBe('/home/mock'); + expect(kaos.getcwd()).toBe('/cwd'); + + expect(inner.__spy.pathClassCalls).toBe(1); + expect(inner.__spy.normpathCalls).toEqual(['/foo']); + expect(inner.__spy.gethomeCalls).toBe(1); + expect(inner.__spy.getcwdCalls).toBe(1); + }); + + it('delegates chdir, stat, mkdir to inner', async () => { + const conn = makeMockConn({}); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + await kaos.chdir('/x'); + await kaos.stat('/y', { followSymlinks: false }); + await kaos.mkdir('/z', { parents: true }); + + expect(inner.__spy.chdirCalls).toEqual(['/x']); + expect(inner.__spy.statCalls).toEqual([{ path: '/y', options: { followSymlinks: false } }]); + expect(inner.__spy.mkdirCalls).toEqual([{ path: '/z', options: { parents: true } }]); + }); + + it('delegates iterdir and glob to inner', async () => { + const conn = makeMockConn({}); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + // Just consume the generators — the inner spy records the call. + for await (const _ of kaos.iterdir('/d')) { + // no-op + } + for await (const _ of kaos.glob('/d', '**/*.ts', { caseSensitive: true })) { + // no-op + } + + expect(inner.__spy.iterdirCalls).toEqual(['/d']); + expect(inner.__spy.globCalls).toEqual([ + { path: '/d', pattern: '**/*.ts', options: { caseSensitive: true } }, + ]); + }); + + it('delegates exec and execWithEnv to inner', async () => { + const conn = makeMockConn({}); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + + await kaos.exec('ls', '-la'); + await kaos.execWithEnv(['env'], { FOO: 'bar' }); + + expect(inner.__spy.execCalls).toEqual([['ls', '-la']]); + expect(inner.__spy.execWithEnvCalls).toEqual([{ args: ['env'], env: { FOO: 'bar' } }]); + }); + }); + + describe('identity', () => { + it('exposes a wrapping name and the inner osEnv', () => { + const conn = makeMockConn({}); + const inner = makeMockInner(); + const kaos = new AcpKaos(conn.asConn(), 's1', inner); + expect(kaos.name).toBe('acp(mock-inner)'); + expect(kaos.osEnv).toBe(inner.osEnv); + }); + }); +}); diff --git a/packages/acp-adapter/test/kaos-activation.test.ts b/packages/acp-adapter/test/kaos-activation.test.ts new file mode 100644 index 000000000..90ffd2781 --- /dev/null +++ b/packages/acp-adapter/test/kaos-activation.test.ts @@ -0,0 +1,252 @@ +/** + * Tests that {@link AcpSession.prompt} activates an {@link AcpKaos} + * (visible to tools via {@link getCurrentKaos}) when, and only when, + * the client advertises `fs.readTextFile` or `fs.writeTextFile`. + * + * Uses scripted `Session` stubs whose `prompt(parts)` synchronously + * calls `getCurrentKaos()` *inside* its body — this is the moment a + * real tool would resolve its Kaos handle, so it's the right place + * to assert the binding propagated through `runWithKaos`. + */ + +import type { AgentSideConnection, ClientCapabilities } from '@agentclientprotocol/sdk'; +import { getCurrentKaos, LocalKaos, runWithKaos } from '@moonshot-ai/kaos'; +import type { Event, Session } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { AcpKaos } from '../src/kaos-acp'; +import { AcpSession } from '../src/session'; + +/** + * Build a minimal {@link AgentSideConnection} stub whose + * `readTextFile` / `writeTextFile` return canned content. The stub + * also captures the calls so tests can verify the bridging path. + */ +function makeFakeConn(opts: { readContent?: string } = {}): { + conn: AgentSideConnection; + readCalls: Array<{ sessionId: string; path: string }>; + writeCalls: Array<{ sessionId: string; path: string; content: string }>; +} { + const readCalls: Array<{ sessionId: string; path: string }> = []; + const writeCalls: Array<{ sessionId: string; path: string; content: string }> = []; + const conn = { + readTextFile: async (req: { sessionId: string; path: string }) => { + readCalls.push({ sessionId: req.sessionId, path: req.path }); + return { content: opts.readContent ?? 'STUB' }; + }, + writeTextFile: async (req: { sessionId: string; path: string; content: string }) => { + writeCalls.push({ sessionId: req.sessionId, path: req.path, content: req.content }); + return {}; + }, + sessionUpdate: async () => undefined, + requestPermission: async () => { + throw new Error('requestPermission should not be called'); + }, + } as unknown as AgentSideConnection; + return { conn, readCalls, writeCalls }; +} + +/** + * Build a `Session` whose `prompt(parts)` calls the supplied probe + * synchronously and then fires `turn.ended` so the outer + * `AcpSession.prompt` resolves promptly. + */ +function makeProbingSession( + sessionId: string, + probe: () => void | Promise, +): Session { + const listeners = new Set<(event: Event) => void>(); + return { + id: sessionId, + prompt: async (_input: unknown) => { + // Run the probe inside the (potentially) runWithKaos-bound async + // subtree — this is exactly where a real tool would observe its + // current Kaos. + await probe(); + for (const fn of listeners) { + fn({ type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; +} + +describe('AcpSession FS-capability activation', () => { + it('binds an AcpKaos as the current Kaos when the client advertises fs.readTextFile', async () => { + const { conn, readCalls } = makeFakeConn({ readContent: 'UNSAVED' }); + + let observedKaosName: string | undefined; + let observedRead: string | undefined; + const session = makeProbingSession('s-fs', async () => { + const current = getCurrentKaos(); + observedKaosName = current.name; + observedRead = await current.readText('/abs/path.ts'); + }); + + // Activation needs a baseline Kaos in the *outer* async context + // (otherwise getCurrentKaos throws when the AsyncLocalStorage store + // is undefined). Real `kimi acp` wires this at startup; tests must + // emulate. Note we deliberately use `runWithKaos` rather than + // `setCurrentKaos` so the outer binding stays test-local. + const outer = await LocalKaos.create(); + const result = await runWithKaos(outer, async () => { + const caps: ClientCapabilities = { fs: { readTextFile: true } }; + const acpSession = new AcpSession(conn, session, caps); + return acpSession.prompt([]); + }); + + expect(result.stopReason).toBe('end_turn'); + // Name probe confirms `runWithKaos` rebound to AcpKaos within the + // scripted prompt body. + expect(observedKaosName).toBe('acp(local)'); + // Read probe confirms the bridge actually routes through the conn. + expect(observedRead).toBe('UNSAVED'); + expect(readCalls).toEqual([{ sessionId: 's-fs', path: '/abs/path.ts' }]); + }); + + it('binds an AcpKaos when only fs.writeTextFile is advertised', async () => { + const { conn, writeCalls } = makeFakeConn(); + let probedName: string | undefined; + const session = makeProbingSession('s-write', async () => { + const current = getCurrentKaos(); + probedName = current.name; + await current.writeText('/abs/out.ts', 'data'); + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + const caps: ClientCapabilities = { fs: { writeTextFile: true } }; + const acpSession = new AcpSession(conn, session, caps); + await acpSession.prompt([]); + }); + + expect(probedName).toBe('acp(local)'); + expect(writeCalls).toEqual([{ sessionId: 's-write', path: '/abs/out.ts', content: 'data' }]); + }); + + it('does NOT wrap when the client advertises no FS capability — current Kaos stays the outer one', async () => { + const { conn, readCalls } = makeFakeConn(); + let observedKaosName: string | undefined; + const session = makeProbingSession('s-nofs', () => { + observedKaosName = getCurrentKaos().name; + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + // No clientCapabilities — equivalent to "client didn't advertise FS" + const acpSession = new AcpSession(conn, session); + await acpSession.prompt([]); + }); + + // Pass-through: tool sees the outer LocalKaos, not an AcpKaos. + expect(observedKaosName).toBe('local'); + expect(readCalls).toEqual([]); + }); + + it('does NOT wrap when the FS capability flags are present-but-false', async () => { + const { conn } = makeFakeConn(); + let observedKaosName: string | undefined; + const session = makeProbingSession('s-falseflags', () => { + observedKaosName = getCurrentKaos().name; + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + const caps: ClientCapabilities = { fs: { readTextFile: false, writeTextFile: false } }; + const acpSession = new AcpSession(conn, session, caps); + await acpSession.prompt([]); + }); + + expect(observedKaosName).toBe('local'); + }); + + it('isolates concurrent prompts on different AcpSessions — each sees its own AcpKaos', async () => { + const { conn: connA } = makeFakeConn({ readContent: 'A-CONTENT' }); + const { conn: connB } = makeFakeConn({ readContent: 'B-CONTENT' }); + + let observedA: string | undefined; + let observedB: string | undefined; + + // Use a manual gate so both prompts overlap — A's probe blocks until + // B has had a chance to enter its scope. This forces the + // AsyncLocalStorage isolation invariant to be exercised: if + // `enterWith` were used naively, B's binding would leak into A. + let resolveA: () => void = () => undefined; + const aGate = new Promise((r) => { + resolveA = r; + }); + + const sessionA = makeProbingSession('s-A', async () => { + // Resolve A's probe AFTER we observe — see comment above. + await new Promise((r) => setTimeout(r, 5)); + observedA = await getCurrentKaos().readText('/file'); + resolveA(); + }); + const sessionB = makeProbingSession('s-B', async () => { + observedB = await getCurrentKaos().readText('/file'); + await aGate; + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + const acpA = new AcpSession(connA, sessionA, { fs: { readTextFile: true } }); + const acpB = new AcpSession(connB, sessionB, { fs: { readTextFile: true } }); + await Promise.all([acpA.prompt([]), acpB.prompt([])]); + }); + + expect(observedA).toBe('A-CONTENT'); + expect(observedB).toBe('B-CONTENT'); + }); + + it('reuses the inner LocalKaos across multiple prompts on the same AcpSession', async () => { + const { conn } = makeFakeConn({ readContent: 'X' }); + let firstInner: import('@moonshot-ai/kaos').Kaos | undefined; + let secondInner: import('@moonshot-ai/kaos').Kaos | undefined; + const session = makeProbingSession('s-reuse', () => { + // Tunnel: AcpKaos wraps an inner Kaos and exposes it indirectly + // via getcwd() (which delegates). For this assertion it's enough + // that the AcpKaos's `name` stays stable AND we observe the + // session through two distinct prompts without errors. + const k = getCurrentKaos(); + if (!firstInner) firstInner = k; + else secondInner = k; + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + const acpSession = new AcpSession(conn, session, { fs: { readTextFile: true } }); + await acpSession.prompt([]); + await acpSession.prompt([]); + }); + + expect(firstInner).toBeDefined(); + expect(secondInner).toBeDefined(); + // Each prompt produces its own AcpKaos wrapper, but the inner + // LocalKaos is reused — we can't directly read the private field, + // but the visible name should stay stable. + expect(firstInner?.name).toBe('acp(local)'); + expect(secondInner?.name).toBe('acp(local)'); + }); + + it('returns an AcpKaos instance from maybeBuildAcpKaos when capable (verified via name + instanceof through prompt path)', async () => { + const { conn } = makeFakeConn(); + let observed: unknown; + const session = makeProbingSession('s-instance', () => { + observed = getCurrentKaos(); + }); + + const outer = await LocalKaos.create(); + await runWithKaos(outer, async () => { + const acpSession = new AcpSession(conn, session, { fs: { readTextFile: true } }); + await acpSession.prompt([]); + }); + + expect(observed).toBeInstanceOf(AcpKaos); + }); +}); diff --git a/packages/acp-adapter/test/log-guard.test.ts b/packages/acp-adapter/test/log-guard.test.ts new file mode 100644 index 000000000..04267834d --- /dev/null +++ b/packages/acp-adapter/test/log-guard.test.ts @@ -0,0 +1,54 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { redirectConsoleToStderr } from '../src/log-guard'; + +describe('redirectConsoleToStderr', () => { + const restorers: Array<() => void> = []; + + afterEach(() => { + while (restorers.length > 0) { + restorers.pop()?.(); + } + vi.restoreAllMocks(); + }); + + it('routes console.log / info / warn to stderr and leaves stdout untouched', () => { + const stdoutSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true); + + const restore = redirectConsoleToStderr(); + restorers.push(restore); + + console.log('hello-log'); + console.info('hello-info'); + console.warn('hello-warn'); + + expect(stdoutSpy).not.toHaveBeenCalled(); + expect(stderrSpy).toHaveBeenCalledWith('hello-log\n'); + expect(stderrSpy).toHaveBeenCalledWith('hello-info\n'); + expect(stderrSpy).toHaveBeenCalledWith('hello-warn\n'); + }); + + it('does not redirect console.error (which already targets stderr)', () => { + const origError = console.error; + const restore = redirectConsoleToStderr(); + restorers.push(restore); + expect(console.error).toBe(origError); + }); + + it('restores the original console sinks when the returned function is called', () => { + const origLog = console.log; + const origInfo = console.info; + const origWarn = console.warn; + + const restore = redirectConsoleToStderr(); + expect(console.log).not.toBe(origLog); + expect(console.info).not.toBe(origInfo); + expect(console.warn).not.toBe(origWarn); + + restore(); + expect(console.log).toBe(origLog); + expect(console.info).toBe(origInfo); + expect(console.warn).toBe(origWarn); + }); +}); diff --git a/packages/acp-adapter/test/mcp-forward.test.ts b/packages/acp-adapter/test/mcp-forward.test.ts new file mode 100644 index 000000000..89ab039d8 --- /dev/null +++ b/packages/acp-adapter/test/mcp-forward.test.ts @@ -0,0 +1,267 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { + AgentSideConnection, + ClientSideConnection, + McpServer, + NewSessionRequest, +} from '@agentclientprotocol/sdk'; +import { + AgentSideConnection as AgentSideConnectionImpl, + ClientSideConnection as ClientSideConnectionImpl, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; +import { log } from '@moonshot-ai/kimi-code-sdk'; +import type { McpServerConfig } from '@moonshot-ai/agent-core'; + +import { acpMcpServersToConfigs } from '../src/mcp'; +import { AcpServer } from '../src/server'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called'); + } + async sessionUpdate(_n: SessionNotification): Promise { + /* drop available_commands_update / etc. — not asserted in this test */ + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface CapturedCall { + options: { workDir: string; mcpServers?: Record }; +} + +function makeHarness( + sessionId: string, + captured: CapturedCall[], +): { + harness: KimiHarness; +} { + const fakeSession = { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { + status: async () => ({ providers: [{ providerName: 'kimi', hasToken: true }] }), + }, + createSession: async (options: CapturedCall['options']) => { + captured.push({ options }); + return fakeSession; + }, + } as unknown as KimiHarness; + return { harness }; +} + +const httpServer = ( + name: string, + url: string, + headers: ReadonlyArray<{ name: string; value: string }>, +): McpServer => + // ACP `McpServer` union with `type: 'http'` is `McpServerHttp & + // { type: 'http' }`. The literal object satisfies the runtime + // shape; the cast bypasses TS's reluctance to widen the readonly + // header array into the union member. + ({ + type: 'http', + name, + url, + headers, + }) as unknown as McpServer; + +const stdioServer = ( + name: string, + command: string, + args: ReadonlyArray, + env: ReadonlyArray<{ name: string; value: string }>, +): McpServer => + // The ACP `McpServer` union has stdio as the bare branch with no + // `type` discriminator (schema 0.23). The cast lets the test + // assemble the literal as the runtime sees it. + ({ + name, + command, + args, + env, + }) as unknown as McpServer; + +const sseServer = ( + name: string, + url: string, + headers: ReadonlyArray<{ name: string; value: string }>, +): McpServer => + ({ + type: 'sse', + name, + url, + headers, + }) as unknown as McpServer; + +const acpServer = (name: string, id: string): McpServer => + ({ + type: 'acp', + name, + id, + }) as unknown as McpServer; + +describe('acpMcpServersToConfigs', () => { + let warnSpy: ReturnType; + + beforeEach(() => { + warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('returns an empty record for undefined input', () => { + expect(acpMcpServersToConfigs(undefined)).toEqual({}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('returns an empty record for an empty list', () => { + expect(acpMcpServersToConfigs([])).toEqual({}); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('converts an HTTP server with headers to a Record keyed by name', () => { + const out = acpMcpServersToConfigs([ + httpServer('docs', 'https://mcp.example.com', [ + { name: 'X-Token', value: 'abc' }, + { name: 'Accept', value: 'application/json' }, + ]), + ]); + expect(out).toEqual({ + docs: { + transport: 'http', + url: 'https://mcp.example.com', + headers: { 'X-Token': 'abc', Accept: 'application/json' }, + }, + }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('converts a stdio server with args + env to a Record keyed by name', () => { + const out = acpMcpServersToConfigs([ + stdioServer( + 'fs', + '/usr/local/bin/mcp-fs', + ['--root', '/tmp'], + [ + { name: 'NODE_ENV', value: 'production' }, + { name: 'DEBUG', value: '1' }, + ], + ), + ]); + expect(out).toEqual({ + fs: { + transport: 'stdio', + command: '/usr/local/bin/mcp-fs', + args: ['--root', '/tmp'], + env: { NODE_ENV: 'production', DEBUG: '1' }, + }, + }); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + it('warn-drops sse servers (PLAN D3 — sse capability is false)', () => { + const out = acpMcpServersToConfigs([ + sseServer('events', 'https://stream.example.com', [{ name: 'X-K', value: 'V' }]), + ]); + expect(out).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'acp: dropping unsupported MCP server transport', + expect.objectContaining({ name: 'events', type: 'sse' }), + ); + }); + + it('warn-drops acp servers (experimental, not supported)', () => { + const out = acpMcpServersToConfigs([acpServer('inner', 'opaque-id')]); + expect(out).toEqual({}); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + 'acp: dropping unsupported MCP server transport', + expect.objectContaining({ name: 'inner', type: 'acp' }), + ); + }); + + it('mixes supported + unsupported transports and warn-drops only the unsupported ones', () => { + const out = acpMcpServersToConfigs([ + httpServer('docs', 'https://h', [{ name: 'X', value: 'v' }]), + sseServer('events', 'https://s', [{ name: 'X', value: 'v' }]), + stdioServer('fs', '/bin/fs', [], []), + ]); + expect(Object.keys(out)).toEqual(['docs', 'fs']); + expect(out['docs']).toMatchObject({ transport: 'http' }); + expect(out['fs']).toMatchObject({ transport: 'stdio' }); + expect(warnSpy).toHaveBeenCalledTimes(1); + }); +}); + +describe('AcpServer session/new MCP forwarding', () => { + it('forwards converted mcpServers to harness.createSession', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-mcp-1', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + let server: AcpServer | undefined; + const _agentConn: AgentSideConnection = new AgentSideConnectionImpl((c) => { + server = new AcpServer(harness, c); + return server; + }, agentStream); + const client: ClientSideConnection = new ClientSideConnectionImpl( + (_a) => new StubClient(), + clientStream, + ); + + const request: NewSessionRequest = { + cwd: '/tmp/work', + mcpServers: [ + httpServer('docs', 'https://mcp.example.com', [{ name: 'Auth', value: 'tok' }]), + sseServer('events', 'https://s', [{ name: 'X', value: 'v' }]), + ], + }; + + const response = await client.newSession(request); + expect(response.sessionId).toBe('sess-mcp-1'); + expect(captured).toHaveLength(1); + expect(captured[0]?.options.workDir).toBe('/tmp/work'); + expect(captured[0]?.options.mcpServers).toEqual({ + docs: { + transport: 'http', + url: 'https://mcp.example.com', + headers: { Auth: 'tok' }, + }, + }); + void _agentConn; + }); +}); diff --git a/packages/acp-adapter/test/plan-and-commands.test.ts b/packages/acp-adapter/test/plan-and-commands.test.ts new file mode 100644 index 000000000..cc5463d73 --- /dev/null +++ b/packages/acp-adapter/test/plan-and-commands.test.ts @@ -0,0 +1,361 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; +import { + availableCommandsUpdateNotification, + planFromDisplayBlock, + todoListToSessionUpdate, +} from '../src/events-map'; + +/** + * Phase 9.3 — end-to-end + helper-unit coverage for: + * - `available_commands_update` emitted ONCE on `session/new` and + * ONCE on `session/load` (empty list — the slash-command registry + * lives in `apps/kimi-code` and is intentionally out-of-scope for + * the adapter; see STATUS for the registry-gap note). + * - `plan` session_update derived from the kimi-code TodoList + * `display.kind === 'todo_list'` payload attached to a + * `tool.call.started` event. + * + * Status mapping is the kimi-code TodoStatus → ACP PlanEntryStatus + * lift: `done` → `completed`, all other names pass through. + */ + +class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error( + 'CollectingClient.requestPermission should not be called in plan-and-commands test', + ); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error( + 'CollectingClient.writeTextFile should not be called in plan-and-commands test', + ); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error( + 'CollectingClient.readTextFile should not be called in plan-and-commands test', + ); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeScriptedSession(sessionId: string, script: readonly Event[]): Session { + const listeners = new Set<(event: Event) => void>(); + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + for (const ev of script) { + for (const fn of listeners) fn(ev); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; + return session; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +async function flushNdjson(): Promise { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +describe('Phase 9.3 unit · todoListToSessionUpdate', () => { + it('maps a populated TodoList into a plan session_update with mapped statuses', () => { + const note = todoListToSessionUpdate('sess-x', 7, [ + { title: 'plan thing', status: 'pending' }, + { title: 'doing thing', status: 'in_progress' }, + { title: 'finished thing', status: 'done' }, + ]); + expect(note).not.toBeNull(); + expect(note?.sessionId).toBe('sess-x'); + expect(note?.update).toEqual({ + sessionUpdate: 'plan', + entries: [ + { content: 'plan thing', priority: 'medium', status: 'pending' }, + { content: 'doing thing', priority: 'medium', status: 'in_progress' }, + { content: 'finished thing', priority: 'medium', status: 'completed' }, + ], + }); + }); + + it('returns null for an empty items array (no spurious empty plan)', () => { + expect(todoListToSessionUpdate('sess-x', 1, [])).toBeNull(); + }); + + it('defaults unknown statuses to pending (defensive)', () => { + const note = todoListToSessionUpdate('sess-x', 1, [ + { title: 'odd', status: 'mysterious' }, + ]); + expect(note?.update).toMatchObject({ + sessionUpdate: 'plan', + entries: [{ content: 'odd', priority: 'medium', status: 'pending' }], + }); + }); + + it('also accepts ACP-style "completed" status verbatim', () => { + const note = todoListToSessionUpdate('sess-x', 1, [ + { title: 'shipped', status: 'completed' }, + ]); + expect(note?.update).toMatchObject({ + sessionUpdate: 'plan', + entries: [{ content: 'shipped', priority: 'medium', status: 'completed' }], + }); + }); +}); + +describe('Phase 9.3 unit · planFromDisplayBlock', () => { + it('translates a todo_list display block into a plan notification', () => { + const note = planFromDisplayBlock('sess-y', 3, { + kind: 'todo_list', + items: [{ title: 'step 1', status: 'pending' }], + }); + expect(note?.update).toEqual({ + sessionUpdate: 'plan', + entries: [{ content: 'step 1', priority: 'medium', status: 'pending' }], + }); + }); + + it('returns null for non-todo_list display kinds', () => { + expect( + planFromDisplayBlock('sess-y', 3, { kind: 'command', command: 'ls' }), + ).toBeNull(); + expect( + planFromDisplayBlock('sess-y', 3, { + kind: 'diff', + path: 'x', + before: 'a', + after: 'b', + }), + ).toBeNull(); + }); +}); + +describe('Phase 9.3 unit · availableCommandsUpdateNotification', () => { + it('builds an available_commands_update with an empty list by default', () => { + expect(availableCommandsUpdateNotification('sess-z')).toEqual({ + sessionId: 'sess-z', + update: { sessionUpdate: 'available_commands_update', availableCommands: [] }, + }); + }); + + it('passes a caller-supplied command list through', () => { + const cmds = [ + { name: 'help', description: 'Show help' }, + { name: 'clear', description: 'Clear the screen' }, + ]; + const note = availableCommandsUpdateNotification('sess-z', cmds); + expect(note.update).toEqual({ + sessionUpdate: 'available_commands_update', + availableCommands: cmds, + }); + }); +}); + +describe('Phase 9.3 e2e · newSession emits available_commands_update once', () => { + it('newSession returns and the client sees exactly one available_commands_update', async () => { + const sessionId = 'sess-cmds-new'; + const session = makeScriptedSession(sessionId, []); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + const response = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + expect(response.sessionId).toBe(sessionId); + await flushNdjson(); + + const cmdUpdates = collecting.updates.filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === + 'available_commands_update', + ); + expect(cmdUpdates).toHaveLength(1); + expect(cmdUpdates[0]?.sessionId).toBe(sessionId); + expect(cmdUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'available_commands_update', + availableCommands: [], + }); + }); +}); + +describe('Phase 9.3 e2e · loadSession emits available_commands_update once', () => { + it('loadSession returns and the client sees exactly one available_commands_update (not duplicated during replay)', async () => { + const sessionId = 'sess-cmds-load'; + const session = { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: () => undefined, + getResumeState: () => ({ + agents: { + main: { + context: { + history: [ + { + role: 'user', + content: [{ type: 'text', text: 'hi' }], + toolCalls: [], + }, + ], + }, + }, + }, + }), + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + resumeSession: async (_opts: { id: string }) => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + await flushNdjson(); + + const cmdUpdates = collecting.updates.filter( + (n) => + (n.update as { sessionUpdate: string }).sessionUpdate === + 'available_commands_update', + ); + expect(cmdUpdates).toHaveLength(1); + }); +}); + +describe('Phase 9.3 e2e · todo_list display block becomes a plan session_update', () => { + it('emits a plan session_update alongside tool_call when a tool.call.started carries display.kind=todo_list', async () => { + const sessionId = 'sess-plan'; + const turnId = 1; + const toolCallId = 'tc-todo'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'TodoList', + args: { todos: [{ title: 'a', status: 'pending' }] }, + display: { + kind: 'todo_list', + items: [ + { title: 'a', status: 'pending' }, + { title: 'b', status: 'in_progress' }, + { title: 'c', status: 'done' }, + ], + }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('plan it')] }); + expect(response.stopReason).toBe('end_turn'); + await flushNdjson(); + + const planUpdates = collecting.updates.filter( + (n) => (n.update as { sessionUpdate: string }).sessionUpdate === 'plan', + ); + expect(planUpdates).toHaveLength(1); + expect(planUpdates[0]?.update).toEqual({ + sessionUpdate: 'plan', + entries: [ + { content: 'a', priority: 'medium', status: 'pending' }, + { content: 'b', priority: 'medium', status: 'in_progress' }, + { content: 'c', priority: 'medium', status: 'completed' }, + ], + }); + }); + + it('does NOT emit plan when no todo_list display is attached', async () => { + const sessionId = 'sess-no-plan'; + const turnId = 1; + const toolCallId = 'tc-read'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + args: { path: 'a' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('read')] }); + await flushNdjson(); + + const planUpdates = collecting.updates.filter( + (n) => (n.update as { sessionUpdate: string }).sessionUpdate === 'plan', + ); + expect(planUpdates).toHaveLength(0); + }); +}); diff --git a/packages/acp-adapter/test/question.test.ts b/packages/acp-adapter/test/question.test.ts new file mode 100644 index 000000000..8b4d22dc7 --- /dev/null +++ b/packages/acp-adapter/test/question.test.ts @@ -0,0 +1,101 @@ +import type { + PermissionOption, + RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; +import type { QuestionItem } from '@moonshot-ai/kimi-code-sdk'; +import { describe, expect, it } from 'vitest'; + +import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from '../src/question'; + +const sampleQuestion: QuestionItem = { + question: 'Pick a flavour', + options: [ + { label: 'Vanilla' }, + { label: 'Chocolate' }, + { label: 'Mint chip' }, + ], +}; + +describe('questionItemToPermissionOptions', () => { + it('maps each option to allow_once + a trailing Skip reject_once', () => { + const opts = questionItemToPermissionOptions(sampleQuestion, 0); + expect(opts).toHaveLength(4); + expect(opts[0]).toEqual({ + optionId: 'q0_opt_0', + name: 'Vanilla', + kind: 'allow_once', + }); + expect(opts[1]).toEqual({ + optionId: 'q0_opt_1', + name: 'Chocolate', + kind: 'allow_once', + }); + expect(opts[2]).toEqual({ + optionId: 'q0_opt_2', + name: 'Mint chip', + kind: 'allow_once', + }); + expect(opts[3]).toEqual({ + optionId: 'q0_skip', + name: 'Skip', + kind: 'reject_once', + }); + }); + + it('does not conflict across different questionIndex values', () => { + const q0 = questionItemToPermissionOptions(sampleQuestion, 0); + const q1 = questionItemToPermissionOptions(sampleQuestion, 1); + const ids0 = q0.map((o: PermissionOption) => o.optionId); + const ids1 = q1.map((o: PermissionOption) => o.optionId); + const overlap = ids0.filter((id) => ids1.includes(id)); + expect(overlap).toEqual([]); + expect(ids1).toEqual(['q1_opt_0', 'q1_opt_1', 'q1_opt_2', 'q1_skip']); + }); + + it('emits only the Skip option for a question with no options', () => { + const empty: QuestionItem = { question: 'Empty?', options: [] }; + const opts = questionItemToPermissionOptions(empty, 0); + expect(opts).toHaveLength(1); + expect(opts[0]).toEqual({ + optionId: 'q0_skip', + name: 'Skip', + kind: 'reject_once', + }); + }); +}); + +describe('outcomeToQuestionAnswer', () => { + function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; + } + + it('maps a selected q0_opt_ to { question: options[i].label }', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_2'))).toEqual({ + 'Pick a flavour': 'Mint chip', + }); + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_0'))).toEqual({ + 'Pick a flavour': 'Vanilla', + }); + }); + + it('maps q0_skip to null', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_skip'))).toBeNull(); + }); + + it('maps cancelled to null', () => { + expect( + outcomeToQuestionAnswer(sampleQuestion, { outcome: { outcome: 'cancelled' } }), + ).toBeNull(); + }); + + it('maps an unknown optionId to null', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('wat'))).toBeNull(); + expect( + outcomeToQuestionAnswer(sampleQuestion, selected('approve_once')), + ).toBeNull(); + }); + + it('defensively maps an out-of-bounds index to null', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_99'))).toBeNull(); + }); +}); diff --git a/packages/acp-adapter/test/server.test.ts b/packages/acp-adapter/test/server.test.ts new file mode 100644 index 000000000..fa1ac5bed --- /dev/null +++ b/packages/acp-adapter/test/server.test.ts @@ -0,0 +1,204 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type InitializeRequest, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { TERMINAL_AUTH_METHOD } from '../src'; + +/** Minimal Client that throws on every callback so tests fail loudly. */ +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in Phase 2'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in Phase 2'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in Phase 2'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in Phase 2'); + } +} + +/** + * Build a bidirectional in-memory ndJSON pair: + * - agentSide reads `clientToAgent` and writes to `agentToClient` + * - clientSide reads `agentToClient` and writes to `clientToAgent` + */ +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +describe('AcpServer + AgentSideConnection', () => { + it('responds to initialize with negotiated v1 capabilities', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + // Agent side + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + // Client side + const client = new ClientSideConnection((_agent) => new StubClient(), clientStream); + + const request: InitializeRequest = { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }; + + const response = await client.initialize(request); + + expect(response.protocolVersion).toBe(1); + expect(response.authMethods).toEqual([TERMINAL_AUTH_METHOD]); + expect(response.agentCapabilities?.loadSession).toBe(true); + expect(response.agentCapabilities?.promptCapabilities?.image).toBe(true); + expect(response.agentCapabilities?.promptCapabilities?.audio).toBe(false); + expect(response.agentCapabilities?.promptCapabilities?.embeddedContext).toBe(false); + expect(response.agentCapabilities?.mcpCapabilities?.http).toBe(true); + expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(false); + expect(response.agentCapabilities?.sessionCapabilities?.list).toEqual({}); + expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({}); + }); + + it('initialize advertises terminal-auth with id, type, args, name', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }); + + expect(response.authMethods).toHaveLength(1); + const method = response.authMethods?.[0]; + expect(method).toMatchObject({ + id: 'login', + type: 'terminal', + name: expect.any(String), + args: ['--login'], + }); + }); + + it('honors version negotiation: client v99 still negotiates to v1', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 99 }); + expect(response.protocolVersion).toBe(1); + }); + + it('initialize returns the supplied agentInfo', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const agentInfo = { name: 'Kimi Code CLI', version: '9.9.9-test' }; + new AgentSideConnection( + (c) => new AcpServer(harness, c, { agentInfo }), + agentStream, + ); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 1 }); + expect(response.agentInfo).toEqual(agentInfo); + }); + + it('initialize omits agentInfo when not supplied', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 1 }); + expect(response.agentInfo).toBeUndefined(); + }); + + it('initialize forwards terminalAuthEnv into authMethods[0].env', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + const terminalAuthEnv = { KIMI_CODE_HOME: '/tmp/kimi-debug' }; + new AgentSideConnection( + (c) => new AcpServer(harness, c, { terminalAuthEnv }), + agentStream, + ); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 1 }); + expect(response.authMethods).toHaveLength(1); + const method = response.authMethods?.[0] as { env?: Record }; + expect(method.env).toEqual({ KIMI_CODE_HOME: '/tmp/kimi-debug' }); + }); + + it('initialize emits legacy _meta["terminal-auth"] when terminalAuthLegacyCommand is set', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection( + (c) => + new AcpServer(harness, c, { + terminalAuthLegacyCommand: '/abs/path/to/kimi', + terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/kimi-debug' }, + }), + agentStream, + ); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 1 }); + const method = response.authMethods?.[0] as { + args?: string[]; + env?: Record; + _meta?: { 'terminal-auth'?: Record }; + }; + // First-class path still uses '--login' for the appended-args form. + expect(method.args).toEqual(['--login']); + // Legacy _meta fallback uses absolute command + 'login' subcommand. + expect(method._meta?.['terminal-auth']).toEqual({ + type: 'terminal', + label: 'Login with Kimi account', + command: '/abs/path/to/kimi', + args: ['login'], + env: { KIMI_CODE_HOME: '/tmp/kimi-debug' }, + }); + }); + + it('initialize omits _meta["terminal-auth"] when terminalAuthLegacyCommand is unset', async () => { + const harness = {} as KimiHarness; + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.initialize({ protocolVersion: 1 }); + const method = response.authMethods?.[0] as { + _meta?: { 'terminal-auth'?: unknown } | null; + }; + expect(method._meta?.['terminal-auth']).toBeUndefined(); + }); +}); diff --git a/packages/acp-adapter/test/session-config-option-funnel.test.ts b/packages/acp-adapter/test/session-config-option-funnel.test.ts new file mode 100644 index 000000000..553c98c16 --- /dev/null +++ b/packages/acp-adapter/test/session-config-option-funnel.test.ts @@ -0,0 +1,256 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + Event, + KimiHarness, + PermissionMode, + Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +/** + * Phase 14.3 funnel — three input paths converge on identical + * `config_option_update` wire shape: + * 1. `unstable_setSessionModel({ sessionId, modelId })` + * 2. `setSessionMode({ sessionId, modeId })` + * 3. `setSessionConfigOption({ sessionId, configId, value })` + * + * Each must emit exactly one `config_option_update` notification + * carrying the same envelope (discriminator, configOptions array + * shape, per-option fields). `currentValue` differs by input path + * but the surrounding structure is identical. + */ + +class CapturingClient implements Client { + readonly notifications: SessionNotification[] = []; + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CapturingClient.requestPermission should not be called'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.notifications.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CapturingClient.writeTextFile should not be called'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CapturingClient.readTextFile should not be called'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeFakeSession(sessionId: string): Session { + return { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: (_handler: ApprovalHandler | undefined) => undefined, + setPlanMode: async () => undefined, + setPermission: async (_mode: PermissionMode) => undefined, + setModel: async () => undefined, + setThinking: async () => undefined, + } as unknown as Session; +} + +function makeHarness(session: Session): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }, + { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; +} + +async function openSession( + harness: KimiHarness, +): Promise<{ client: ClientSideConnection; capturing: CapturingClient; sessionId: string }> { + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const capturing = new CapturingClient(); + const client = new ClientSideConnection((_a) => capturing, clientStream); + const response = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + return { client, capturing, sessionId: response.sessionId }; +} + +/** + * Extract just the `update` field from the single + * `config_option_update` notification emitted on `sessionId`. + * Throws if the count is anything other than 1 so the test fails + * loudly on funnel regression. + */ +function extractSingleConfigOptionUpdate( + capturing: CapturingClient, + sessionId: string, +): SessionNotification['update'] { + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + return updates[0]!.update; +} + +describe('config_option_update wire-shape funnel', () => { + it('unstable_setSessionModel emits one config_option_update with `model` currentValue updated', async () => { + const session = makeFakeSession('sess-funnel-1'); + const harness = makeHarness(session); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await client.unstable_setSessionModel({ sessionId, modelId: 'kimi-v2' }); + + const update = extractSingleConfigOptionUpdate(capturing, sessionId); + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + expect(update.configOptions).toHaveLength(2); + const modelOpt = update.configOptions.find((o) => o.id === 'model'); + if (modelOpt && modelOpt.type === 'select') { + expect(modelOpt.currentValue).toBe('kimi-v2'); + } + const modeOpt = update.configOptions.find((o) => o.id === 'mode'); + if (modeOpt && modeOpt.type === 'select') { + // Mode unchanged on a model-only switch — stays at the session's default. + expect(modeOpt.currentValue).toBe('default'); + } + }); + + it('setSessionMode emits one config_option_update with `mode` currentValue updated', async () => { + const session = makeFakeSession('sess-funnel-2'); + const harness = makeHarness(session); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await client.setSessionMode({ sessionId, modeId: 'plan' }); + + const update = extractSingleConfigOptionUpdate(capturing, sessionId); + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const modeOpt = update.configOptions.find((o) => o.id === 'mode'); + if (modeOpt && modeOpt.type === 'select') { + expect(modeOpt.currentValue).toBe('plan'); + } + }); + + it('setSessionConfigOption(mode=yolo) emits one config_option_update with `mode` currentValue updated', async () => { + const session = makeFakeSession('sess-funnel-3'); + const harness = makeHarness(session); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await client.setSessionConfigOption({ sessionId, configId: 'mode', value: 'yolo' }); + + const update = extractSingleConfigOptionUpdate(capturing, sessionId); + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const modeOpt = update.configOptions.find((o) => o.id === 'mode'); + if (modeOpt && modeOpt.type === 'select') { + expect(modeOpt.currentValue).toBe('yolo'); + } + }); + + it('setSessionConfigOption(thinking="on") emits one config_option_update with thinking toggle on', async () => { + // Catalog needs at least one thinkingSupported entry so the toggle + // is visible in the snapshot; default model resolves to kimi-coder + // (the harness's configured default). + const session = makeFakeSession('sess-funnel-thinking'); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([{ id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'on', + }); + + const update = extractSingleConfigOptionUpdate(capturing, sessionId); + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const toggle = update.configOptions.find((o) => o.id === 'thinking'); + if (!toggle || toggle.type !== 'select') throw new Error('expected select toggle'); + expect(toggle.currentValue).toBe('on'); + expect(update.configOptions.map((o) => o.id)).toEqual(['model', 'thinking', 'mode']); + }); + + it('all three input paths emit the SAME wire envelope (discriminator + option ids + per-option shape)', async () => { + // Reusable extractor — collects the wire envelope skeleton from + // each path so we can deep-equal-check structural identity. + async function envelopeFromPath( + driver: ( + client: ClientSideConnection, + sessionId: string, + ) => Promise, + ): Promise<{ + sessionUpdate: string; + configOptionIds: string[]; + configOptionCategories: Array; + configOptionTypes: string[]; + }> { + const session = makeFakeSession(`sess-envelope-${Math.random().toString(36).slice(2)}`); + const harness = makeHarness(session); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + await driver(client, sessionId); + const update = extractSingleConfigOptionUpdate(capturing, sessionId); + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + return { + sessionUpdate: update.sessionUpdate, + configOptionIds: update.configOptions.map((o) => o.id), + configOptionCategories: update.configOptions.map((o) => o.category ?? null), + configOptionTypes: update.configOptions.map((o) => o.type), + }; + } + + const viaModel = await envelopeFromPath((c, sid) => + c.unstable_setSessionModel({ sessionId: sid, modelId: 'kimi-v2' }), + ); + const viaMode = await envelopeFromPath((c, sid) => + c.setSessionMode({ sessionId: sid, modeId: 'plan' }), + ); + const viaConfigOption = await envelopeFromPath((c, sid) => + c.setSessionConfigOption({ sessionId: sid, configId: 'mode', value: 'yolo' }), + ); + + expect(viaModel).toEqual(viaMode); + expect(viaMode).toEqual(viaConfigOption); + expect(viaModel.sessionUpdate).toBe('config_option_update'); + expect(viaModel.configOptionIds).toEqual(['model', 'mode']); + expect(viaModel.configOptionTypes).toEqual(['select', 'select']); + expect(viaModel.configOptionCategories).toEqual(['model', 'mode']); + }); +}); diff --git a/packages/acp-adapter/test/session-control.test.ts b/packages/acp-adapter/test/session-control.test.ts new file mode 100644 index 000000000..16458faeb --- /dev/null +++ b/packages/acp-adapter/test/session-control.test.ts @@ -0,0 +1,345 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + Event, + KimiHarness, + PermissionMode, + Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +/** + * Captures every `session/update` notification the server pushes so + * `setMode` tests can assert the `config_option_update` payload (Phase + * 14.3). The other reverse-RPC methods continue to throw because no + * test under the `session/set_mode` or `session/unstable_setSessionModel` + * describe blocks exercises them; surfacing an explicit error keeps + * unintended paths loud rather than silently capturing them. + */ +class CapturingClient implements Client { + readonly notifications: SessionNotification[] = []; + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CapturingClient.requestPermission should not be called in session-control test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.notifications.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CapturingClient.writeTextFile should not be called in session-control test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CapturingClient.readTextFile should not be called in session-control test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface FakeSessionOverrides { + /** + * If set, `setPlanMode` will throw this `Error` on every call instead + * of recording into `planModeCalls`. Used by the SDK-error-propagation + * test to verify `setPermission` and the notification are suppressed. + * Typed as `Error` (rather than `unknown`) so the lint rule + * `only-throw-error` is satisfied without an inline disable. + */ + setPlanModeError?: Error; +} + +interface FakeSessionHandle { + session: Session; + planModeCalls: boolean[]; + setPermissionCalls: PermissionMode[]; + setModelCalls: string[]; + setThinkingCalls: string[]; +} + +function makeFakeSession( + sessionId: string, + overrides: FakeSessionOverrides = {}, +): FakeSessionHandle { + const planModeCalls: boolean[] = []; + const setPermissionCalls: PermissionMode[] = []; + const setModelCalls: string[] = []; + const setThinkingCalls: string[] = []; + const session = { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: (_handler: ApprovalHandler | undefined) => undefined, + setPlanMode: async (enabled: boolean) => { + if (overrides.setPlanModeError !== undefined) { + throw overrides.setPlanModeError; + } + planModeCalls.push(enabled); + }, + setPermission: async (mode: PermissionMode) => { + setPermissionCalls.push(mode); + }, + setModel: async (model: string) => { + setModelCalls.push(model); + }, + setThinking: async (level: string) => { + setThinkingCalls.push(level); + }, + } as unknown as Session; + return { session, planModeCalls, setPermissionCalls, setModelCalls, setThinkingCalls }; +} + +function makeHarness(handle: FakeSessionHandle): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + createSession: async (_options: unknown) => handle.session, + // Phase 14: server.newSession reads these for configOptions assembly. + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([{ id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: false }]), + }), + } as unknown as KimiHarness; +} + +async function openSession( + harness: KimiHarness, +): Promise<{ client: ClientSideConnection; capturing: CapturingClient; sessionId: string }> { + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const capturing = new CapturingClient(); + const client = new ClientSideConnection((_a) => capturing, clientStream); + const response = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + return { client, capturing, sessionId: response.sessionId }; +} + +describe('AcpServer session/set_mode', () => { + // Parameterized table over the four canonical modes (PLAN D9). Each + // arm verifies both SDK toggles fire in the documented order + // (setPlanMode → setPermission) AND that the server emits exactly one + // `config_option_update` notification (Phase 14.3) carrying a snapshot + // whose mode picker `currentValue` matches the requested modeId. + const MODE_CASES: ReadonlyArray<{ + modeId: 'default' | 'plan' | 'auto' | 'yolo'; + expectedPlan: boolean; + expectedPermission: PermissionMode; + }> = [ + { modeId: 'default', expectedPlan: false, expectedPermission: 'manual' }, + { modeId: 'plan', expectedPlan: true, expectedPermission: 'manual' }, + { modeId: 'auto', expectedPlan: false, expectedPermission: 'auto' }, + { modeId: 'yolo', expectedPlan: false, expectedPermission: 'yolo' }, + ]; + + for (const { modeId, expectedPlan, expectedPermission } of MODE_CASES) { + it(`forwards "${modeId}" → setPlanMode(${expectedPlan}) + setPermission(${expectedPermission}) + emits config_option_update`, async () => { + const handle = makeFakeSession(`sess-${modeId}`); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + + await client.setSessionMode({ sessionId, modeId }); + + expect(handle.planModeCalls).toEqual([expectedPlan]); + expect(handle.setPermissionCalls).toEqual([expectedPermission]); + + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') { + throw new Error('unreachable: filtered above'); + } + // Phase 14.3: payload is the full SessionConfigOption snapshot; + // the mode picker's currentValue reflects the just-applied mode. + const modeOpt = update.configOptions.find((o) => o.id === 'mode'); + expect(modeOpt).toBeDefined(); + if (modeOpt && modeOpt.type === 'select') { + expect(modeOpt.currentValue).toBe(modeId); + } + // Cross-check the model picker is still in the snapshot so a + // client subscribed to one channel can repaint both dropdowns. + const modelOpt = update.configOptions.find((o) => o.id === 'model'); + expect(modelOpt).toBeDefined(); + }); + } + + it('rejects unknown modeId with invalid_params before touching SDK or emitting notifications', async () => { + const handle = makeFakeSession('sess-bad-mode'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + + await expect( + client.setSessionMode({ sessionId, modeId: 'turbo' }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(handle.planModeCalls).toEqual([]); + expect(handle.setPermissionCalls).toEqual([]); + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toEqual([]); + }); + + it('rejects unknown sessionId with invalid_params and does not call setPlanMode', async () => { + const handle = makeFakeSession('sess-known'); + const harness = makeHarness(handle); + const { client } = await openSession(harness); + + await expect( + client.setSessionMode({ sessionId: 'sess-unknown', modeId: 'plan' }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(handle.planModeCalls).toEqual([]); + expect(handle.setPermissionCalls).toEqual([]); + }); + + it('propagates SDK errors from setPlanMode, skipping setPermission and the notification', async () => { + const handle = makeFakeSession('sess-plan-error', { + setPlanModeError: new Error('boom: setPlanMode failed'), + }); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + + // The thrown SDK Error is opaque to the JSON-RPC layer; the only + // contract we assert is that the request rejects (not an + // invalid_params -32602, which would mean the adapter swallowed and + // re-mapped the SDK error — see §4 "What you must NOT do"). + await expect( + client.setSessionMode({ sessionId, modeId: 'auto' }), + ).rejects.toBeDefined(); + + expect(handle.planModeCalls).toEqual([]); // setPlanMode threw before push + expect(handle.setPermissionCalls).toEqual([]); // never reached + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toEqual([]); // notification suppressed on SDK error + }); +}); + +describe('AcpServer session/unstable_setSessionModel', () => { + it('forwards modelId to Session.setModel exactly once + emits one config_option_update', async () => { + const handle = makeFakeSession('sess-model'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + + await client.unstable_setSessionModel({ sessionId, modelId: 'kimi-v2-something' }); + + expect(handle.setModelCalls).toEqual(['kimi-v2-something']); + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') { + throw new Error('unreachable: filtered above'); + } + const modelOpt = update.configOptions.find((o) => o.id === 'model'); + expect(modelOpt).toBeDefined(); + if (modelOpt && modelOpt.type === 'select') { + expect(modelOpt.currentValue).toBe('kimi-v2-something'); + } + }); + + it('splits a `,thinking` suffix into a bare setModel + setThinking("high") call; snapshot model carries the base id', async () => { + const handle = makeFakeSession('sess-model-thinking'); + // This test needs a thinking-supported catalog row so the snapshot + // includes the toggle (otherwise it would be omitted). + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-v2-something', + models: makeModelsMap([ + { id: 'kimi-v2-something', name: 'Kimi v2 something', thinkingSupported: true }, + ]), + }), + } as unknown as KimiHarness; + const { client, capturing, sessionId } = await openSession(harness); + + await client.unstable_setSessionModel({ + sessionId, + modelId: 'kimi-v2-something,thinking', + }); + + // SDK receives the bare model key for setModel and `'high'` for + // setThinking — Phase 15 routes thinking through the dedicated SDK + // channel instead of dropping the suffix on the floor. + expect(handle.setModelCalls).toEqual(['kimi-v2-something']); + expect(handle.setThinkingCalls).toEqual(['high']); + + // The model picker's currentValue is the bare id — thinking lives + // on its own boolean toggle, and the snapshot reflects that. + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const modelOpt = update.configOptions.find((o) => o.id === 'model'); + if (modelOpt && modelOpt.type === 'select') { + expect(modelOpt.currentValue).toBe('kimi-v2-something'); + } + const toggle = update.configOptions.find((o) => o.id === 'thinking'); + if (!toggle || toggle.type !== 'select') throw new Error('expected thinking toggle'); + expect(toggle.currentValue).toBe('on'); + }); + + it('rejects unknown sessionId with invalid_params and does not call setModel or emit notifications', async () => { + const handle = makeFakeSession('sess-known'); + const harness = makeHarness(handle); + const { client, capturing } = await openSession(harness); + + await expect( + client.unstable_setSessionModel({ sessionId: 'sess-unknown', modelId: 'kimi-v2' }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(handle.setModelCalls).toEqual([]); + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toEqual([]); + }); + + // Parameterised across 4 model ids — verifies the model-switch path + // emits one config_option_update per call, mirroring the mode-switch + // table above so a future regression in the funnel (Phase 14.3) hits + // both pickers. + for (const modelId of ['alpha', 'beta', 'gamma,thinking', 'delta']) { + it(`emits exactly one config_option_update for setSessionModel(${modelId})`, async () => { + const handle = makeFakeSession(`sess-${modelId.replace(',', '-')}`); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + + await client.unstable_setSessionModel({ sessionId, modelId }); + + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + }); + } +}); diff --git a/packages/acp-adapter/test/session-list.test.ts b/packages/acp-adapter/test/session-list.test.ts new file mode 100644 index 000000000..e85976a1e --- /dev/null +++ b/packages/acp-adapter/test/session-list.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness, SessionSummary } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in session-list test'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in session-list test'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in session-list test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in session-list test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface CapturedListOptions { + options: { workDir?: string; sessionId?: string }; +} + +function makeHarness( + summaries: SessionSummary[], + captured: CapturedListOptions[] = [], +): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + listSessions: async (options: { workDir?: string; sessionId?: string } = {}) => { + captured.push({ options }); + if (options.workDir !== undefined) { + return summaries.filter((s) => s.workDir === options.workDir); + } + return summaries; + }, + } as unknown as KimiHarness; +} + +function openConn(harness: KimiHarness): ClientSideConnection { + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + return new ClientSideConnection((_a) => new StubClient(), clientStream); +} + +describe('AcpServer session/list', () => { + it('returns an empty list (with nextCursor: null) when the harness reports no sessions', async () => { + const harness = makeHarness([]); + const client = openConn(harness); + + const response = await client.listSessions({}); + + expect(response.sessions).toEqual([]); + expect(response.nextCursor).toBeNull(); + }); + + it('maps SessionSummary[] to SessionInfo[] with sessionId / cwd / title / updatedAt', async () => { + const updated1Ms = Date.UTC(2026, 0, 1, 12, 0, 0); + const updated2Ms = Date.UTC(2026, 4, 15, 9, 30, 0); + const summaries: SessionSummary[] = [ + { + id: 'sess-a', + title: 'My first chat', + workDir: '/repo/a', + sessionDir: '/home/.kimi/sessions/sess-a', + createdAt: updated1Ms - 1_000, + updatedAt: updated1Ms, + }, + { + id: 'sess-b', + title: 'Refactor', + workDir: '/repo/b', + sessionDir: '/home/.kimi/sessions/sess-b', + createdAt: updated2Ms - 1_000, + updatedAt: updated2Ms, + }, + ]; + const harness = makeHarness(summaries); + const client = openConn(harness); + + const response = await client.listSessions({}); + + expect(response.sessions).toHaveLength(2); + expect(response.sessions[0]).toMatchObject({ + sessionId: 'sess-a', + cwd: '/repo/a', + title: 'My first chat', + updatedAt: new Date(updated1Ms).toISOString(), + }); + expect(response.sessions[1]).toMatchObject({ + sessionId: 'sess-b', + cwd: '/repo/b', + title: 'Refactor', + updatedAt: new Date(updated2Ms).toISOString(), + }); + expect(response.nextCursor).toBeNull(); + }); + + it('passes the cwd filter through to harness.listSessions as workDir', async () => { + const summaries: SessionSummary[] = [ + { + id: 'sess-here', + workDir: '/repo/here', + sessionDir: '/home/.kimi/sessions/sess-here', + createdAt: 0, + updatedAt: 0, + }, + { + id: 'sess-elsewhere', + workDir: '/repo/elsewhere', + sessionDir: '/home/.kimi/sessions/sess-elsewhere', + createdAt: 0, + updatedAt: 0, + }, + ]; + const captured: CapturedListOptions[] = []; + const harness = makeHarness(summaries, captured); + const client = openConn(harness); + + const response = await client.listSessions({ cwd: '/repo/here' }); + + expect(captured).toEqual([{ options: { workDir: '/repo/here' } }]); + expect(response.sessions).toHaveLength(1); + expect(response.sessions[0]?.sessionId).toBe('sess-here'); + expect(response.sessions[0]?.cwd).toBe('/repo/here'); + }); + + it('falls back to title: null when the SDK summary has no title', async () => { + const summaries: SessionSummary[] = [ + { + id: 'sess-untitled', + workDir: '/repo/u', + sessionDir: '/home/.kimi/sessions/sess-untitled', + createdAt: 0, + updatedAt: 1, + }, + { + id: 'sess-empty-title', + title: '', + workDir: '/repo/e', + sessionDir: '/home/.kimi/sessions/sess-empty-title', + createdAt: 0, + updatedAt: 2, + }, + ]; + const harness = makeHarness(summaries); + const client = openConn(harness); + + const response = await client.listSessions({}); + + expect(response.sessions[0]?.title).toBeNull(); + expect(response.sessions[1]?.title).toBeNull(); + }); +}); diff --git a/packages/acp-adapter/test/session-load.test.ts b/packages/acp-adapter/test/session-load.test.ts new file mode 100644 index 000000000..fb3e62807 --- /dev/null +++ b/packages/acp-adapter/test/session-load.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { KimiError, ErrorCodes, type Event, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, UNAUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +class CapturingClient implements Client { + readonly updates: SessionNotification[] = []; + + /** + * Updates produced AFTER `session/load` returns. Phase 9.3 makes + * `loadSession` emit exactly one `available_commands_update` after + * the history-replay batch; existing replay tests assert only on + * history-derived updates, so we filter that variant out. + */ + get historyUpdates(): readonly SessionNotification[] { + return this.updates.filter( + (n) => + (n.update as { sessionUpdate?: string }).sessionUpdate !== + 'available_commands_update', + ); + } + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CapturingClient.requestPermission should not be called in session-load test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CapturingClient.writeTextFile should not be called in session-load test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CapturingClient.readTextFile should not be called in session-load test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeSessionWithHistory( + sessionId: string, + history: ReadonlyArray, +): Session { + return { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: () => undefined, + getResumeState: () => ({ + agents: { + main: { + context: { history, tokenCount: 0 }, + }, + }, + }), + } as unknown as Session; +} + +function makeHarness( + opts: { + hasUsableToken?: boolean; + session?: Session; + resumeError?: Error; + }, +): KimiHarness { + const authed = opts.hasUsableToken ?? true; + return { + auth: { + status: async () => (authed ? AUTHED_STATUS : UNAUTHED_STATUS), + }, + resumeSession: async (_input: { id: string }) => { + if (opts.resumeError) throw opts.resumeError; + if (!opts.session) throw new Error('test harness has no session configured'); + return opts.session; + }, + // Phase 14: server.loadSession reads these to assemble configOptions + // when the resumed session lacks a `modelAlias` (the fixture sessions + // in this file do not set one). `models` map carries the same + // (id, displayName, thinkingSupported) intent the old + // `listAvailableModels` stub did — `kimi-coder` opts in to thinking + // via `capabilities: ['thinking']`, `kimi-plain` stays off. + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }, + { id: 'kimi-plain', name: 'Kimi Plain', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; +} + +describe('AcpServer session/load auth gate', () => { + it('rejects loadSession with auth_required (-32000) when no token', async () => { + const harness = makeHarness({ hasUsableToken: false }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await expect( + clientConn.loadSession({ sessionId: 'sess-x', cwd: '/tmp/x', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32000 }); + }); +}); + +describe('AcpServer session/load replay', () => { + it('replays a single assistant text-only turn as agent_message_chunk updates', async () => { + const sessionId = 'sess-text-only'; + const history = [ + { + role: 'user', + content: [{ type: 'text', text: 'hello' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'hi there' }], + toolCalls: [], + }, + ]; + const session = makeSessionWithHistory(sessionId, history); + const harness = makeHarness({ hasUsableToken: true, session }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection((_a) => client, clientStream); + + const response = await clientConn.loadSession({ + sessionId, + cwd: '/tmp/x', + mcpServers: [], + }); + + // Response shape: per ACP schema every field on LoadSessionResponse is + // optional, so an empty object is a valid success body. + expect(response).toBeDefined(); + + // Two history entries → expect exactly two session/update notifications. + expect(client.historyUpdates.length).toBe(2); + expect(client.historyUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }); + expect(client.historyUpdates[1]?.update).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi there' }, + }); + }); + + it('replays a turn with a tool call + tool result using ${turnId}:${toolCallId} ids', async () => { + const sessionId = 'sess-with-tools'; + const history = [ + { + role: 'user', + content: [{ type: 'text', text: 'ls' }], + toolCalls: [], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'running ls' }], + toolCalls: [ + { + type: 'function', + id: 'tc-abc', + name: 'Bash', + arguments: JSON.stringify({ command: 'ls' }), + }, + ], + }, + { + role: 'tool', + toolCallId: 'tc-abc', + content: [{ type: 'text', text: 'file1\nfile2' }], + toolCalls: [], + }, + ]; + const session = makeSessionWithHistory(sessionId, history); + const harness = makeHarness({ hasUsableToken: true, session }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection((_a) => client, clientStream); + + await clientConn.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + // user_message_chunk + agent_message_chunk + tool_call + tool_call_update = 4 updates. + expect(client.historyUpdates.length).toBe(4); + expect(client.historyUpdates[0]?.update).toMatchObject({ sessionUpdate: 'user_message_chunk' }); + expect(client.historyUpdates[1]?.update).toMatchObject({ sessionUpdate: 'agent_message_chunk' }); + // Synthetic turnId starts at 1 (first assistant message in history). + expect(client.historyUpdates[2]?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: '1:tc-abc', + title: 'Bash', + status: 'in_progress', + }); + expect(client.historyUpdates[3]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: '1:tc-abc', + status: 'completed', + }); + }); + + it('maps the SDK session.not_found error to ACP invalid_params (-32602)', async () => { + const harness = makeHarness({ + hasUsableToken: true, + resumeError: new KimiError(ErrorCodes.SESSION_NOT_FOUND, 'Session "ghost" was not found'), + }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await expect( + clientConn.loadSession({ sessionId: 'ghost', cwd: '/tmp/x', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('registers the AcpSession under its id so subsequent calls can locate it', async () => { + const sessionId = 'sess-registered'; + const session = makeSessionWithHistory(sessionId, []); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + let server: AcpServer | undefined; + new AgentSideConnection((c) => { + server = new AcpServer(harness, c); + return server; + }, agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await clientConn.loadSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + expect(server?.getSession(sessionId)?.id).toBe(sessionId); + }); + + it('advertises configOptions (PLAN D11 + Phase 15 thinking toggle) on loadSession too — model + thinking + mode under the unified surface', async () => { + const sessionId = 'sess-modes-load'; + const session = makeSessionWithHistory(sessionId, []); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + const response = await clientConn.loadSession({ + sessionId, + cwd: '/tmp/x', + mcpServers: [], + }); + + // Phase 14 (PLAN D11): the dedicated `modes:` field is gone; the + // four-mode taxonomy now lives under `configOptions[id='mode']`. + // Mode is still session-scoped and not persisted, so a resumed + // session re-starts in `default`. + expect(response.modes).toBeUndefined(); + + expect(response.configOptions).toBeDefined(); + // Default model resolves to `kimi-coder` (thinkingSupported) so the + // toggle is visible → 3 options. + expect(response.configOptions).toHaveLength(3); + const [modelOpt, thinkingOpt, modeOpt] = response.configOptions!; + expect(modelOpt!.id).toBe('model'); + expect(thinkingOpt!.id).toBe('thinking'); + expect(modeOpt!.id).toBe('mode'); + + if (thinkingOpt!.type !== 'select') { + throw new Error('thinking option must be a select'); + } + expect(thinkingOpt!.category).toBe('thought_level'); + expect(thinkingOpt!.currentValue).toBe('off'); + + if (modeOpt!.type !== 'select') { + throw new Error('mode option must be a select'); + } + expect(modeOpt!.currentValue).toBe('default'); + expect(modeOpt!.options).toHaveLength(4); + const modeIds = modeOpt!.options.map((o) => 'value' in o ? o.value : ''); + expect(modeIds).toEqual(['default', 'plan', 'auto', 'yolo']); + for (const entry of modeOpt!.options) { + if ('value' in entry) { + expect(typeof entry.name).toBe('string'); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.description).toBe('string'); + expect((entry.description ?? '').length).toBeGreaterThan(0); + } + } + + if (modelOpt!.type !== 'select') { + throw new Error('model option must be a select'); + } + // Resumed session has no main-agent `modelAlias` in its fixture + // resume state → server falls back to harness `defaultModel`. + expect(modelOpt!.currentValue).toBe('kimi-coder'); + // Phase 15: model dropdown holds N rows (no `,thinking` variants). + expect(modelOpt!.options).toHaveLength(2); + }); +}); diff --git a/packages/acp-adapter/test/session-new.test.ts b/packages/acp-adapter/test/session-new.test.ts new file mode 100644 index 000000000..dc358755f --- /dev/null +++ b/packages/acp-adapter/test/session-new.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type NewSessionRequest, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +class StubClient implements Client { + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('StubClient.requestPermission should not be called in session-new test'); + } + async sessionUpdate(_n: SessionNotification): Promise { + throw new Error('StubClient.sessionUpdate should not be called in session-new test'); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('StubClient.writeTextFile should not be called in session-new test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('StubClient.readTextFile should not be called in session-new test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface CapturedCall { + options: { workDir: string }; +} + +function makeHarness(sessionId: string, captured: CapturedCall[]): { + harness: KimiHarness; + fakeSession: Session; +} { + const fakeSession = { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async (options: { workDir: string }) => { + captured.push({ options }); + return fakeSession; + }, + // Phase 14: server.newSession reads these to assemble configOptions. + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }, + { id: 'kimi-plain', name: 'Kimi Plain', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; + return { harness, fakeSession }; +} + +describe('AcpServer session/new', () => { + it('calls harness.createSession with workDir from ACP cwd and returns the new sessionId', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-42', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + let server: AcpServer | undefined; + new AgentSideConnection((c) => { + server = new AcpServer(harness, c); + return server; + }, agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const request: NewSessionRequest = { + cwd: '/tmp/work', + mcpServers: [], + }; + + const response = await client.newSession(request); + + expect(response.sessionId).toBe('sess-42'); + expect(captured).toHaveLength(1); + expect(captured[0]?.options).toEqual({ workDir: '/tmp/work', mcpServers: {} }); + + // The wrapper is stashed in the map under the same id we returned to + // the client (so Phase 3.3/3.4 can look it up by sessionId). + expect(server?.getSession('sess-42')?.id).toBe('sess-42'); + }); + + it('returns a distinct sessionId per call (one createSession per request)', async () => { + const captured: CapturedCall[] = []; + let counter = 0; + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async (options: { workDir: string }) => { + counter += 1; + const id = `sess-${counter}`; + captured.push({ options }); + return { + id, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + } as unknown as Session; + }, + // Phase 14: server.newSession reads these to assemble configOptions. + getConfig: async () => ({ providers: {}, models: {} }), + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const first = await client.newSession({ cwd: '/tmp/a', mcpServers: [] }); + const second = await client.newSession({ cwd: '/tmp/b', mcpServers: [] }); + + expect(first.sessionId).toBe('sess-1'); + expect(second.sessionId).toBe('sess-2'); + expect(captured).toHaveLength(2); + expect(captured[0]?.options).toEqual({ workDir: '/tmp/a', mcpServers: {} }); + expect(captured[1]?.options).toEqual({ workDir: '/tmp/b', mcpServers: {} }); + }); + + it('advertises configOptions (PLAN D11 + Phase 15 thinking toggle) — model + thinking + mode under the unified SessionConfigOption surface', async () => { + const captured: CapturedCall[] = []; + const { harness } = makeHarness('sess-modes', captured); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection((_a) => new StubClient(), clientStream); + + const response = await client.newSession({ cwd: '/tmp/work', mcpServers: [] }); + + // Phase 14 (PLAN D11) replaces Phase 12's dedicated `modes:` field + // with the spec's generic `configOptions:` surface — model + mode + // are now sibling SessionConfigOption entries on the same dropdown + // channel. Positive proof the legacy field is gone: + expect(response.modes).toBeUndefined(); + + expect(response.configOptions).toBeDefined(); + // Default model is `kimi-coder` (thinkingSupported), so the toggle is + // visible between model and mode → 3 options total. + expect(response.configOptions).toHaveLength(3); + const [modelOpt, thinkingOpt, modeOpt] = response.configOptions!; + expect(modelOpt!.id).toBe('model'); + expect(thinkingOpt!.id).toBe('thinking'); + expect(modeOpt!.id).toBe('mode'); + + // Thinking picker — Phase 16 reshaped this to a 2-entry select + // (`off` / `on`) so Zed renders it; the underlying axis is still + // binary. `thought_level` category, currentValue='off' (no + // defaultThinking set on the harness fixture). + if (thinkingOpt!.type !== 'select') { + throw new Error('thinking option must be a select'); + } + expect(thinkingOpt!.category).toBe('thought_level'); + expect(thinkingOpt!.currentValue).toBe('off'); + + // Mode picker — locked taxonomy (PLAN D9). Same order assertions + // the Phase 12 test made, just rephrased against the new shape. + if (modeOpt!.type !== 'select') { + throw new Error('mode option must be a select'); + } + expect(modeOpt!.currentValue).toBe('default'); + expect(modeOpt!.options).toHaveLength(4); + const modeIds = modeOpt!.options.map((o) => 'value' in o ? o.value : ''); + expect(modeIds).toEqual(['default', 'plan', 'auto', 'yolo']); + for (const entry of modeOpt!.options) { + if ('value' in entry) { + expect(typeof entry.name).toBe('string'); + expect(entry.name.length).toBeGreaterThan(0); + expect(typeof entry.description).toBe('string'); + expect((entry.description ?? '').length).toBeGreaterThan(0); + } + } + + // Model picker — Phase 15 removed `,thinking` variant rows: each + // catalog entry surfaces exactly one option. Fixture has 2 entries. + if (modelOpt!.type !== 'select') { + throw new Error('model option must be a select'); + } + expect(modelOpt!.currentValue).toBe('kimi-coder'); + expect(modelOpt!.options).toHaveLength(2); + const modelValues = modelOpt!.options.map((o) => 'value' in o ? o.value : ''); + expect(modelValues).toEqual(['kimi-coder', 'kimi-plain']); + }); +}); diff --git a/packages/acp-adapter/test/session-prompt.test.ts b/packages/acp-adapter/test/session-prompt.test.ts new file mode 100644 index 000000000..0ceb5f10e --- /dev/null +++ b/packages/acp-adapter/test/session-prompt.test.ts @@ -0,0 +1,229 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + + /** + * Updates produced AFTER `session/new` returns. Phase 9.3 makes + * `newSession` emit exactly one `available_commands_update` on + * creation; tests in this file pre-date that emission and assert + * only on prompt-driven updates, so we filter that variant out. + */ + get promptUpdates(): readonly SessionNotification[] { + return this.updates.filter( + (n) => + (n.update as { sessionUpdate?: string }).sessionUpdate !== + 'available_commands_update', + ); + } + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CollectingClient.requestPermission should not be called in prompt test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CollectingClient.writeTextFile should not be called in prompt test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CollectingClient.readTextFile should not be called in prompt test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * Construct a fake Session whose `prompt()` synchronously emits a + * pre-recorded sequence of `Event`s through any subscribed listener. + */ +function makeScriptedSession( + sessionId: string, + script: readonly Event[], +): { + session: Session; + unsubscribeCount: () => number; +} { + const listeners = new Set<(event: Event) => void>(); + let unsubCount = 0; + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + // Emit asynchronously so the caller has time to set `settled` + // before the first event lands (matches real RPC ordering). + for (const ev of script) { + for (const fn of listeners) fn(ev); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + unsubCount += 1; + listeners.delete(fn); + }; + }, + } as unknown as Session; + return { session, unsubscribeCount: () => unsubCount }; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +describe('AcpServer session/prompt', () => { + it('streams two AssistantDelta events as agent_message_chunk updates and resolves with end_turn', async () => { + const sessionId = 'sess-A'; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, [ + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'hel' } as Event, + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'lo' } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const response = await client.prompt({ + sessionId, + prompt: [textBlock('hi')], + }); + + expect(response.stopReason).toBe('end_turn'); + + // Give the agent side a tick to flush queued sessionUpdate writes + // through the ndjson stream. + await new Promise((resolve) => setTimeout(resolve, 20)); + + expect(collecting.promptUpdates).toHaveLength(2); + for (const note of collecting.promptUpdates) { + expect(note.sessionId).toBe(sessionId); + } + const first = collecting.promptUpdates[0]?.update; + const second = collecting.promptUpdates[1]?.update; + expect(first).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hel' }, + }); + expect(second).toMatchObject({ + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'lo' }, + }); + + // Listener must be unsubscribed exactly once after turn.ended fires. + expect(unsubscribeCount()).toBe(1); + }); + + it('resolves with cancelled stopReason when turn.ended reason is cancelled', async () => { + const sessionId = 'sess-B'; + const { session, unsubscribeCount } = makeScriptedSession(sessionId, [ + { type: 'assistant.delta', sessionId, agentId: 'main', turnId: 1, delta: 'partial' } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'cancelled' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + const response = await client.prompt({ + sessionId, + prompt: [textBlock('do something long')], + }); + + expect(response.stopReason).toBe('cancelled'); + expect(unsubscribeCount()).toBe(1); + }); + + it('rejects prompt with invalid_params when sessionId is unknown', async () => { + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => { + throw new Error('createSession should not be called for unknown-id test'); + }, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await expect( + client.prompt({ sessionId: 'sess-does-not-exist', prompt: [textBlock('hi')] }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('rejects prompt (and unsubscribes) when underlying session.prompt rejects', async () => { + const sessionId = 'sess-C'; + const listeners = new Set<(event: Event) => void>(); + let unsubCount = 0; + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + throw new Error('boom from session.prompt'); + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + unsubCount += 1; + listeners.delete(fn); + }; + }, + } as unknown as Session; + + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new ClientSideConnection(() => new CollectingClient(), clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + + await expect( + client.prompt({ sessionId, prompt: [textBlock('hi')] }), + ).rejects.toBeDefined(); + expect(unsubCount).toBe(1); + }); +}); diff --git a/packages/acp-adapter/test/session-question-handler.test.ts b/packages/acp-adapter/test/session-question-handler.test.ts new file mode 100644 index 000000000..937ab9da3 --- /dev/null +++ b/packages/acp-adapter/test/session-question-handler.test.ts @@ -0,0 +1,273 @@ +/** + * Tests for {@link AcpSession.handleQuestion} — the Phase 13.1 bridge + * from the SDK's AskUserQuestion reverse-RPC to the ACP + * `session/request_permission` surface. + * + * Uses a captured-handler pattern (mirrors `approval.test.ts`): the stub + * `Session` records the `QuestionHandler` registered by the AcpSession + * constructor, and the test invokes it directly as the SDK would. + */ +import type { + AgentSideConnection, + RequestPermissionRequest, + RequestPermissionResponse, +} from '@agentclientprotocol/sdk'; +import { + log, + type QuestionAnswers, + type QuestionHandler, + type QuestionItem, + type QuestionRequest, + type QuestionResult, + type Session, +} from '@moonshot-ai/kimi-code-sdk'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { AcpSession, type TelemetryTrackFn } from '../src/session'; + +/** + * Build a stub {@link Session} that captures the question handler + * registered by {@link AcpSession}'s constructor and exposes it for + * the test to invoke as the SDK reverse-RPC layer would. + */ +function makeQuestionSession(sessionId: string): { + session: Session; + invokeHandler: (req: QuestionRequest) => Promise; +} { + let questionHandler: QuestionHandler | undefined; + const session = { + id: sessionId, + prompt: async (_input: unknown) => undefined, + cancel: async () => undefined, + onEvent: () => () => undefined, + setApprovalHandler: () => undefined, + setQuestionHandler: (handler: QuestionHandler | undefined) => { + questionHandler = handler; + }, + } as unknown as Session; + return { + session, + invokeHandler: async (req: QuestionRequest) => { + if (!questionHandler) { + throw new Error('question handler was not registered by AcpSession'); + } + const result = await questionHandler(req); + return result; + }, + }; +} + +/** + * Capturing connection — only `requestPermission` is exercised here; + * everything else throws to surface accidental usage. + */ +class CapturingConn { + readonly permissionRequests: RequestPermissionRequest[] = []; + reply: RequestPermissionResponse = { + outcome: { outcome: 'selected', optionId: 'q0_opt_0' }, + }; + shouldThrow = false; + + async requestPermission(p: RequestPermissionRequest): Promise { + this.permissionRequests.push(p); + if (this.shouldThrow) { + throw new Error('client unreachable'); + } + return this.reply; + } + async sessionUpdate(): Promise { + /* not exercised */ + } + async readTextFile(): Promise<{ content: string }> { + throw new Error('not exercised'); + } + async writeTextFile(): Promise> { + throw new Error('not exercised'); + } +} + +function makeConn(): { conn: AgentSideConnection; raw: CapturingConn } { + const raw = new CapturingConn(); + return { conn: raw as unknown as AgentSideConnection, raw }; +} + +const sampleQuestion: QuestionItem = { + question: '哪个口味?', + options: [{ label: '香草' }, { label: '巧克力' }, { label: '抹茶' }], +}; + +function makeReq(overrides: Partial = {}): QuestionRequest { + return { + toolCallId: 'tc-ask-1', + questions: [sampleQuestion], + ...overrides, + }; +} + +describe('AcpSession.handleQuestion', () => { + let warnSpy: ReturnType; + let trackCalls: Array<{ event: string; properties?: Record }>; + let track: TelemetryTrackFn; + + beforeEach(() => { + warnSpy = vi.spyOn(log, 'warn').mockImplementation(() => undefined); + trackCalls = []; + track = (event: string, properties?: Record) => { + trackCalls.push({ event, properties }); + }; + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('registers a question handler at construction time', () => { + const { conn } = makeConn(); + const { session } = makeQuestionSession('s-q-1'); + const setSpy = vi.fn(); + (session as unknown as { setQuestionHandler: typeof setSpy }).setQuestionHandler = setSpy; + new AcpSession(conn, session, undefined, track); + expect(setSpy).toHaveBeenCalledTimes(1); + expect(typeof setSpy.mock.calls[0]![0]).toBe('function'); + }); + + it('happy path: forwards a single question and resolves with the matched answer + question_answered', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-happy'); + raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler(makeReq()); + + expect(answer).toEqual({ '哪个口味?': '香草' } satisfies QuestionAnswers); + expect(raw.permissionRequests).toHaveLength(1); + const req = raw.permissionRequests[0]!; + expect(req.sessionId).toBe('s-q-happy'); + // Options: 3 allow_once + 1 reject_once skip + expect(req.options).toHaveLength(4); + expect(req.options.map((o) => o.optionId)).toEqual([ + 'q0_opt_0', + 'q0_opt_1', + 'q0_opt_2', + 'q0_skip', + ]); + expect(req.options.map((o) => o.kind)).toEqual([ + 'allow_once', + 'allow_once', + 'allow_once', + 'reject_once', + ]); + expect(req.toolCall.title).toBe('AskUserQuestion'); + // currentTurnId is undefined in this test path, so raw toolCallId is used. + expect(req.toolCall.toolCallId).toBe('tc-ask-1'); + expect(req.toolCall.content).toEqual([ + { type: 'content', content: { type: 'text', text: '哪个口味?' } }, + ]); + expect(trackCalls).toEqual([{ event: 'question_answered', properties: undefined }]); + }); + + it('skip: q0_skip resolves to null with question_dismissed telemetry', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-skip'); + raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_skip' } }; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler(makeReq()); + + expect(answer).toBeNull(); + expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); + }); + + it('cancelled: outcome cancelled resolves to null with question_dismissed', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-cancel'); + raw.reply = { outcome: { outcome: 'cancelled' } }; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler(makeReq()); + + expect(answer).toBeNull(); + expect(trackCalls).toEqual([{ event: 'question_dismissed', properties: undefined }]); + }); + + it('multi-question degradation: 3 questions → only first asked + question_degraded', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-multi'); + raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_1' } }; + new AcpSession(conn, handle.session, undefined, track); + + const extra1: QuestionItem = { question: 'Q2', options: [{ label: 'a' }] }; + const extra2: QuestionItem = { question: 'Q3', options: [{ label: 'b' }] }; + const answer = await handle.invokeHandler( + makeReq({ questions: [sampleQuestion, extra1, extra2] }), + ); + + expect(answer).toEqual({ '哪个口味?': '巧克力' }); + expect(raw.permissionRequests).toHaveLength(1); + // Telemetry: degraded(multi_question) first, then answered. + expect(trackCalls).toEqual([ + { event: 'question_degraded', properties: { reason: 'multi_question', dropped: 2 } }, + { event: 'question_answered', properties: undefined }, + ]); + // log.warn fired with the dropped count. + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('degrading to first question only'), + expect.objectContaining({ dropped: 2 }), + ); + }); + + it('multiSelect degradation: still asks the question + question_degraded', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-multisel'); + raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }; + new AcpSession(conn, handle.session, undefined, track); + + const multi: QuestionItem = { + question: 'Pick any', + options: [{ label: 'a' }, { label: 'b' }], + multiSelect: true, + }; + const answer = await handle.invokeHandler({ + toolCallId: 'tc-multi', + questions: [multi], + }); + + expect(answer).toEqual({ 'Pick any': 'a' }); + expect(raw.permissionRequests).toHaveLength(1); + expect(trackCalls).toEqual([ + { event: 'question_degraded', properties: { reason: 'multi_select' } }, + { event: 'question_answered', properties: undefined }, + ]); + }); + + it('requestPermission throw → log.warn + null', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-throw'); + raw.shouldThrow = true; + new AcpSession(conn, handle.session, undefined, track); + + const answer = await handle.invokeHandler(makeReq()); + + expect(answer).toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('requestPermission (question) failed'), + expect.objectContaining({ toolCallId: 'tc-ask-1' }), + ); + // No question_answered / question_dismissed emitted on throw — the + // RPC failure is its own observability path (log.warn above). + expect(trackCalls).toEqual([]); + }); + + it('no track sink: handler still runs without emitting telemetry', async () => { + const { conn, raw } = makeConn(); + const handle = makeQuestionSession('s-q-no-track'); + raw.reply = { outcome: { outcome: 'selected', optionId: 'q0_opt_0' } }; + // No track passed. + new AcpSession(conn, handle.session); + + const answer = await handle.invokeHandler(makeReq()); + + expect(answer).toEqual({ '哪个口味?': '香草' }); + expect(trackCalls).toEqual([]); + }); +}); diff --git a/packages/acp-adapter/test/session-resume.test.ts b/packages/acp-adapter/test/session-resume.test.ts new file mode 100644 index 000000000..3711308c7 --- /dev/null +++ b/packages/acp-adapter/test/session-resume.test.ts @@ -0,0 +1,264 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import { KimiError, ErrorCodes, type Event, type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, UNAUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +/** + * Tests for the ACP `session/resume` handler (gap-4.3). Mirrors the + * shape of `session-load.test.ts` because the two handlers share + * `setupSessionFromExisting`; the assertions below pin the + * `resumeSession`-specific contract: + * + * - auth gate parity with newSession / loadSession, + * - configOptions reflects the resumed model + thinking projection, + * - NO history replay (the ONE difference vs loadSession), + * - SDK `session.not_found` maps to ACP invalid_params, + * - AcpSession is registered so subsequent calls can locate it. + */ + +class CapturingClient implements Client { + readonly updates: SessionNotification[] = []; + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CapturingClient.requestPermission should not be called in session-resume test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CapturingClient.writeTextFile should not be called in session-resume test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CapturingClient.readTextFile should not be called in session-resume test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +/** + * Build a fake {@link Session} whose `getResumeState` reports the given + * main-agent config so the server's resume-state projection (modelAlias + * → currentModelId, thinkingLevel → currentThinkingEnabled) gets a + * deterministic input. History is empty because `resumeSession` does + * not replay anyway — the field is kept for API parity with the + * matching session-load helper. + */ +function makeSessionWithMainConfig( + sessionId: string, + mainConfig?: { modelAlias?: string; thinkingLevel?: string }, +): Session { + return { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: () => undefined, + getResumeState: () => + mainConfig + ? { + agents: { + main: { + config: mainConfig, + context: { history: [], tokenCount: 0 }, + }, + }, + } + : { + agents: { + main: { + context: { history: [], tokenCount: 0 }, + }, + }, + }, + } as unknown as Session; +} + +function makeHarness(opts: { + hasUsableToken?: boolean; + session?: Session; + resumeError?: Error; +}): KimiHarness { + const authed = opts.hasUsableToken ?? true; + return { + auth: { + status: async () => (authed ? AUTHED_STATUS : UNAUTHED_STATUS), + }, + resumeSession: async (_input: { id: string }) => { + if (opts.resumeError) throw opts.resumeError; + if (!opts.session) throw new Error('test harness has no session configured'); + return opts.session; + }, + // Phase 14: server.resumeSession (via setupSessionFromExisting) reads + // these to assemble configOptions. `kimi-coder` opts in to thinking + // via `capabilities: ['thinking']`; `kimi-plain` stays off. + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }, + { id: 'kimi-plain', name: 'Kimi Plain', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; +} + +describe('AcpServer.resumeSession', () => { + it('auth gate rejects with authRequired (-32000) when no token', async () => { + const harness = makeHarness({ hasUsableToken: false }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await expect( + clientConn.resumeSession({ sessionId: 'sess-x', cwd: '/tmp/x', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32000 }); + }); + + it('returns configOptions matching the resumed session model + mode + thinking', async () => { + const sessionId = 'sess-resume-model'; + // Resume state reports kimi-plain (thinking unsupported) so we can + // assert the projection picks the alias from main-agent config and + // that thinking flips to `on` because `thinkingLevel='high'` is + // non-`off` per the server's boolean projection. The mode currentValue + // is always `default` because mode is session-scoped (PLAN D9). + // + // We use kimi-coder so the thinking option is rendered (kimi-plain + // would suppress it via `thinkingSupported: false`). + const session = makeSessionWithMainConfig(sessionId, { + modelAlias: 'kimi-coder', + thinkingLevel: 'high', + }); + const harness = makeHarness({ hasUsableToken: true, session }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + const response = await clientConn.resumeSession({ + sessionId, + cwd: '/tmp/x', + mcpServers: [], + }); + + expect(response.configOptions).toBeDefined(); + expect(response.configOptions).toHaveLength(3); + + const modelOpt = response.configOptions!.find((o) => o.id === 'model'); + const thinkingOpt = response.configOptions!.find((o) => o.id === 'thinking'); + const modeOpt = response.configOptions!.find((o) => o.id === 'mode'); + expect(modelOpt).toBeDefined(); + expect(thinkingOpt).toBeDefined(); + expect(modeOpt).toBeDefined(); + + if (modelOpt!.type !== 'select') throw new Error('model option must be a select'); + expect(modelOpt!.currentValue).toBe('kimi-coder'); + + if (thinkingOpt!.type !== 'select') throw new Error('thinking option must be a select'); + // `thinkingLevel='high'` → boolean projection picks the `on` slot. + expect(thinkingOpt!.currentValue).toBe('on'); + + if (modeOpt!.type !== 'select') throw new Error('mode option must be a select'); + // Mode is session-scoped and not persisted → resumed sessions + // start at `default`. + expect(modeOpt!.currentValue).toBe('default'); + }); + + it('does NOT emit replay session/update notifications (only the available_commands_update)', async () => { + const sessionId = 'sess-no-replay'; + // Use a session that WOULD replay 2 turns if loadSession had been + // called — pass a populated history (the server ignores it for + // resume because `replayHistory()` is not invoked). + const session = { + id: sessionId, + cancel: async () => undefined, + prompt: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: () => undefined, + getResumeState: () => ({ + agents: { + main: { + context: { + history: [ + { role: 'user', content: [{ type: 'text', text: 'hello' }], toolCalls: [] }, + { role: 'assistant', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ], + tokenCount: 0, + }, + }, + }, + }), + } as unknown as Session; + const harness = makeHarness({ hasUsableToken: true, session }); + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const client = new CapturingClient(); + const clientConn = new ClientSideConnection((_a) => client, clientStream); + + await clientConn.resumeSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + // Exactly ONE notification: the available_commands_update. Compare + // to session-load.test.ts which sees 1 update per history turn + // PLUS the available_commands_update. + expect(client.updates).toHaveLength(1); + expect((client.updates[0]!.update as { sessionUpdate?: string }).sessionUpdate).toBe( + 'available_commands_update', + ); + }); + + it('maps SDK session.not_found error to invalidParams (-32602)', async () => { + const harness = makeHarness({ + hasUsableToken: true, + resumeError: new KimiError(ErrorCodes.SESSION_NOT_FOUND, 'Session "ghost" was not found'), + }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await expect( + clientConn.resumeSession({ sessionId: 'ghost', cwd: '/tmp/x', mcpServers: [] }), + ).rejects.toMatchObject({ code: -32602 }); + }); + + it('registers the AcpSession under its id so subsequent calls can locate it', async () => { + const sessionId = 'sess-resume-registered'; + const session = makeSessionWithMainConfig(sessionId); + const harness = makeHarness({ hasUsableToken: true, session }); + const { agentStream, clientStream } = makeInMemoryStreamPair(); + let server: AcpServer | undefined; + new AgentSideConnection((c) => { + server = new AcpServer(harness, c); + return server; + }, agentStream); + const clientConn = new ClientSideConnection((_a) => new CapturingClient(), clientStream); + + await clientConn.resumeSession({ sessionId, cwd: '/tmp/x', mcpServers: [] }); + + expect(server?.getSession(sessionId)?.id).toBe(sessionId); + }); +}); diff --git a/packages/acp-adapter/test/set-session-config-option.test.ts b/packages/acp-adapter/test/set-session-config-option.test.ts new file mode 100644 index 000000000..a90eaf8d6 --- /dev/null +++ b/packages/acp-adapter/test/set-session-config-option.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { + ApprovalHandler, + Event, + KimiHarness, + PermissionMode, + Session, +} from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS, makeModelsMap } from './_helpers/harness-stubs'; + +class CapturingClient implements Client { + readonly notifications: SessionNotification[] = []; + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CapturingClient.requestPermission should not be called'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.notifications.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CapturingClient.writeTextFile should not be called'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CapturingClient.readTextFile should not be called'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +interface FakeSessionHandle { + session: Session; + planModeCalls: boolean[]; + setPermissionCalls: PermissionMode[]; + setModelCalls: string[]; + setThinkingCalls: string[]; +} + +function makeFakeSession(sessionId: string): FakeSessionHandle { + const planModeCalls: boolean[] = []; + const setPermissionCalls: PermissionMode[] = []; + const setModelCalls: string[] = []; + const setThinkingCalls: string[] = []; + const session = { + id: sessionId, + prompt: async () => undefined, + cancel: async () => undefined, + onEvent: (_fn: (event: Event) => void) => () => undefined, + setApprovalHandler: (_handler: ApprovalHandler | undefined) => undefined, + setPlanMode: async (enabled: boolean) => { + planModeCalls.push(enabled); + }, + setPermission: async (mode: PermissionMode) => { + setPermissionCalls.push(mode); + }, + setModel: async (model: string) => { + setModelCalls.push(model); + }, + setThinking: async (level: string) => { + setThinkingCalls.push(level); + }, + } as unknown as Session; + return { session, planModeCalls, setPermissionCalls, setModelCalls, setThinkingCalls }; +} + +function makeHarness(handle: FakeSessionHandle): KimiHarness { + return { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => handle.session, + getConfig: async () => ({ + providers: {}, + defaultModel: 'kimi-coder', + models: makeModelsMap([ + { id: 'kimi-coder', name: 'Kimi Coder', thinkingSupported: true }, + { id: 'kimi-v2', name: 'Kimi v2', thinkingSupported: false }, + ]), + }), + } as unknown as KimiHarness; +} + +async function openSession( + harness: KimiHarness, +): Promise<{ client: ClientSideConnection; capturing: CapturingClient; sessionId: string }> { + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const capturing = new CapturingClient(); + const client = new ClientSideConnection((_a) => capturing, clientStream); + const response = await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + return { client, capturing, sessionId: response.sessionId }; +} + +describe('AcpServer session/set_config_option', () => { + it('configId="model" + known modelId → setModel + 1 config_option_update + response contains full snapshot', async () => { + const handle = makeFakeSession('sess-model'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; // ignore newSession-time notifications + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'model', + value: 'kimi-v2', + }); + + expect(handle.setModelCalls).toEqual(['kimi-v2']); + // The new model is non-thinking-supported, so the toggle is omitted. + expect(handle.setThinkingCalls).toEqual([]); + + // Exactly one config_option_update notification (no double-emit). + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const modelOpt = update.configOptions.find((o) => o.id === 'model'); + if (modelOpt && modelOpt.type === 'select') { + expect(modelOpt.currentValue).toBe('kimi-v2'); + } + // Switching to a non-thinking-supported model drops the toggle entirely. + expect(update.configOptions.map((o) => o.id)).toEqual(['model', 'mode']); + + // Response carries the same snapshot as the notification. + expect(response.configOptions).toBeDefined(); + expect(response.configOptions).toHaveLength(2); + const respModel = response.configOptions.find((o) => o.id === 'model'); + if (respModel && respModel.type === 'select') { + expect(respModel.currentValue).toBe('kimi-v2'); + } + }); + + it('configId="model" + `${id},thinking` → SDK gets stripped id + setThinking("high") + snapshot shows base id with thinking toggle on', async () => { + const handle = makeFakeSession('sess-model-thinking'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'model', + value: 'kimi-coder,thinking', + }); + + expect(handle.setModelCalls).toEqual(['kimi-coder']); + expect(handle.setThinkingCalls).toEqual(['high']); + const respModel = response.configOptions.find((o) => o.id === 'model'); + if (respModel && respModel.type === 'select') { + // Snapshot now carries the bare model id; thinking lives on a separate axis. + expect(respModel.currentValue).toBe('kimi-coder'); + } + const respThinking = response.configOptions.find((o) => o.id === 'thinking'); + if (!respThinking || respThinking.type !== 'select') { + throw new Error('expected thinking toggle in snapshot'); + } + expect(respThinking.currentValue).toBe('on'); + expect(respThinking.category).toBe('thought_level'); + }); + + it('configId="thinking" + "on" → setThinking("high") + 1 config_option_update with currentValue="on"', async () => { + const handle = makeFakeSession('sess-thinking-on'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'on', + }); + + expect(handle.setThinkingCalls).toEqual(['high']); + expect(handle.setModelCalls).toEqual([]); + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const toggle = update.configOptions.find((o) => o.id === 'thinking'); + if (!toggle || toggle.type !== 'select') throw new Error('expected select toggle'); + expect(toggle.currentValue).toBe('on'); + + const respToggle = response.configOptions.find((o) => o.id === 'thinking'); + if (!respToggle || respToggle.type !== 'select') throw new Error('expected select toggle'); + expect(respToggle.currentValue).toBe('on'); + }); + + it('configId="thinking" + "off" → setThinking("off") + currentValue="off"', async () => { + const handle = makeFakeSession('sess-thinking-off'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + const response = await client.setSessionConfigOption({ + sessionId, + configId: 'thinking', + value: 'off', + }); + + expect(handle.setThinkingCalls).toEqual(['off']); + const respToggle = response.configOptions.find((o) => o.id === 'thinking'); + if (!respToggle || respToggle.type !== 'select') throw new Error('expected select toggle'); + expect(respToggle.currentValue).toBe('off'); + }); + + const MODE_CASES: ReadonlyArray<{ + modeId: 'default' | 'plan' | 'auto' | 'yolo'; + expectedPlan: boolean; + expectedPermission: PermissionMode; + }> = [ + { modeId: 'default', expectedPlan: false, expectedPermission: 'manual' }, + { modeId: 'plan', expectedPlan: true, expectedPermission: 'manual' }, + { modeId: 'auto', expectedPlan: false, expectedPermission: 'auto' }, + { modeId: 'yolo', expectedPlan: false, expectedPermission: 'yolo' }, + ]; + + for (const { modeId, expectedPlan, expectedPermission } of MODE_CASES) { + it(`configId="mode" + "${modeId}" → setPlanMode(${expectedPlan}) + setPermission(${expectedPermission}) + 1 config_option_update`, async () => { + const handle = makeFakeSession(`sess-mode-${modeId}`); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await client.setSessionConfigOption({ sessionId, configId: 'mode', value: modeId }); + + expect(handle.planModeCalls).toEqual([expectedPlan]); + expect(handle.setPermissionCalls).toEqual([expectedPermission]); + const updates = capturing.notifications.filter( + (n) => n.sessionId === sessionId && n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toHaveLength(1); + const update = updates[0]!.update; + if (update.sessionUpdate !== 'config_option_update') throw new Error('unreachable'); + const modeOpt = update.configOptions.find((o) => o.id === 'mode'); + if (modeOpt && modeOpt.type === 'select') { + expect(modeOpt.currentValue).toBe(modeId); + } + }); + } + + it('unknown configId throws invalid_params (-32602) BEFORE any SDK call and emits zero notifications', async () => { + const handle = makeFakeSession('sess-bad-configId'); + const harness = makeHarness(handle); + const { client, capturing, sessionId } = await openSession(harness); + capturing.notifications.length = 0; + + await expect( + client.setSessionConfigOption({ sessionId, configId: 'theme', value: 'dark' }), + ).rejects.toMatchObject({ code: -32602 }); + + expect(handle.planModeCalls).toEqual([]); + expect(handle.setPermissionCalls).toEqual([]); + expect(handle.setModelCalls).toEqual([]); + const updates = capturing.notifications.filter( + (n) => n.update.sessionUpdate === 'config_option_update', + ); + expect(updates).toEqual([]); + }); + + it('unknown sessionId throws invalid_params (-32602)', async () => { + const handle = makeFakeSession('sess-known'); + const harness = makeHarness(handle); + const { client } = await openSession(harness); + + await expect( + client.setSessionConfigOption({ + sessionId: 'sess-unknown', + configId: 'mode', + value: 'plan', + }), + ).rejects.toMatchObject({ code: -32602 }); + }); +}); diff --git a/packages/acp-adapter/test/shutdown.test.ts b/packages/acp-adapter/test/shutdown.test.ts new file mode 100644 index 000000000..1d2265f6d --- /dev/null +++ b/packages/acp-adapter/test/shutdown.test.ts @@ -0,0 +1,134 @@ +import { EventEmitter } from 'node:events'; +import { PassThrough } from 'node:stream'; + +import { describe, expect, it } from 'vitest'; +import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk'; + +import { runAcpServer } from '../src/server'; + +interface CloseCounterHarness { + harness: KimiHarness; + closeCalls: () => number; +} + +/** + * Minimal harness stub. Phase 11's shutdown wiring only touches + * {@link KimiHarness.close}; the other harness surface is exercised in + * sibling tests (`session-new`, `session-load`, etc.) and is irrelevant + * here. Each close call increments a counter so we can assert + * idempotency on signal+natural-close interleavings. + */ +function makeCloseCounterHarness(opts: { throwOnClose?: boolean } = {}): CloseCounterHarness { + let calls = 0; + const harness = { + close: async (): Promise => { + calls += 1; + if (opts.throwOnClose) { + throw new Error('intentional close failure for test'); + } + }, + } as unknown as KimiHarness; + return { harness, closeCalls: () => calls }; +} + +/** + * Tear off the JSON-RPC connection by ending stdin so + * `AgentSideConnection.closed` resolves and `runAcpServer` returns. + * Used by the natural-close test path; the signal-path test forces + * cleanup BEFORE this end fires. + */ +function endInput(input: PassThrough): void { + input.end(); +} + +describe('runAcpServer graceful shutdown', () => { + it('calls harness.close() exactly once when SIGINT fires before natural close', async () => { + const { harness, closeCalls } = makeCloseCounterHarness(); + const signals = new EventEmitter(); + const input = new PassThrough(); + const output = new PassThrough(); + // Drain output so the agent side never backpressures. + output.on('data', () => undefined); + + const run = runAcpServer(harness, { input, output, signals }); + + // Give the connection a tick to start, then fire SIGINT. + await new Promise((resolve) => setTimeout(resolve, 10)); + signals.emit('SIGINT'); + + // The signal-driven cleanup runs synchronously after the tick but + // doesn't itself end the stream — close the input so the + // connection actually settles. + await new Promise((resolve) => setTimeout(resolve, 10)); + endInput(input); + await run; + + expect(closeCalls()).toBe(1); + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(signals.listenerCount('SIGTERM')).toBe(0); + }); + + it('calls harness.close() exactly once on natural close (no signal)', async () => { + const { harness, closeCalls } = makeCloseCounterHarness(); + const signals = new EventEmitter(); + const input = new PassThrough(); + const output = new PassThrough(); + output.on('data', () => undefined); + + const run = runAcpServer(harness, { input, output, signals }); + + // Natural close: end stdin immediately. + await new Promise((resolve) => setTimeout(resolve, 10)); + endInput(input); + await run; + + expect(closeCalls()).toBe(1); + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(signals.listenerCount('SIGTERM')).toBe(0); + }); + + it('treats SIGTERM the same as SIGINT and stays idempotent if both fire', async () => { + const { harness, closeCalls } = makeCloseCounterHarness(); + const signals = new EventEmitter(); + const input = new PassThrough(); + const output = new PassThrough(); + output.on('data', () => undefined); + + const run = runAcpServer(harness, { input, output, signals }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + signals.emit('SIGTERM'); + signals.emit('SIGINT'); // duplicate signal — must NOT call close again + + await new Promise((resolve) => setTimeout(resolve, 10)); + endInput(input); + await run; + + // SIGTERM and SIGINT collapse to a single close call thanks to the + // `cleanedUp` latch. The natural-close path in `finally` also + // re-enters `cleanup()` and must be a no-op. + expect(closeCalls()).toBe(1); + }); + + it('uninstalls listeners even when harness.close() throws', async () => { + // The process is exiting anyway; the implementation must NOT let a + // throwing `close()` leak the SIGINT/SIGTERM handlers. + const { harness, closeCalls } = makeCloseCounterHarness({ throwOnClose: true }); + const signals = new EventEmitter(); + const input = new PassThrough(); + const output = new PassThrough(); + output.on('data', () => undefined); + + const run = runAcpServer(harness, { input, output, signals }); + + await new Promise((resolve) => setTimeout(resolve, 10)); + signals.emit('SIGINT'); + await new Promise((resolve) => setTimeout(resolve, 10)); + endInput(input); + await run; + + expect(closeCalls()).toBe(1); + expect(signals.listenerCount('SIGINT')).toBe(0); + expect(signals.listenerCount('SIGTERM')).toBe(0); + }); +}); diff --git a/packages/acp-adapter/test/tool-call-stream.test.ts b/packages/acp-adapter/test/tool-call-stream.test.ts new file mode 100644 index 000000000..7bf0511fe --- /dev/null +++ b/packages/acp-adapter/test/tool-call-stream.test.ts @@ -0,0 +1,476 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; + +class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + + /** + * Updates produced AFTER `session/new` returns. Phase 9.3 makes + * `newSession` emit exactly one `available_commands_update` on + * creation; existing tests assert only on prompt-driven updates, + * so we filter that variant out. + */ + get promptUpdates(): readonly SessionNotification[] { + return this.updates.filter( + (n) => + (n.update as { sessionUpdate?: string }).sessionUpdate !== + 'available_commands_update', + ); + } + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CollectingClient.requestPermission should not be called in tool-call-stream test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CollectingClient.writeTextFile should not be called in tool-call-stream test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CollectingClient.readTextFile should not be called in tool-call-stream test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeScriptedSession( + sessionId: string, + script: readonly Event[], +): Session { + const listeners = new Set<(event: Event) => void>(); + const session = { + id: sessionId, + prompt: async (_input: unknown) => { + for (const ev of script) { + for (const fn of listeners) fn(ev); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; + return session; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +async function flushNdjson(): Promise { + // Let queued sessionUpdate writes drain through the ndjson stream. + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +describe('AcpServer tool-call streaming', () => { + it('streams tool_call (start) → tool_call_update (delta x N) → end_turn for a single tool call', async () => { + const sessionId = 'sess-tc-1'; + const turnId = 1; + const toolCallId = 'tc-abc'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + args: { path: 'a' }, + } as Event, + { + type: 'tool.call.delta', + sessionId, + agentId: 'main', + turnId, + toolCallId, + argumentsPart: ', "lim', + } as Event, + { + type: 'tool.call.delta', + sessionId, + agentId: 'main', + turnId, + toolCallId, + argumentsPart: 'it": 5}', + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('go')] }); + expect(response.stopReason).toBe('end_turn'); + await flushNdjson(); + + expect(collecting.promptUpdates).toHaveLength(3); + + // 1) tool_call (creation) with stringified initial args. + expect(collecting.promptUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: `${turnId}:${toolCallId}`, + title: 'Read', + kind: 'read', + status: 'in_progress', + rawInput: { path: 'a' }, + content: [ + { + type: 'content', + content: { type: 'text', text: JSON.stringify({ path: 'a' }) }, + }, + ], + }); + + // 2) first delta — cumulative args = initial + first part. + const firstCumulative = `${JSON.stringify({ path: 'a' })}, "lim`; + expect(collecting.promptUpdates[1]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + status: 'in_progress', + content: [ + { type: 'content', content: { type: 'text', text: firstCumulative } }, + ], + }); + + // 3) second delta — cumulative args = initial + first + second. + const secondCumulative = `${firstCumulative}it": 5}`; + expect(collecting.promptUpdates[2]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + status: 'in_progress', + content: [ + { type: 'content', content: { type: 'text', text: secondCumulative } }, + ], + }); + }); + + it('uses turn-prefixed toolCallId so identical SDK ids across turns do not collide', async () => { + // We script two consecutive `tool.call.started` events with the + // SAME SDK `toolCallId` but DIFFERENT `turnId` to assert the ACP + // wire ids are distinct. + const sessionId = 'sess-tc-collision'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId: 1, + toolCallId: 'X', + name: 'Bash', + args: { cmd: 'ls' }, + } as Event, + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId: 2, + toolCallId: 'X', + name: 'Bash', + args: { cmd: 'pwd' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 2, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + const startUpdates = collecting.updates.filter( + (n) => (n.update as { sessionUpdate: string }).sessionUpdate === 'tool_call', + ); + expect(startUpdates).toHaveLength(2); + const ids = startUpdates.map((n) => (n.update as { toolCallId: string }).toolCallId); + expect(ids).toEqual(['1:X', '2:X']); + expect(ids[0]).not.toBe(ids[1]); + }); + + it('emits agent_thought_chunk for thinking.delta events', async () => { + const sessionId = 'sess-thinking'; + const session = makeScriptedSession(sessionId, [ + { type: 'thinking.delta', sessionId, agentId: 'main', turnId: 1, delta: 'hmm' } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId: 1, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + expect(collecting.promptUpdates).toHaveLength(1); + expect(collecting.promptUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'hmm' }, + }); + }); + + it('relays only `status` tool.progress updates as title-bearing tool_call_update', async () => { + const sessionId = 'sess-progress'; + const turnId = 1; + const toolCallId = 'tc-prog'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Bash', + args: { cmd: 'pnpm test' }, + } as Event, + { + type: 'tool.progress', + sessionId, + agentId: 'main', + turnId, + toolCallId, + update: { kind: 'stdout', text: 'should not stream' }, + } as Event, + { + type: 'tool.progress', + sessionId, + agentId: 'main', + turnId, + toolCallId, + update: { kind: 'status', text: 'running test suite' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + // 1 start + 1 status (stdout is dropped) = 2 updates. + expect(collecting.promptUpdates).toHaveLength(2); + const second = collecting.promptUpdates[1]?.update as { + sessionUpdate: string; + title?: string; + }; + expect(second.sessionUpdate).toBe('tool_call_update'); + expect(second.title).toBe('running test suite'); + }); + + it('lazy-creates tool_call on the first delta and upgrades on tool.call.started (production event order)', async () => { + // The agent-core actually emits `tool.call.delta` events DURING + // the provider's args-streaming phase and only fires + // `tool.call.started` afterwards. The adapter must therefore + // lazy-create the wire `tool_call` from the first delta, otherwise + // Zed sees `tool_call_update` notifications for an unknown id and + // surfaces "Tool call not found" until the start eventually lands. + // This test pins the production order delta → delta → started → + // result → end. + const sessionId = 'sess-tc-lazy'; + const turnId = 1; + const toolCallId = 'tc-stream'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.delta', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + argumentsPart: '{"path":', + } as Event, + { + type: 'tool.call.delta', + sessionId, + agentId: 'main', + turnId, + toolCallId, + argumentsPart: '"a"}', + } as Event, + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + args: { path: 'a' }, + description: 'Reading a', + } as Event, + { + type: 'tool.result', + sessionId, + agentId: 'main', + turnId, + toolCallId, + output: 'file content', + isError: false, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + const response = await client.prompt({ sessionId, prompt: [textBlock('go')] }); + expect(response.stopReason).toBe('end_turn'); + await flushNdjson(); + + // delta(lazy-create) + delta(cumulative) + started(upgrade) + result + expect(collecting.promptUpdates).toHaveLength(4); + + // 1) Lazy create: `tool_call` MUST land before any update, with + // `name`-derived title and the first delta fragment as content. + expect(collecting.promptUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: `${turnId}:${toolCallId}`, + title: 'Read', + kind: 'read', + status: 'pending', + content: [ + { type: 'content', content: { type: 'text', text: '{"path":' } }, + ], + }); + + // 2) Second delta: cumulative args replace content. + expect(collecting.promptUpdates[1]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + status: 'in_progress', + content: [ + { type: 'content', content: { type: 'text', text: '{"path":"a"}' } }, + ], + }); + + // 3) Start arrives after lazy-create: emitted as `tool_call_update` + // carrying the canonical title (from `description`), `rawInput`, + // and canonical stringified args. Status flips to `in_progress`. + expect(collecting.promptUpdates[2]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + title: 'Reading a', + kind: 'read', + status: 'in_progress', + rawInput: { path: 'a' }, + content: [ + { + type: 'content', + content: { type: 'text', text: JSON.stringify({ path: 'a' }) }, + }, + ], + }); + + // 4) Result: terminal update. + expect(collecting.promptUpdates[3]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + status: 'completed', + }); + }); + + it('keeps the start-first path unchanged when no deltas precede tool.call.started', async () => { + // Some providers (or the synthetic / replay paths) emit + // `tool.call.started` without a preceding args stream. The adapter + // must still send a `tool_call` CREATE in that case and NOT an + // update — clients otherwise have no card to update. + const sessionId = 'sess-tc-startfirst'; + const turnId = 1; + const toolCallId = 'tc-start'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Read', + args: { path: 'a' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + expect(collecting.promptUpdates).toHaveLength(1); + expect(collecting.promptUpdates[0]?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: `${turnId}:${toolCallId}`, + title: 'Read', + status: 'in_progress', + rawInput: { path: 'a' }, + }); + }); +}); diff --git a/packages/acp-adapter/test/tool-result.test.ts b/packages/acp-adapter/test/tool-result.test.ts new file mode 100644 index 000000000..d8489af6b --- /dev/null +++ b/packages/acp-adapter/test/tool-result.test.ts @@ -0,0 +1,402 @@ +import { describe, expect, it } from 'vitest'; + +import { + AgentSideConnection, + ClientSideConnection, + ndJsonStream, + type Client, + type ContentBlock, + type ReadTextFileRequest, + type ReadTextFileResponse, + type RequestPermissionRequest, + type RequestPermissionResponse, + type SessionNotification, + type WriteTextFileRequest, + type WriteTextFileResponse, +} from '@agentclientprotocol/sdk'; +import type { Event, KimiHarness, Session } from '@moonshot-ai/kimi-code-sdk'; + +import { AcpServer } from '../src/server'; +import { AUTHED_STATUS } from './_helpers/harness-stubs'; +import { toolResultToAcpContent } from '../src/convert'; + +class CollectingClient implements Client { + readonly updates: SessionNotification[] = []; + + /** + * Updates produced AFTER `session/new` returns. Phase 9.3 makes + * `newSession` emit exactly one `available_commands_update` on + * creation; existing tests assert only on prompt-driven updates, + * so we filter that variant out. + */ + get promptUpdates(): readonly SessionNotification[] { + return this.updates.filter( + (n) => + (n.update as { sessionUpdate?: string }).sessionUpdate !== + 'available_commands_update', + ); + } + + async requestPermission(_p: RequestPermissionRequest): Promise { + throw new Error('CollectingClient.requestPermission should not be called in tool-result test'); + } + async sessionUpdate(n: SessionNotification): Promise { + this.updates.push(n); + } + async writeTextFile(_p: WriteTextFileRequest): Promise { + throw new Error('CollectingClient.writeTextFile should not be called in tool-result test'); + } + async readTextFile(_p: ReadTextFileRequest): Promise { + throw new Error('CollectingClient.readTextFile should not be called in tool-result test'); + } +} + +function makeInMemoryStreamPair(): { + agentStream: ReturnType; + clientStream: ReturnType; +} { + const clientToAgent = new TransformStream(); + const agentToClient = new TransformStream(); + const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable); + const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable); + return { agentStream, clientStream }; +} + +function makeScriptedSession(sessionId: string, script: readonly Event[]): Session { + const listeners = new Set<(event: Event) => void>(); + return { + id: sessionId, + prompt: async (_input: unknown) => { + for (const ev of script) { + for (const fn of listeners) fn(ev); + } + }, + cancel: async () => undefined, + onEvent: (fn: (event: Event) => void) => { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + } as unknown as Session; +} + +const textBlock = (text: string): ContentBlock => ({ type: 'text', text }); + +async function flushNdjson(): Promise { + await new Promise((resolve) => setTimeout(resolve, 25)); +} + +describe('toolResultToAcpContent (unit)', () => { + it('returns a text content entry for a non-empty string output', () => { + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: 'hello world', + isError: false, + } as never); + expect(content).toEqual([ + { type: 'content', content: { type: 'text', text: 'hello world' } }, + ]); + }); + + it('JSON-stringifies object output', () => { + const content = toolResultToAcpContent({ + type: 'tool.result', + turnId: 1, + toolCallId: 'tc', + output: { count: 3 }, + } as never); + expect(content).toEqual([ + { type: 'content', content: { type: 'text', text: '{"count":3}' } }, + ]); + }); + + it('returns an empty array for empty / undefined / null output', () => { + expect(toolResultToAcpContent({ output: '' } as never)).toEqual([]); + expect(toolResultToAcpContent({ output: undefined } as never)).toEqual([]); + expect(toolResultToAcpContent({ output: null } as never)).toEqual([]); + }); +}); + +describe('AcpServer tool.result → tool_call_update', () => { + it('emits status=completed with text content for non-error string output', async () => { + const sessionId = 'sess-tr-1'; + const turnId = 1; + const toolCallId = 'tc-1'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Bash', + args: { cmd: 'echo hi' }, + } as Event, + { + type: 'tool.result', + sessionId, + agentId: 'main', + turnId, + toolCallId, + output: 'hello world', + isError: false, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + // 1 start + 1 result = 2 updates. + expect(collecting.promptUpdates).toHaveLength(2); + expect(collecting.promptUpdates[1]?.update).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${toolCallId}`, + status: 'completed', + content: [ + { type: 'content', content: { type: 'text', text: 'hello world' } }, + ], + rawOutput: 'hello world', + }); + }); + + it('emits status=failed when isError is true', async () => { + const sessionId = 'sess-tr-err'; + const turnId = 1; + const toolCallId = 'tc-err'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Bash', + args: { cmd: 'false' }, + } as Event, + { + type: 'tool.result', + sessionId, + agentId: 'main', + turnId, + toolCallId, + output: 'oops', + isError: true, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + const last = collecting.updates.at(-1)?.update as { sessionUpdate: string; status: string }; + expect(last.sessionUpdate).toBe('tool_call_update'); + expect(last.status).toBe('failed'); + }); + + it('emits status=completed with empty content array for empty output', async () => { + const sessionId = 'sess-tr-empty'; + const turnId = 1; + const toolCallId = 'tc-empty'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Bash', + args: { cmd: 'true' }, + } as Event, + { + type: 'tool.result', + sessionId, + agentId: 'main', + turnId, + toolCallId, + output: '', + isError: false, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + const last = collecting.updates.at(-1)?.update as { + sessionUpdate: string; + status: string; + content: unknown[]; + }; + expect(last.sessionUpdate).toBe('tool_call_update'); + expect(last.status).toBe('completed'); + expect(last.content).toEqual([]); + }); +}); + +describe('AcpServer tool.call.started with diff display', () => { + it('prepends a diff ToolCallContent entry when display.kind === "diff"', async () => { + const sessionId = 'sess-diff-1'; + const turnId = 1; + const toolCallId = 'tc-diff'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Edit', + args: { path: 'a.txt', oldText: 'foo', newText: 'bar' }, + display: { kind: 'diff', path: 'a.txt', before: 'foo', after: 'bar' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + expect(collecting.promptUpdates).toHaveLength(1); + const update = collecting.promptUpdates[0]?.update as { + sessionUpdate: string; + kind: string; + content: Array<{ type: string; path?: string; oldText?: string; newText?: string }>; + }; + expect(update.sessionUpdate).toBe('tool_call'); + expect(update.kind).toBe('edit'); + // Diff entry should be first, args text second. + expect(update.content[0]).toEqual({ + type: 'diff', + path: 'a.txt', + oldText: 'foo', + newText: 'bar', + }); + expect(update.content[1]).toMatchObject({ + type: 'content', + content: { type: 'text' }, + }); + }); + + it('prepends a diff entry for file_io display with before+after (Edit/Write payload)', async () => { + const sessionId = 'sess-diff-2'; + const turnId = 1; + const toolCallId = 'tc-fio'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Edit', + args: { path: 'b.txt' }, + display: { + kind: 'file_io', + operation: 'edit', + path: 'b.txt', + before: 'alpha', + after: 'beta', + }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + const update = collecting.promptUpdates[0]?.update as { + content: Array<{ type: string; path?: string; oldText?: string; newText?: string }>; + }; + expect(update.content[0]).toEqual({ + type: 'diff', + path: 'b.txt', + oldText: 'alpha', + newText: 'beta', + }); + }); + + it('does NOT prepend a diff entry for non-diff display kinds (e.g. command)', async () => { + const sessionId = 'sess-diff-skip'; + const turnId = 1; + const toolCallId = 'tc-cmd'; + const session = makeScriptedSession(sessionId, [ + { + type: 'tool.call.started', + sessionId, + agentId: 'main', + turnId, + toolCallId, + name: 'Bash', + args: { cmd: 'ls' }, + display: { kind: 'command', command: 'ls' }, + } as Event, + { type: 'turn.ended', sessionId, agentId: 'main', turnId, reason: 'completed' } as Event, + ]); + const harness = { + auth: { status: async () => AUTHED_STATUS }, + createSession: async () => session, + } as unknown as KimiHarness; + + const { agentStream, clientStream } = makeInMemoryStreamPair(); + new AgentSideConnection((c) => new AcpServer(harness, c), agentStream); + const collecting = new CollectingClient(); + const client = new ClientSideConnection(() => collecting, clientStream); + await client.newSession({ cwd: '/tmp/x', mcpServers: [] }); + await client.prompt({ sessionId, prompt: [textBlock('go')] }); + await flushNdjson(); + + const update = collecting.promptUpdates[0]?.update as { + content: Array<{ type: string }>; + }; + expect(update.content).toHaveLength(1); + expect(update.content[0]?.type).toBe('content'); + }); +}); diff --git a/packages/acp-adapter/test/version.test.ts b/packages/acp-adapter/test/version.test.ts new file mode 100644 index 000000000..dc859c44d --- /dev/null +++ b/packages/acp-adapter/test/version.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest'; + +import { CURRENT_VERSION, MIN_PROTOCOL_VERSION, negotiateVersion } from '../src/version'; + +describe('negotiateVersion', () => { + it('returns CURRENT_VERSION when the client version is below MIN_PROTOCOL_VERSION', () => { + const result = negotiateVersion(0); + expect(result).toBe(CURRENT_VERSION); + expect(result.protocolVersion).toBe(1); + }); + + it('returns the matching spec when the client requests the current version', () => { + const result = negotiateVersion(1); + expect(result).toBe(CURRENT_VERSION); + expect(result.protocolVersion).toBe(1); + expect(result.specTag).toBe('v0.10.x'); + expect(result.sdkVersion).toBe('0.23.0'); + }); + + it('returns the highest supported version when the client advertises a newer one', () => { + const result = negotiateVersion(99); + expect(result).toBe(CURRENT_VERSION); + expect(result.protocolVersion).toBe(1); + }); + + it('exposes MIN_PROTOCOL_VERSION = 1', () => { + expect(MIN_PROTOCOL_VERSION).toBe(1); + }); +}); diff --git a/packages/acp-adapter/tsconfig.json b/packages/acp-adapter/tsconfig.json new file mode 100644 index 000000000..385b8dab9 --- /dev/null +++ b/packages/acp-adapter/tsconfig.json @@ -0,0 +1,5 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": {}, + "include": ["src", "test", "../agent-core/src/prompt-modules.d.ts"] +} diff --git a/packages/acp-adapter/tsdown.config.ts b/packages/acp-adapter/tsdown.config.ts new file mode 100644 index 000000000..37f147198 --- /dev/null +++ b/packages/acp-adapter/tsdown.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, + deps: { + neverBundle: [ + '@agentclientprotocol/sdk', + '@moonshot-ai/agent-core', + '@moonshot-ai/kimi-code-sdk', + '@moonshot-ai/kosong', + '@moonshot-ai/kaos', + ], + }, +}); diff --git a/packages/acp-adapter/vitest.config.ts b/packages/acp-adapter/vitest.config.ts new file mode 100644 index 000000000..c47cebdbf --- /dev/null +++ b/packages/acp-adapter/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +export default defineConfig({ + plugins: [rawTextPlugin()], + test: { + name: 'acp-adapter', + include: ['test/**/*.test.ts'], + }, +}); diff --git a/packages/agent-core/src/mcp/session-config.ts b/packages/agent-core/src/mcp/session-config.ts index 874de1ea1..d3fea129b 100644 --- a/packages/agent-core/src/mcp/session-config.ts +++ b/packages/agent-core/src/mcp/session-config.ts @@ -21,3 +21,18 @@ export async function resolveSessionMcpConfig( if (Object.keys(servers).length === 0) return undefined; return { servers }; } + +export function mergeCallerMcpServers( + base: SessionMcpConfig | undefined, + callerServers: Readonly> | undefined, +): SessionMcpConfig | undefined { + if (callerServers === undefined || Object.keys(callerServers).length === 0) { + return base; + } + return { + servers: { + ...base?.servers, + ...callerServers, + }, + }; +} diff --git a/packages/agent-core/src/rpc/core-api.ts b/packages/agent-core/src/rpc/core-api.ts index 3ea43ceaf..922d1fa5f 100644 --- a/packages/agent-core/src/rpc/core-api.ts +++ b/packages/agent-core/src/rpc/core-api.ts @@ -4,7 +4,7 @@ import type { BackgroundTaskInfo } from '#/agent/background'; 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'; @@ -47,6 +47,7 @@ export interface CreateSessionPayload { readonly thinking?: string | undefined; readonly permission?: PermissionMode | undefined; readonly metadata?: JsonObject | undefined; + readonly mcpServers?: Readonly>; } export interface CloseSessionPayload { @@ -55,6 +56,7 @@ export interface CloseSessionPayload { export interface ResumeSessionPayload { readonly sessionId: string; + readonly mcpServers?: Readonly>; } export interface ForkSessionPayload { diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 4c61de75a..764724866 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -27,7 +27,7 @@ import { type ExperimentalFlagMap, } from '../flags'; import type { Logger } from '../logging/types'; -import { resolveSessionMcpConfig, type SessionMcpConfig } from '../mcp'; +import { resolveSessionMcpConfig, mergeCallerMcpServers, type SessionMcpConfig } from '../mcp'; import { Session, type SessionMeta, type SessionSkillConfig } from '../session'; import { exportSessionDirectory } from '../session/export'; import { @@ -183,6 +183,7 @@ export class KimiCore implements PromisableMethods { cwd: workDir, homeDir: this.homeDir, }); + const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, options.mcpServers); const summary = await this.sessionStore.create({ id, workDir, @@ -194,7 +195,7 @@ export class KimiCore implements PromisableMethods { await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); - const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig); + const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); // Session ctor attaches its own log sink. If anything in the setup-after- // ctor block throws, `session.close()` releases the sink (and mcp). @@ -281,9 +282,10 @@ export class KimiCore implements PromisableMethods { cwd: summary.workDir, homeDir: this.homeDir, }); + const withCallerMcp = mergeCallerMcpServers(baseMcpConfig, input.mcpServers); await this.pluginsReady; const pluginSessionStarts = this.plugins.enabledSessionStarts(); - const mcpConfig = this.mergePluginMcpConfig(baseMcpConfig); + const mcpConfig = this.mergePluginMcpConfig(withCallerMcp); const runtime = await this.resolveRuntime(config); const session = new Session({ kaos: (await this.kaos).withCwd(summary.workDir), @@ -730,9 +732,7 @@ export class KimiCore implements PromisableMethods { ...pluginServers, }, }; - } - - private sessionApi(sessionId: string): SessionAPIImpl { + } private sessionApi(sessionId: string): SessionAPIImpl { const session = this.sessions.get(sessionId); if (session === undefined) { throw new KimiError(ErrorCodes.SESSION_NOT_FOUND, `Session "${sessionId}" was not found`, { diff --git a/packages/agent-core/test/mcp/session-config-merge.test.ts b/packages/agent-core/test/mcp/session-config-merge.test.ts new file mode 100644 index 000000000..f9cb0c2b9 --- /dev/null +++ b/packages/agent-core/test/mcp/session-config-merge.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeCallerMcpServers, type SessionMcpConfig } from '../../src/mcp/session-config'; +import type { McpServerConfig } from '../../src/config/schema'; + +const stdio = (command: string): McpServerConfig => ({ + transport: 'stdio', + command, +}); + +const http = (url: string): McpServerConfig => ({ + transport: 'http', + url, +}); + +describe('mergeCallerMcpServers', () => { + it('returns base unchanged when callerServers is undefined', () => { + const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; + expect(mergeCallerMcpServers(base, undefined)).toBe(base); + }); + + it('returns base unchanged when callerServers is empty', () => { + const base: SessionMcpConfig = { servers: { fs: stdio('fs') } }; + expect(mergeCallerMcpServers(base, {})).toBe(base); + }); + + it('returns undefined when both base and callerServers are absent', () => { + expect(mergeCallerMcpServers(undefined, undefined)).toBeUndefined(); + expect(mergeCallerMcpServers(undefined, {})).toBeUndefined(); + }); + + it('promotes a caller-only payload into a fresh SessionMcpConfig when base is undefined', () => { + const callerServers = { docs: http('https://mcp.example.com') }; + expect(mergeCallerMcpServers(undefined, callerServers)).toEqual({ + servers: { docs: http('https://mcp.example.com') }, + }); + }); + + it('layers caller on top of base with caller winning on key collision', () => { + const base: SessionMcpConfig = { + servers: { + shared: stdio('disk-version'), + diskOnly: stdio('disk-only'), + }, + }; + const callerServers = { + shared: stdio('caller-version'), + callerOnly: http('https://caller.example.com'), + }; + expect(mergeCallerMcpServers(base, callerServers)).toEqual({ + servers: { + shared: stdio('caller-version'), + diskOnly: stdio('disk-only'), + callerOnly: http('https://caller.example.com'), + }, + }); + }); +}); diff --git a/packages/node-sdk/src/kimi-harness.ts b/packages/node-sdk/src/kimi-harness.ts index 499ce75a0..12bdf298a 100644 --- a/packages/node-sdk/src/kimi-harness.ts +++ b/packages/node-sdk/src/kimi-harness.ts @@ -109,7 +109,7 @@ 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({ ...input, id }); const session = new Session({ id: summary.id, workDir: summary.workDir, diff --git a/packages/node-sdk/src/rpc.ts b/packages/node-sdk/src/rpc.ts index 3b0f4de25..570b13dcd 100644 --- a/packages/node-sdk/src/rpc.ts +++ b/packages/node-sdk/src/rpc.ts @@ -108,7 +108,7 @@ export abstract class SDKRpcClientBase { async resumeSession(input: ResumeSessionInput): Promise { const rpc = await this.getRpc(); - return rpc.resumeSession({ sessionId: input.id }); + return rpc.resumeSession({ ...input, sessionId: input.id }); } async forkSession(input: ForkSessionInput): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 036de0d90..6279480f9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -97,6 +97,9 @@ importers: specifier: ^4.3.6 version: 4.3.6 devDependencies: + '@moonshot-ai/acp-adapter': + specifier: workspace:^ + version: link:../../packages/acp-adapter '@moonshot-ai/kimi-code-oauth': specifier: workspace:^ version: link:../../packages/oauth @@ -206,6 +209,21 @@ importers: specifier: ^1.5.0 version: 1.6.4(@algolia/client-search@5.52.1)(@types/node@22.19.17)(lightningcss@1.32.0)(postcss@8.5.15)(search-insights@2.17.3)(typescript@6.0.2) + packages/acp-adapter: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.23.0 + version: 0.23.0(zod@4.3.6) + '@moonshot-ai/agent-core': + specifier: workspace:^ + version: link:../agent-core + '@moonshot-ai/kaos': + specifier: workspace:^ + version: link:../kaos + '@moonshot-ai/kimi-code-sdk': + specifier: workspace:^ + version: link:../node-sdk + packages/agent-core: dependencies: '@antfu/utils': @@ -399,6 +417,11 @@ importers: packages: + '@agentclientprotocol/sdk@0.23.0': + resolution: {integrity: sha512-wIGDQMSXwhoSPNCJflSQvf4WeSU2Jq1bbPRAwwb49euo9qMcND5Pb80hD7hVfuAwz9ZvvDuzVrNTOZrvKPR9Eg==} + 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 +5518,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@0.23.0(zod@4.3.6)': + dependencies: + zod: 4.3.6 + '@algolia/abtesting@1.18.1': dependencies: '@algolia/client-common': 5.52.1 @@ -10426,7 +10453,7 @@ snapshots: dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.5.9 + postcss: 8.5.15 rolldown: 1.0.0-rc.15 tinyglobby: 0.2.16 optionalDependencies: