From 2dfa11e5daeccad4fca2a73830b47d9eccc04f29 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Sun, 12 Jul 2026 20:40:37 +0800 Subject: [PATCH 1/6] feat(acp): add agent-core-v2 ACP server - add ACP session lifecycle, configuration, permissions, and event bridging - expose the experimental kimi acp-v2 command with terminal authentication - add integration coverage and workspace build configuration --- apps/kimi-code/package.json | 1 + apps/kimi-code/src/cli/commands.ts | 2 + apps/kimi-code/src/cli/sub/acp-v2.ts | 77 ++++ apps/kimi-code/test/cli/acp-v2.test.ts | 165 +++++++ flake.nix | 2 + packages/acp-server/package.json | 26 ++ .../acp-server/src/acp-fs/acpConnection.ts | 82 ++++ .../acp-server/src/acp-fs/acpFsService.ts | 118 +++++ packages/acp-server/src/acp-fs/index.ts | 14 + packages/acp-server/src/approval.ts | 242 +++++++++++ packages/acp-server/src/auth-methods.ts | 62 +++ packages/acp-server/src/builtin-commands.ts | 44 ++ packages/acp-server/src/config-options.ts | 128 ++++++ packages/acp-server/src/convert.ts | 213 +++++++++ packages/acp-server/src/events-map.ts | 407 +++++++++++++++++ packages/acp-server/src/index.ts | 79 ++++ packages/acp-server/src/interaction-bridge.ts | 189 ++++++++ packages/acp-server/src/log.ts | 25 ++ packages/acp-server/src/marker.ts | 37 ++ packages/acp-server/src/model-catalog.ts | 92 ++++ packages/acp-server/src/modes.ts | 87 ++++ packages/acp-server/src/question.ts | 86 ++++ packages/acp-server/src/replay.ts | 191 ++++++++ packages/acp-server/src/server.ts | 404 +++++++++++++++++ packages/acp-server/src/session.ts | 410 ++++++++++++++++++ packages/acp-server/src/slash.ts | 56 +++ packages/acp-server/src/start.ts | 136 ++++++ packages/acp-server/src/types.ts | 29 ++ .../acp-server/test/_helpers/acpClient.ts | 175 ++++++++ .../test/_helpers/fakeModelConfig.ts | 34 ++ .../test/_helpers/scriptedProvider.ts | 171 ++++++++ packages/acp-server/test/approval.test.ts | 185 ++++++++ packages/acp-server/test/close.test.ts | 62 +++ packages/acp-server/test/config.test.ts | 110 +++++ packages/acp-server/test/e2e-turn.test.ts | 141 ++++++ packages/acp-server/test/initialize.test.ts | 119 +++++ .../test/interaction-bridge.test.ts | 199 +++++++++ packages/acp-server/test/lifecycle.test.ts | 88 ++++ packages/acp-server/test/question.test.ts | 52 +++ packages/acp-server/test/replay.test.ts | 129 ++++++ packages/acp-server/test/skills.test.ts | 53 +++ packages/acp-server/tsconfig.json | 7 + packages/acp-server/tsdown.config.ts | 9 + packages/acp-server/vitest.config.ts | 53 +++ packages/agent-core-v2/src/index.ts | 7 +- pnpm-lock.yaml | 15 + 46 files changed, 5012 insertions(+), 1 deletion(-) create mode 100644 apps/kimi-code/src/cli/sub/acp-v2.ts create mode 100644 apps/kimi-code/test/cli/acp-v2.test.ts create mode 100644 packages/acp-server/package.json create mode 100644 packages/acp-server/src/acp-fs/acpConnection.ts create mode 100644 packages/acp-server/src/acp-fs/acpFsService.ts create mode 100644 packages/acp-server/src/acp-fs/index.ts create mode 100644 packages/acp-server/src/approval.ts create mode 100644 packages/acp-server/src/auth-methods.ts create mode 100644 packages/acp-server/src/builtin-commands.ts create mode 100644 packages/acp-server/src/config-options.ts create mode 100644 packages/acp-server/src/convert.ts create mode 100644 packages/acp-server/src/events-map.ts create mode 100644 packages/acp-server/src/index.ts create mode 100644 packages/acp-server/src/interaction-bridge.ts create mode 100644 packages/acp-server/src/log.ts create mode 100644 packages/acp-server/src/marker.ts create mode 100644 packages/acp-server/src/model-catalog.ts create mode 100644 packages/acp-server/src/modes.ts create mode 100644 packages/acp-server/src/question.ts create mode 100644 packages/acp-server/src/replay.ts create mode 100644 packages/acp-server/src/server.ts create mode 100644 packages/acp-server/src/session.ts create mode 100644 packages/acp-server/src/slash.ts create mode 100644 packages/acp-server/src/start.ts create mode 100644 packages/acp-server/src/types.ts create mode 100644 packages/acp-server/test/_helpers/acpClient.ts create mode 100644 packages/acp-server/test/_helpers/fakeModelConfig.ts create mode 100644 packages/acp-server/test/_helpers/scriptedProvider.ts create mode 100644 packages/acp-server/test/approval.test.ts create mode 100644 packages/acp-server/test/close.test.ts create mode 100644 packages/acp-server/test/config.test.ts create mode 100644 packages/acp-server/test/e2e-turn.test.ts create mode 100644 packages/acp-server/test/initialize.test.ts create mode 100644 packages/acp-server/test/interaction-bridge.test.ts create mode 100644 packages/acp-server/test/lifecycle.test.ts create mode 100644 packages/acp-server/test/question.test.ts create mode 100644 packages/acp-server/test/replay.test.ts create mode 100644 packages/acp-server/test/skills.test.ts create mode 100644 packages/acp-server/tsconfig.json create mode 100644 packages/acp-server/tsdown.config.ts create mode 100644 packages/acp-server/vitest.config.ts diff --git a/apps/kimi-code/package.json b/apps/kimi-code/package.json index 4cc9611253..5e6bcabe5a 100644 --- a/apps/kimi-code/package.json +++ b/apps/kimi-code/package.json @@ -81,6 +81,7 @@ }, "devDependencies": { "@moonshot-ai/acp-adapter": "workspace:^", + "@moonshot-ai/acp-server": "workspace:^", "@moonshot-ai/agent-core-v2": "workspace:^", "@moonshot-ai/kap-server": "workspace:^", "@moonshot-ai/kimi-code-oauth": "workspace:^", diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 32a65eb0e0..843f34c83f 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -4,6 +4,7 @@ import { Command, Option } from 'commander'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; +import { registerAcpV2Command } from './sub/acp-v2'; import { registerDoctorCommand } from './sub/doctor'; import { registerExportCommand } from './sub/export'; import { registerLoginCommand } from './sub/login'; @@ -89,6 +90,7 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); + registerAcpV2Command(program); registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); diff --git a/apps/kimi-code/src/cli/sub/acp-v2.ts b/apps/kimi-code/src/cli/sub/acp-v2.ts new file mode 100644 index 0000000000..d1aef65eaf --- /dev/null +++ b/apps/kimi-code/src/cli/sub/acp-v2.ts @@ -0,0 +1,77 @@ +/** + * `kimi acp-v2` sub-command. + * + * Starts the Agent Client Protocol (ACP) server backed directly by the + * DI × Scope agent engine (`agent-core-v2`) over stdio, so ACP-compatible + * clients can drive a kimi-code session on the new engine. This is the v2 + * counterpart to `kimi acp` (which runs the legacy `@moonshot-ai/acp-adapter` + * over the SDK harness). + * + * Wire-up mirrors `kimi acp` for the parts that are host-independent: + * - `--login` pivots into the shared device-code login flow (the entry point + * ACP clients hit via the first-class `AuthMethodTerminal` path, re-invoking + * the agent binary with the advertised `args:['--login']`). + * - `KIMI_CODE_HOME` (if set) is forwarded into `authMethods[0].env` so the + * login subprocess writes its token under the same data root the server + * reads from, and `process.argv[1]` is advertised as the legacy + * `_meta['terminal-auth'].command` fallback. + * + * `@moonshot-ai/acp-server` (and its `agent-core-v2` engine) is loaded via a + * lazy dynamic import so the default CLI / `kimi acp` module graph stays free + * of the experimental v2 engine — mirroring the `kimi server run` v2 routing + * in `#/cli/sub/server/run.ts`. + */ + +import type { Command } from 'commander'; + +import { getVersion } from '#/cli/version'; +import { KIMI_CODE_HOME_ENV } from '#/constant/app'; +import { getDataDir } from '#/utils/paths'; + +import { runLoginFlow } from './login-flow'; + +export function registerAcpV2Command(parent: Command): void { + parent + .command('acp-v2') + .description( + 'Run kimi-code as an Agent Client Protocol (ACP) server over stdio (experimental agent-core-v2 engine).', + ) + .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; + } + // Forward `KIMI_CODE_HOME` (if set) into `authMethods[0].env` so the + // login subprocess clients spawn for terminal-auth writes its token + // under the same data root the ACP server reads from. + 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'`. `command` is the absolute + // path to this very binary so the client can spawn it for login. + const legacyCommand = process.argv[1]; + try { + const { runAcpServer } = await import('@moonshot-ai/acp-server'); + await runAcpServer({ + homeDir: getDataDir(), + agentInfo: { name: 'Kimi Code CLI', version: getVersion() }, + ...(terminalAuthEnv ? { terminalAuthEnv } : {}), + ...(legacyCommand !== undefined && legacyCommand.length > 0 + ? { terminalAuthLegacyCommand: legacyCommand } + : {}), + }); + process.exit(0); + } catch (error) { + process.stderr.write(`acp-v2 server: fatal error: ${String(error)}\n`); + process.exit(1); + } + }); +} diff --git a/apps/kimi-code/test/cli/acp-v2.test.ts b/apps/kimi-code/test/cli/acp-v2.test.ts new file mode 100644 index 0000000000..d26b0b2533 --- /dev/null +++ b/apps/kimi-code/test/cli/acp-v2.test.ts @@ -0,0 +1,165 @@ +/** + * `kimi acp-v2` + * + * Verifies that the ACP v2 sub-command is registered on the program and that + * the action wires `@moonshot-ai/acp-server`'s `runAcpServer` (the real server + * is stubbed so the test doesn't actually take over stdio). The module is + * loaded via a lazy dynamic import in the action, so the mock intercepts that + * import. + */ + +import { Command } from 'commander'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@moonshot-ai/acp-server', () => ({ + runAcpServer: vi.fn(async () => undefined), +})); + +import { runAcpServer } from '@moonshot-ai/acp-server'; + +import { registerAcpV2Command } from '#/cli/sub/acp-v2'; +import { getDataDir } from '#/utils/paths'; + +class ExitCalled extends Error { + constructor(public code: number | string | null | undefined) { + super(`process.exit(${String(code)})`); + } +} + +describe('kimi acp-v2', () => { + 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-v2` subcommand on the program', () => { + const program = new Command('kimi'); + registerAcpV2Command(program); + + const acpV2 = program.commands.find((c) => c.name() === 'acp-v2'); + expect(acpV2).toBeDefined(); + expect(acpV2?.description()).toMatch(/Agent Client Protocol/); + }); + + it('invokes runAcpServer with the v2 host options and exits 0 on success', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpV2Command(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled); + + expect(runAcpServer).toHaveBeenCalledTimes(1); + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(optsArg).toEqual( + expect.objectContaining({ + homeDir: getDataDir(), + agentInfo: { name: 'Kimi Code CLI', version: expect.any(String) }, + }), + ); + expect(exitSpy).toHaveBeenCalledWith(0); + }); + + it('forwards KIMI_CODE_HOME to terminalAuthEnv and homeDir 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(); + registerAcpV2Command(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0]; + expect(optsArg).toEqual( + expect.objectContaining({ + homeDir: '/tmp/kimi-debug', + 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(); + registerAcpV2Command(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as { + terminalAuthEnv?: unknown; + }; + expect(optsArg.terminalAuthEnv).toBeUndefined(); + } finally { + if (previous === undefined) { + delete process.env['KIMI_CODE_HOME']; + } else { + process.env['KIMI_CODE_HOME'] = previous; + } + } + }); + + it('forwards process.argv[1] as terminalAuthLegacyCommand', async () => { + const program = new Command('kimi').exitOverride(); + registerAcpV2Command(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp-v2'])).rejects.toThrow(ExitCalled); + + const optsArg = vi.mocked(runAcpServer).mock.calls[0]?.[0] as { + terminalAuthLegacyCommand?: string; + }; + 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 SDK harness so runLoginFlow doesn't hit a real OAuth endpoint: + // harness.auth.login resolves immediately and triggers exit 0. + 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 { registerAcpV2Command: freshRegister } = await import('#/cli/sub/acp-v2'); + try { + const program = new Command('kimi').exitOverride(); + freshRegister(program); + + await expect(program.parseAsync(['node', 'kimi', 'acp-v2', '--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/flake.nix b/flake.nix index 63de5dc897..1502740ba8 100644 --- a/flake.nix +++ b/flake.nix @@ -63,6 +63,7 @@ # ------------------------------------------------------------------- workspacePaths = [ ./packages/acp-adapter + ./packages/acp-server ./packages/agent-core ./packages/agent-core-v2 ./packages/server @@ -89,6 +90,7 @@ workspaceNames = [ "@moonshot-ai/acp-adapter" + "@moonshot-ai/acp-server" "@moonshot-ai/agent-core" "@moonshot-ai/agent-core-v2" "@moonshot-ai/server" diff --git a/packages/acp-server/package.json b/packages/acp-server/package.json new file mode 100644 index 0000000000..25a56b49e1 --- /dev/null +++ b/packages/acp-server/package.json @@ -0,0 +1,26 @@ +{ + "name": "@moonshot-ai/acp-server", + "version": "0.0.0", + "private": true, + "description": "Agent Client Protocol (ACP) host backed directly by the DI × Scope agent engine (agent-core-v2)", + "license": "MIT", + "author": "Moonshot AI", + "type": "module", + "exports": { + ".": { + "types": "./src/index.ts", + "default": "./src/index.ts" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "vitest run", + "clean": "rm -rf dist" + }, + "dependencies": { + "@agentclientprotocol/sdk": "^0.23.0", + "@moonshot-ai/agent-core-v2": "workspace:^", + "@moonshot-ai/protocol": "workspace:^" + } +} diff --git a/packages/acp-server/src/acp-fs/acpConnection.ts b/packages/acp-server/src/acp-fs/acpConnection.ts new file mode 100644 index 0000000000..b89b098228 --- /dev/null +++ b/packages/acp-server/src/acp-fs/acpConnection.ts @@ -0,0 +1,82 @@ +/** + * ACP client-fs bridge — App-scope holder for the process-wide ACP client + * connection used by the ACP-backed `IHostFileSystem` to reverse-RPC file text + * reads/writes to the editor (ACP `fs.readTextFile` / `fs.writeTextFile`). + * + * One ACP client connection exists per `acp-server` process (a single stdio + * `AgentSideConnection`, multiplexed by `sessionId`); it is established after + * `bootstrap()`, so it is bound here lazily via {@link IAcpConnection.bind} + * rather than seeded at composition time. The ACP-backed `IHostFileSystem` + * (Session scope) reads it on first use through {@link IAcpConnection.get}. + */ + +import { + createDecorator, + InstantiationType, + LifecycleScope, + registerScopedService, + type ServiceIdentifier, +} from '@moonshot-ai/agent-core-v2'; + +/** + * Narrow ACP-client file surface the ACP-backed `IHostFileSystem` needs. + * + * Structurally compatible with `@agentclientprotocol/sdk`'s + * `AgentSideConnection` (`readTextFile` / `writeTextFile`), so the host can + * `bind(conn)` directly without an adapter. + */ +export interface IAcpFsClient { + readTextFile(params: { + readonly sessionId: string; + readonly path: string; + }): Promise<{ readonly content: string }>; + writeTextFile(params: { + readonly sessionId: string; + readonly path: string; + readonly content: string; + }): Promise; +} + +export interface IAcpConnection { + readonly _serviceBrand: undefined; + /** Bind the process-wide ACP client connection. Later binds replace earlier ones. */ + bind(client: IAcpFsClient): void; + /** The bound ACP client connection. Throws if called before {@link bind}. */ + get(): IAcpFsClient; + /** Whether a client connection has been bound. */ + readonly bound: boolean; +} + +export const IAcpConnection: ServiceIdentifier = + createDecorator('acpConnection'); + +export class AcpConnection implements IAcpConnection { + declare readonly _serviceBrand: undefined; + + private client: IAcpFsClient | undefined; + + bind(client: IAcpFsClient): void { + this.client = client; + } + + get(): IAcpFsClient { + if (this.client === undefined) { + throw new Error( + 'IAcpConnection.get() called before bind() — acp-server must bind the ACP client connection before any session performs file IO.', + ); + } + return this.client; + } + + get bound(): boolean { + return this.client !== undefined; + } +} + +registerScopedService( + LifecycleScope.App, + IAcpConnection, + AcpConnection, + InstantiationType.Delayed, + 'acp', +); diff --git a/packages/acp-server/src/acp-fs/acpFsService.ts b/packages/acp-server/src/acp-fs/acpFsService.ts new file mode 100644 index 0000000000..8e294e78a3 --- /dev/null +++ b/packages/acp-server/src/acp-fs/acpFsService.ts @@ -0,0 +1,118 @@ +/** + * ACP-backed `IHostFileSystem` — Session-scoped `IHostFileSystem` that routes + * text file reads/writes through the ACP client (`fs.readTextFile` / + * `fs.writeTextFile`, keyed by this session's `sessionId`) and delegates every + * other operation (binary IO, stat/readdir/mkdir/remove, exclusive create) to a + * node-local inner backend. + * + * Registered at Session scope so it shadows the App-scope node-local + * `IHostFileSystem` for Session- and Agent-scope consumers (the os file tools), + * while App-scope consumers (persistence, skill loading, workspace registry) + * keep using the real local disk. + * + * Lives in `acp-server` (not `agent-core-v2`) because it is ACP-specific: the + * engine stays agnostic of the ACP client, and only this host binds the client + * connection. + */ + +import { + HostFileSystem, + type HostDirEntry, + type HostFileStat, + IHostFileSystem, + InstantiationType, + ISessionContext, + LifecycleScope, + registerScopedService, +} from '@moonshot-ai/agent-core-v2'; + +import { IAcpConnection } from './acpConnection'; + +/** Options type lifted from `IHostFileSystem.readText` / `readLines`. */ +type ReadTextOptions = NonNullable[1]>; + +function* splitLinesKeepingTerminator(text: string): Generator { + if (text.length === 0) return; + let start = 0; + for (let i = 0; i < text.length; i++) { + if (text.codePointAt(i) === 0x0a) { + yield text.slice(start, i + 1); + start = i + 1; + } + } + if (start < text.length) { + yield text.slice(start); + } +} + +export class AcpHostFileSystem implements IHostFileSystem { + declare readonly _serviceBrand: undefined; + + /** + * Local inner backend for every operation ACP `fs` does not model (binary IO, + * directory ops, stat, exclusive create). `HostFileSystem` is stateless (no + * DI dependencies), so constructing it directly is safe. + */ + private readonly inner = new HostFileSystem(); + + constructor( + @ISessionContext private readonly ctx: ISessionContext, + @IAcpConnection private readonly connection: IAcpConnection, + ) {} + + async readText(path: string, _options?: ReadTextOptions): Promise { + // ACP `fs.readTextFile` returns already-decoded UTF-8 text, so the + // `encoding`/`errors` decode options are a no-op here. + const { content } = await this.connection + .get() + .readTextFile({ sessionId: this.ctx.sessionId, path }); + return content; + } + + async writeText(path: string, data: string): Promise { + await this.connection + .get() + .writeTextFile({ sessionId: this.ctx.sessionId, path, content: data }); + } + + async *readLines(path: string, options?: ReadTextOptions): AsyncGenerator { + const text = await this.readText(path, options); + yield* splitLinesKeepingTerminator(text); + } + + readBytes(path: string, n?: number): Promise { + return this.inner.readBytes(path, n); + } + + writeBytes(path: string, data: Uint8Array): Promise { + return this.inner.writeBytes(path, data); + } + + createExclusive(path: string, data: Uint8Array): Promise { + return this.inner.createExclusive(path, data); + } + + stat(path: string): Promise { + return this.inner.stat(path); + } + + readdir(path: string): Promise { + return this.inner.readdir(path); + } + + mkdir(path: string, options?: { readonly recursive?: boolean }): Promise { + return this.inner.mkdir(path, options); + } + + remove(path: string): Promise { + return this.inner.remove(path); + } +} + +registerScopedService( + LifecycleScope.Session, + IHostFileSystem, + AcpHostFileSystem, + InstantiationType.Delayed, + 'acp', +); diff --git a/packages/acp-server/src/acp-fs/index.ts b/packages/acp-server/src/acp-fs/index.ts new file mode 100644 index 0000000000..579e1ba3c8 --- /dev/null +++ b/packages/acp-server/src/acp-fs/index.ts @@ -0,0 +1,14 @@ +/** + * `acp-fs` barrel — registers the ACP-backed `IHostFileSystem` (Session scope) + * and its App-scope connection holder. + * + * Imported for its module side effects by `start.ts` before any session is + * created, so the `IHostFileSystem` shadow is in place when the first session + * scope is built. + */ + +import './acpConnection'; +import './acpFsService'; + +export { AcpConnection, IAcpConnection, type IAcpFsClient } from './acpConnection'; +export { AcpHostFileSystem } from './acpFsService'; diff --git a/packages/acp-server/src/approval.ts b/packages/acp-server/src/approval.ts new file mode 100644 index 0000000000..bb1bb788c3 --- /dev/null +++ b/packages/acp-server/src/approval.ts @@ -0,0 +1,242 @@ +/** + * ACP `session/request_permission` ↔ agent-core-v2 approval mappers. + * + * Pure functions that translate an `ApprovalRequest` (raised by the engine's + * `AgentPermissionGate` and surfaced through the `interaction` kernel) into the + * ACP `PermissionOption[]` + `ToolCallUpdate` surfaced to the client, and the + * client's `RequestPermissionResponse` back into an `ApprovalResponse`. Kept + * free of IO so the mappings stay unit-testable without a live connection. + */ + +import type { + PermissionOption, + RequestPermissionResponse, + ToolCallContent, + ToolCallUpdate, +} from '@agentclientprotocol/sdk'; +import type { + SessionApprovalRequest as ApprovalRequest, + SessionApprovalResponse as ApprovalResponse, +} from '@moonshot-ai/agent-core-v2'; + +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 + * host 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'; + +/** + * `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` + * 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. + * - `plan_revise` / `plan_reject_and_exit` — the two reject-side exits. + */ +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 render options top-to-bottom, so + * allow-once is the primary action, allow-always the secondary, and reject the + * terminal/dangerous action that should be hardest to click by accident. + */ +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. + * + * When the request's display block carries `kind: 'plan_review'`, the options + * expand to one `allow_once` per `display.options[i]` (A / B / C) — or a + * single `plan_approve` fallback when the policy did not supply ≥ 2 discrete + * options — plus the two `reject_once` exits `Revise` and `Reject and Exit`. + * + * For every other display kind, returns the canonical 3-option list. + */ +export function approvalRequestToPermissionOptions( + req: ApprovalRequest, +): readonly PermissionOption[] { + if (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 an engine + * {@link ApprovalResponse}. + * + * Decision mapping (canonical / non-plan_review path): + * - `cancelled` outcome → `decision: 'cancelled'`. + * - `approve_once` → `decision: 'approved'` (no scope, one-shot). + * - `approve_always` → `decision: 'approved'` with `scope: 'session'` so the + * engine installs a session-runtime allow rule for subsequent invocations. + * - `reject` → `decision: 'rejected'`. + * - Any other optionId → defensive `rejected` (rejecting is strictly safer + * than approving for an unknown id). + * + * For `plan_review`, the `plan_opt_` / `plan_approve` / `plan_revise` / + * `plan_reject_and_exit` optionIds map directly to the discriminator, and the + * matched option's label is attached as `selectedLabel` so the downstream + * policy can drive its branch off a stable string. + */ +export function permissionResponseToApprovalResponse( + req: ApprovalRequest, + 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. + return { decision: 'rejected' }; + } +} + +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' }; + } + 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 `req.turnId` is `undefined` the raw id is used as a + * defensive fallback (in practice approvals always fire after + * `tool.call.started`, so the fallback is effectively unreachable). + * + * Content shape: + * - If `req.display` produces a diff-bearing entry, prepend it so the diff / + * plan card is the headline of the approval prompt. + * - Always append a human-readable action summary + * (`"Requesting approval to ${req.action}"`) so the prompt is never empty. + */ +export function buildPermissionToolCallUpdate(req: ApprovalRequest): ToolCallUpdate { + const rawId = req.toolCallId ?? req.toolName; + const toolCallId = req.turnId !== undefined ? acpToolCallId(req.turnId, rawId) : rawId; + const content: ToolCallContent[] = []; + const headlineEntry = displayBlockToAcpContent(req.display); + if (headlineEntry !== null) { + content.push(headlineEntry); + } + 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`, the optionId + * is unknown, or it is in the `plan_*` namespace (the plan_review branch + * attaches `selectedLabel` inside the mapper already). + * + * Pure: returns a fresh object (never mutates the input). + */ +export function attachSelectedLabel( + response: RequestPermissionResponse, + approval: ApprovalResponse, + options: readonly PermissionOption[], +): ApprovalResponse { + const outcome = response.outcome; + if (outcome.outcome !== 'selected') return approval; + 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-server/src/auth-methods.ts b/packages/acp-server/src/auth-methods.ts new file mode 100644 index 0000000000..7b537995c9 --- /dev/null +++ b/packages/acp-server/src/auth-methods.ts @@ -0,0 +1,62 @@ +// 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 ` --login`, handled by the +// 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. +// +// Most clients hit path 1; path 2 is required for Zed today because the +// first-class handler is beta-gated. Mirrors `packages/acp-adapter` so the +// v1 and v2 ACP hosts advertise identical login surfaces. + +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 login subprocess (e.g. + * `{ KIMI_CODE_HOME: '/tmp/sandbox' }` so the token lands under the same + * data root the server reads from). + * - `legacyCommand`: absolute path of the agent binary, used to populate + * `_meta['terminal-auth'].command` so legacy clients can spawn it directly. + * 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.', + 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', + command: opts.legacyCommand, + args: ['login'], + env: { ...env }, + }, + }; + } + return method; +} + +/** + * Default `terminal-auth` advertisement with no env propagation and no legacy + * `_meta` fallback. + */ +export const TERMINAL_AUTH_METHOD: AuthMethod = buildTerminalAuthMethod(); diff --git a/packages/acp-server/src/builtin-commands.ts b/packages/acp-server/src/builtin-commands.ts new file mode 100644 index 0000000000..452aa35600 --- /dev/null +++ b/packages/acp-server/src/builtin-commands.ts @@ -0,0 +1,44 @@ +import type { AvailableCommand } from '@agentclientprotocol/sdk'; + +/** + * ACP-owned built-in slash commands. These are advertised in the + * `available_commands_update` and (in a later phase) executed locally by the + * host rather than forwarded to the model. + */ +export const ACP_BUILTIN_SLASH_COMMANDS = [ + { + name: 'compact', + description: 'Compact the conversation context', + input: { hint: '' }, + }, + { + name: 'status', + description: 'Show current session status', + }, + { + name: 'usage', + description: 'Show session token usage', + }, + { + name: 'mcp', + description: 'Show MCP server status', + }, + { + name: 'tasks', + description: 'List background tasks', + }, + { + name: 'help', + description: 'Show available ACP commands', + }, +] as const satisfies readonly AvailableCommand[]; + +export type AcpBuiltinSlashCommandName = (typeof ACP_BUILTIN_SLASH_COMMANDS)[number]['name']; + +export const ACP_BUILTIN_SLASH_COMMAND_NAMES = new Set( + ACP_BUILTIN_SLASH_COMMANDS.map((command) => command.name), +); + +export function isAcpBuiltinSlashCommand(name: string): name is AcpBuiltinSlashCommandName { + return ACP_BUILTIN_SLASH_COMMAND_NAMES.has(name); +} diff --git a/packages/acp-server/src/config-options.ts b/packages/acp-server/src/config-options.ts new file mode 100644 index 0000000000..9382a79427 --- /dev/null +++ b/packages/acp-server/src/config-options.ts @@ -0,0 +1,128 @@ +/** + * Build the unified `SessionConfigOption[]` surface advertised on + * `session/new` + `session/load` + `session/resume` and refreshed by + * `config_option_update`. + * + * The surface has up to three options: + * - `id: 'model'` (`type: 'select'`, `category: 'model'`) — one row per + * {@link AcpModelEntry}. Thinking is an orthogonal axis (separate toggle). + * - `id: 'thinking'` (`type: 'select'`, `category: 'thought_level'`) — + * appears ONLY when the currently-selected model's catalog row has + * `thinkingSupported === true`; otherwise omitted so the client doesn't + * render a non-actionable toggle. Encoded as a 2-entry select + * (`off` / `on`) for Zed compatibility (its chip strip only renders + * `select` options; the spec's `boolean` arm shows as "Unknown"). Effort + * granularity stays hidden behind the host — the runtime uses a single + * non-`'off'` level (the model's default effort). + * - `id: 'mode'` (`type: 'select'`, `category: 'mode'`) — the locked + * 4-mode taxonomy ({@link ACP_MODES}). + */ + +import type { SessionConfigOption, SessionConfigSelectOption } from '@agentclientprotocol/sdk'; + +import { ACP_MODES, type AcpModeId } from './modes'; +import type { AcpModelEntry } from './model-catalog'; + +/** + * Project the catalog into the `SessionConfigOption` `model` arm. One option + * row per catalog entry. `currentValue` is the bare model id. + */ +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. `alwaysThinking` models collapse the select to a + * single locked `on` entry (the state stays visible but there is no off option + * to pick — ACP has no "disabled entry" concept). + */ +export function buildThinkingOption(enabled: boolean, alwaysThinking = false): SessionConfigOption { + if (alwaysThinking) { + return { + type: 'select', + id: 'thinking', + name: 'Thinking', + category: 'thought_level', + currentValue: 'on', + options: [{ value: 'on', name: 'Thinking On' }], + }; + } + 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). + */ +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 `SessionConfigOption[]` surface — + * `[modelOption, …(thinkingOption?), modeOption]`. Order is part of the + * contract: ACP clients render options top-to-bottom, model on top of mode. + * + * The thinking toggle only appears when the currently-selected base model is + * `thinkingSupported`; otherwise the snapshot is just `[modelOption, modeOption]`. + * + * Returns a mutable `SessionConfigOption[]` (rather than `readonly`) so the + * value is assignable to the SDK's `NewSessionResponse.configOptions` field, + * which is typed `Array`. + */ +export function buildSessionConfigOptions( + models: readonly AcpModelEntry[], + currentBaseModelId: string, + currentThinkingEnabled: boolean, + currentModeId: AcpModeId, +): SessionConfigOption[] { + const currentModelEntry = models.find((m) => m.id === currentBaseModelId); + const showThinking = currentModelEntry?.thinkingSupported === true; + const alwaysThinking = currentModelEntry?.alwaysThinking === true; + const out: SessionConfigOption[] = [buildModelOption(models, currentBaseModelId)]; + if (showThinking) { + // Always-thinking models render locked-on regardless of the session's + // recorded toggle state — the runtime clamps the same way. + out.push(buildThinkingOption(alwaysThinking || currentThinkingEnabled, alwaysThinking)); + } + out.push(buildModeOption(currentModeId)); + return out; +} diff --git a/packages/acp-server/src/convert.ts b/packages/acp-server/src/convert.ts new file mode 100644 index 0000000000..edba39455f --- /dev/null +++ b/packages/acp-server/src/convert.ts @@ -0,0 +1,213 @@ +import type { ContentBlock, ToolCallContent } from '@agentclientprotocol/sdk'; +import { type ContentPart } from '@moonshot-ai/agent-core-v2'; +import type { ToolInputDisplay, ToolResultEvent } from '@moonshot-ai/protocol'; + +import { log } from './log'; +import { isHideOutputMarker } from './marker'; + +/** + * Convert an array of ACP {@link ContentBlock}s into agent-core-v2 + * {@link ContentPart}s suitable for a user `ContextMessage`'s `content`. + * + * Image blocks are passed through as `image_url` data URLs (input-stage + * compression is a later phase). Audio and blob embedded resources are dropped + * with a warning (ACP `promptCapabilities` currently advertise audio as + * unsupported). + */ +export function acpBlocksToContentParts(blocks: readonly ContentBlock[]): readonly ContentPart[] { + const out: ContentPart[] = []; + 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 fileRef = fileLinkToTextRef(block.uri); + if (fileRef !== null) { + out.push({ type: 'text', text: fileRef }); + continue; + } + 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 — 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 + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') + .replaceAll('"', '"') + .replaceAll("'", '''); +} + +function fileLinkToTextRef(uri: string): string | null { + let url: URL; + try { + url = new URL(uri); + } catch { + return null; + } + if (url.protocol !== 'file:') return null; + + let path: string; + try { + path = decodeURIComponent(url.pathname); + } catch { + return null; + } + + // `file://server/share/a.ts` is the URI form of a Windows UNC path + // (`\\server\share\a.ts`). `URL.pathname` only carries `/share/a.ts`; the + // host is part of the file location, so keep it in the projected text ref. + // `file://localhost/...` is still treated as local. Host is lower-cased so + // `file://Server/...` and `file://server/...` collapse to one ref. + const host = url.hostname.toLowerCase(); + const isUncHost = host !== '' && host !== 'localhost'; + + // Drive-letter normalization is local-only: a UNC URI never legitimately + // carries `/C:/...` in its path, so we leave such inputs untouched rather + // than stripping a leading slash that would alter the UNC payload. + if (!isUncHost && /^\/[A-Za-z]:/.test(path)) path = path.slice(1); + + if (isUncHost) { + path = `//${host}${path.startsWith('/') ? path : `/${path}`}`; + } + + const range = parseLineRange(url.hash) ?? parseLineRange(url.search); + return range !== null ? `${path}:${range}` : path; +} + +function parseLineRange(suffix: string): string | null { + if (!suffix) return null; + const body = suffix.replace(/^[#?]/, ''); + const match = /^(?:lines?=|L)(\d+)(?:[-:]L?(\d+))?/i.exec(body); + if (!match) return null; + return match[2] !== undefined ? `${match[1]}-${match[2]}` : match[1]!; +} + +/** + * Project a {@link ToolInputDisplay} block into an ACP {@link ToolCallContent} + * entry for the tool-call card. Diff/file_io blocks become inline diffs; + * plan_review becomes a text content entry; everything else yields `null` + * (the caller drops it). + */ +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. Empty plan → `null` + * (caller drops the entry). When `block.path` is set, prefix with the on-disk + * location so the client can show it alongside the markdown body. + */ +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. + * + * A non-empty string is passed through as a text block; objects/arrays are + * JSON-stringified (best-effort — falls back to a placeholder 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; + // Array output containing the HideOutputMarker tells the adapter to suppress + // this tool's textual content entirely (e.g. terminal output routed through + // its own reverse-RPC channel). 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 { + text = typeof out === 'object' && out !== null ? '[object]' : String(out); + } + if (!text) return []; + return [{ type: 'content', content: { type: 'text', text } }]; +} diff --git a/packages/acp-server/src/events-map.ts b/packages/acp-server/src/events-map.ts new file mode 100644 index 0000000000..9a02d4db44 --- /dev/null +++ b/packages/acp-server/src/events-map.ts @@ -0,0 +1,407 @@ +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/protocol'; + +import { displayBlockToAcpContent, toolResultToAcpContent } from './convert'; +import type { AcpStopReason } from './types'; + +/** + * Build an ACP `session/update` notification with an + * `agent_message_chunk` payload from an `assistant.delta` event. + */ +export function assistantDeltaToSessionUpdate( + sessionId: string, + event: AssistantDeltaEvent, +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: event.delta }, + }, + }; +} + +/** + * Map a {@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 the out-of-band `error` logged by the + * caller). ACP's `StopReason` has no dedicated `failed` variant in this + * protocol version, and the spec discourages signaling errors through + * `stopReason` (errors belong on the JSON-RPC error channel). + * `failed` + `provider.filtered` → `refusal`: the provider's safety policy + * blocked the response. + * `blocked` → `refusal`: a prompt hook blocked the turn before the model + * ran. ACP has no separate hook-blocked terminal state, so reuse the + * refusal channel. + */ +export function turnEndReasonToStopReason( + reason: TurnEndReason, + error?: { readonly code: string }, +): AcpStopReason { + switch (reason) { + case 'completed': + return 'end_turn'; + case 'cancelled': + return 'cancelled'; + case 'failed': + if (error?.code === 'provider.filtered') return 'refusal'; + return 'end_turn'; + case 'blocked': + return 'refusal'; + } +} + +/** + * 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 raw + * `toolCallId` remains the in-process accumulator key — only the ACP wire + * id is prefixed. + */ +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. + */ +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. Never throws — a streaming + * push must never crash the prompt loop. + */ +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 a `tool.call.started` event. + */ +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, prepend an inline diff entry + // so the client can render it alongside the textual args preview. + 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 and emits cumulative REPLACE + * content. + */ +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`. + * + * agent-core-v2 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). Lazy-creating the wire + * tool_call from the first delta gives subsequent deltas a legitimate parent + * to update, so the client never sees an update before its create. + */ +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} already emitted a `tool_call` for + * this `toolCallId` from a streaming delta — we cannot send a second CREATE, + * so the canonical metadata is delivered as an update instead. + */ +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 a `tool.progress` event to an ACP `tool_call_update`. Only + * `update.kind === 'status'` with non-empty `text` produces a notification + * (refreshes the tool card title); everything else returns `null`. + */ +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. + */ +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. `status` flips to `completed` (success) or + * `failed` (`event.isError === true`); content replaces the streaming args + * preview with the final tool output; `rawOutput` preserves the raw output. + */ +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 a TodoList display block into an ACP `plan` session update. + * `done` rewrites to `completed`; `priority` defaults to `'medium'`. Returns + * `null` for an empty items array. + */ +export function todoListToSessionUpdate( + sessionId: string, + turnId: number, + items: ReadonlyArray<{ title: string; status: string }>, +): SessionNotification | null { + 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. + */ +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. + */ +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 after the model + * / mode / thinking pickers change so clients repaint the dropdown's selected + * indicator. + */ +export function configOptionUpdateNotification( + sessionId: string, + configOptions: readonly SessionConfigOption[], +): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'config_option_update', + configOptions: [...configOptions], + }, + }; +} diff --git a/packages/acp-server/src/index.ts b/packages/acp-server/src/index.ts new file mode 100644 index 0000000000..3466d6d23d --- /dev/null +++ b/packages/acp-server/src/index.ts @@ -0,0 +1,79 @@ +export type { Implementation } from '@agentclientprotocol/sdk'; + +export { AcpServer } from './server'; +export type { AcpServerOptions } from './server'; +export { AcpSession } from './session'; +export { runAcpServer, runAcpServerWithStream } from './start'; +export type { RunAcpServerOptions, RunningAcpServer } from './start'; + +export { + acpToolCallId, + assistantDeltaToSessionUpdate, + availableCommandsUpdateNotification, + configOptionUpdateNotification, + inferToolKind, + planFromDisplayBlock, + stringifyArgs, + thinkingDeltaToSessionUpdate, + todoListToSessionUpdate, + toolCallDeltaToSessionUpdate, + toolCallLazyCreateToSessionUpdate, + toolCallStartedUpgradeToSessionUpdate, + toolCallStartToSessionUpdate, + toolProgressToSessionUpdate, + toolResultToSessionUpdate, + turnEndReasonToStopReason, +} from './events-map'; +export { + acpBlocksToContentParts, + displayBlockToAcpContent, + toolResultToAcpContent, +} from './convert'; +export { + buildModeOption, + buildModelOption, + buildSessionConfigOptions, + buildThinkingOption, +} from './config-options'; +export { + deriveAlwaysThinking, + deriveDefaultThinkingEffort, + deriveThinkingSupported, + projectModelCatalog, +} from './model-catalog'; +export type { AcpModelEntry } from './model-catalog'; +export { + ACP_MODES, + acpModeToToggles, + DEFAULT_MODE_ID, + isAcpModeId, +} from './modes'; +export type { AcpModeId, AcpModeToggles } from './modes'; +export type { AcpStopReason, AcpToolCallStatus, AcpToolKind } from './types'; +export { HideOutputMarker, isHideOutputMarker } from './marker'; +export { + ACP_BUILTIN_SLASH_COMMAND_NAMES, + ACP_BUILTIN_SLASH_COMMANDS, + isAcpBuiltinSlashCommand, +} from './builtin-commands'; +export type { AcpBuiltinSlashCommandName } from './builtin-commands'; +export { detectSlashIntent, parseSlashInput, resolveSkillCommand } from './slash'; +export type { ParsedSlashInput, SlashIntent } from './slash'; +export { AcpInteractionBridge } from './interaction-bridge'; +export { + APPROVE_ALWAYS_OPTION_ID, + APPROVE_ONCE_OPTION_ID, + approvalRequestToPermissionOptions, + attachSelectedLabel, + buildPermissionToolCallUpdate, + permissionResponseToApprovalResponse, + PLAN_APPROVE_OPTION_ID, + PLAN_REJECT_AND_EXIT_OPTION_ID, + PLAN_REVISE_OPTION_ID, + REJECT_OPTION_ID, +} from './approval'; +export { + outcomeToQuestionAnswer, + questionItemToPermissionOptions, +} from './question'; +export { projectHistoryToSessionUpdates } from './replay'; diff --git a/packages/acp-server/src/interaction-bridge.ts b/packages/acp-server/src/interaction-bridge.ts new file mode 100644 index 0000000000..512833274f --- /dev/null +++ b/packages/acp-server/src/interaction-bridge.ts @@ -0,0 +1,189 @@ +/** + * ACP interaction bridge — forwards the engine's blocking human-in-the-loop + * requests (approval + ask-user) to the ACP client via + * `session/request_permission`, and relays the client's decision back to the + * `interaction` kernel. + * + * The engine's `AgentPermissionGate` and `AskUserQuestionTool` park requests on + * the Session-scoped `ISessionInteractionService` and block on their response. + * This bridge is a pure edge observer: it subscribes to + * `ISessionInteractionService.onDidChangePending`, and for every newly-pending + * `approval` / `question` interaction it calls `conn.requestPermission(...)`, + * maps the response through the pure mappers in `./approval` / `./question`, + * and settles the parked request via `interaction.respond(id, ...)`. The + * default `SessionApprovalService` / `SessionQuestionService` stay in place — + * the bridge never replaces them. + */ + +import type { AgentSideConnection } from '@agentclientprotocol/sdk'; +import { + type Interaction, + type ISessionScopeHandle, + ISessionInteractionService, + type QuestionAnswers, + type QuestionRequest, + type SessionApprovalRequest as ApprovalRequest, + type SessionApprovalResponse as ApprovalResponse, +} from '@moonshot-ai/agent-core-v2'; + +import { + approvalRequestToPermissionOptions, + attachSelectedLabel, + buildPermissionToolCallUpdate, + permissionResponseToApprovalResponse, +} from './approval'; +import { acpToolCallId } from './events-map'; +import { log } from './log'; +import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from './question'; + +/** Disposable subscription handle returned by the event-bus `Event`. */ +interface Disposable { + dispose(): void; +} + +export class AcpInteractionBridge { + /** Ids the bridge has already begun handling — guards against re-entry. */ + private readonly inFlight = new Set(); + private readonly subscription: Disposable; + private readonly interaction: ISessionInteractionService; + private disposed = false; + + constructor( + private readonly conn: AgentSideConnection, + sessionHandle: ISessionScopeHandle, + private readonly sessionId: string, + ) { + this.interaction = sessionHandle.accessor.get(ISessionInteractionService); + this.subscription = this.interaction.onDidChangePending(() => this.onPendingChanged()); + // Catch anything that was parked before the subscription attached. + this.onPendingChanged(); + } + + dispose(): void { + if (this.disposed) return; + this.disposed = true; + this.subscription.dispose(); + this.inFlight.clear(); + } + + private onPendingChanged(): void { + if (this.disposed) return; + for (const interaction of this.interaction.listPending()) { + if (this.inFlight.has(interaction.id)) continue; + if (interaction.kind !== 'approval' && interaction.kind !== 'question') continue; + this.inFlight.add(interaction.id); + void this.dispatch(interaction); + } + } + + private async dispatch(interaction: Interaction): Promise { + try { + if (interaction.kind === 'approval') { + const response = await this.handleApproval(interaction.payload as ApprovalRequest); + this.interaction.respond(interaction.id, response); + return; + } + if (interaction.kind === 'question') { + const result = await this.handleQuestion(interaction.payload as QuestionRequest); + this.interaction.respond(interaction.id, result); + } + } catch (error) { + // `respond` itself never throws for a still-pending id, and the handlers + // already swallow RPC failures into a safe response — so reaching here + // means something unexpected broke. Log and settle with the safest + // default so the gate/tool does not park forever. + log.warn('acp: interaction bridge dispatch failed', { + sessionId: this.sessionId, + interactionId: interaction.id, + kind: interaction.kind, + error: error instanceof Error ? error.message : String(error), + }); + if (interaction.kind === 'approval') { + this.interaction.respond(interaction.id, { decision: 'rejected' } satisfies ApprovalResponse); + } else { + this.interaction.respond(interaction.id, null); + } + } + } + + /** + * Bridge an engine {@link ApprovalRequest} to the ACP client and back. Any + * RPC failure resolves with `decision: 'rejected'` — rejecting on failure is + * strictly safer than approving when the client cannot confirm intent. + */ + private async handleApproval(req: ApprovalRequest): Promise { + const toolCall = buildPermissionToolCallUpdate(req); + const options = approvalRequestToPermissionOptions(req); + try { + const response = await this.conn.requestPermission({ + sessionId: this.sessionId, + options: [...options], + toolCall, + }); + return attachSelectedLabel( + response, + permissionResponseToApprovalResponse(req, response), + options, + ); + } catch (error) { + log.warn('acp: requestPermission failed; rejecting', { + sessionId: this.sessionId, + toolCallId: req.toolCallId, + toolName: req.toolName, + error: error instanceof Error ? error.message : String(error), + }); + return { decision: 'rejected' }; + } + } + + /** + * Bridge an engine {@link QuestionRequest} (the AskUserQuestion tool) through + * the same `session/request_permission` surface approvals use. + * + * Degradation rules: + * - `questions.length > 1` → only the first question is asked (logged). + * - `multiSelect === true` → still asked as single-select; the engine's + * ask-user tool tolerates a single-key answer for a multi-select prompt. + * + * Any RPC failure resolves with `null` so the tool takes its canonical + * "user dismissed" branch — strictly safer than fabricating an answer. + */ + private async handleQuestion(req: QuestionRequest): Promise { + const questions = req.questions; + if (questions.length === 0) { + log.warn('acp: handleQuestion received empty questions array', { + sessionId: this.sessionId, + }); + return null; + } + if (questions.length > 1) { + log.warn('acp: handleQuestion degrading to first question only', { + sessionId: this.sessionId, + dropped: questions.length - 1, + }); + } + const q = questions[0]!; + const options = questionItemToPermissionOptions(q, 0); + const rawToolCallId = req.toolCallId ?? 'ask-user'; + const toolCallId = req.turnId !== undefined ? acpToolCallId(req.turnId, rawToolCallId) : rawToolCallId; + try { + const response = await this.conn.requestPermission({ + sessionId: this.sessionId, + options: [...options], + toolCall: { + toolCallId, + title: 'AskUserQuestion', + content: [{ type: 'content', content: { type: 'text', text: q.question } }], + }, + }); + return outcomeToQuestionAnswer(q, response); + } catch (error) { + log.warn('acp: requestPermission (question) failed; dismissing', { + sessionId: this.sessionId, + toolCallId: req.toolCallId, + error: error instanceof Error ? error.message : String(error), + }); + return null; + } + } +} diff --git a/packages/acp-server/src/log.ts b/packages/acp-server/src/log.ts new file mode 100644 index 0000000000..731ff23a72 --- /dev/null +++ b/packages/acp-server/src/log.ts @@ -0,0 +1,25 @@ +/** + * acp-server diagnostic logger — writes structured lines to **stderr**. + * + * Stdout is the ACP JSON-RPC channel and must stay clean, so adapter + * diagnostics go to stderr. Kept tiny (and dependency-free) on purpose; the + * shape mirrors the `log.warn(msg, ctx)` call sites used throughout the + * adapter. + */ + +function write(level: string, msg: string, ctx?: Record): void { + const line = JSON.stringify({ level, msg, ...ctx }); + process.stderr.write(`${line}\n`); +} + +export const log = { + warn(msg: string, ctx?: Record): void { + write('warn', msg, ctx); + }, + error(msg: string, ctx?: Record): void { + write('error', msg, ctx); + }, + info(msg: string, ctx?: Record): void { + write('info', msg, ctx); + }, +}; diff --git a/packages/acp-server/src/marker.ts b/packages/acp-server/src/marker.ts new file mode 100644 index 0000000000..9486791f06 --- /dev/null +++ b/packages/acp-server/src/marker.ts @@ -0,0 +1,37 @@ +/** + * Sentinel object that a tool can attach to its result `output` to + * signal the ACP adapter to suppress this tool's textual output. + * + * Motivation: a tool that emits its output via a dedicated ACP reverse-RPC + * channel (e.g. `terminal/*`) must NOT also relay the textual stdout / stderr + * through `tool_call_update` content or the client UI would render the same + * bytes twice (once in the terminal pane, once in the tool card). The tool + * implementation sets `output: [HideOutputMarker, ...]` (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, 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-server/src/model-catalog.ts b/packages/acp-server/src/model-catalog.ts new file mode 100644 index 0000000000..7c32b15cd1 --- /dev/null +++ b/packages/acp-server/src/model-catalog.ts @@ -0,0 +1,92 @@ +/** + * ACP model catalog — projects agent-core-v2's model configuration registry + * (`IModelService`) into a flat list of selectable models for the ACP + * `configOptions` picker. + * + * ACP-specific heuristics (thinking-capability derivation, the toggleable-models + * allow-list) stay scoped to this host. Iteration order mirrors `models` + * insertion order (Node's `Object.entries` over plain object keys is + * insertion-ordered for string keys). + * + * `thinkingSupported` is true if any of: + * 1. the model's declared `capabilities` contains `'thinking'`/`'always_thinking'`, or + * 2. the wire-facing model name matches `/thinking|reason/i`, or + * 3. the wire-facing model name is on the {@link TOGGLEABLE_THINKING_MODELS} + * allow-list. + */ + +import type { ModelConfig } from '@moonshot-ai/agent-core-v2'; + +/** + * One catalog row per configured model, suitable for an ACP picker. + */ +export interface AcpModelEntry { + readonly id: string; + readonly name: string; + readonly description?: string | undefined; + readonly thinkingSupported: boolean; + /** Declared 'always_thinking' capability — thinking cannot be turned off. */ + readonly alwaysThinking?: boolean; + /** + * The thinking effort to send when the binary ACP toggle flips on: the + * model's declared `defaultEffort`, else the middle `supportEfforts` entry, + * else `'on'` for boolean models. + */ + readonly defaultThinkingEffort: string; +} + +/** + * Models that support thinking by toggle (not by name match or capability + * declaration). ACP-picker-specific UX. + */ +const TOGGLEABLE_THINKING_MODELS = new Set(['kimi-for-coding', 'kimi-code']); + +/** Wire-facing model name used for heuristics/display. */ +function modelName(config: ModelConfig): string { + return config.model ?? config.name ?? ''; +} + +export function deriveThinkingSupported(config: ModelConfig): boolean { + const capabilities = config.capabilities ?? []; + if (capabilities.includes('thinking') || capabilities.includes('always_thinking')) return true; + const lower = modelName(config).toLowerCase(); + if (lower.includes('thinking') || lower.includes('reason')) return true; + if (TOGGLEABLE_THINKING_MODELS.has(modelName(config))) return true; + return false; +} + +/** + * Whether the model declares the 'always_thinking' capability — thinking cannot + * be disabled, so the ACP toggle must lock to on. Capability-only by design. + */ +export function deriveAlwaysThinking(config: ModelConfig): boolean { + return (config.capabilities ?? []).includes('always_thinking'); +} + +/** + * The effort a boolean "thinking on" toggle maps to for this model: declared + * `defaultEffort`, else the middle `supportEfforts` entry, else `'on'`. + */ +export function deriveDefaultThinkingEffort(config: ModelConfig): string { + const efforts = config.supportEfforts; + if (efforts !== undefined && efforts.length > 0) { + return config.defaultEffort ?? efforts[Math.floor(efforts.length / 2)]!; + } + return 'on'; +} + +/** + * Project a model configuration record into a flat ACP catalog. Returns an + * empty array when no models are configured. + */ +export function projectModelCatalog( + models: Readonly>, +): readonly AcpModelEntry[] { + return Object.entries(models).map(([id, config]) => ({ + id, + name: config.displayName ?? modelName(config) ?? id, + thinkingSupported: deriveThinkingSupported(config), + alwaysThinking: deriveAlwaysThinking(config), + defaultThinkingEffort: deriveDefaultThinkingEffort(config), + })); +} diff --git a/packages/acp-server/src/modes.ts b/packages/acp-server/src/modes.ts new file mode 100644 index 0000000000..a0ecdb7cd7 --- /dev/null +++ b/packages/acp-server/src/modes.ts @@ -0,0 +1,87 @@ +/** + * ACP session-mode taxonomy. + * + * The 4 modes (`default`, `plan`, `auto`, `yolo`) are the locked decision. + * Every `session/new` and `session/load` response advertises {@link ACP_MODES} + * as the mode picker plus {@link DEFAULT_MODE_ID} as `currentModeId`, so ACP + * clients render the dropdown from a single canonical source. + * + * `session/set_mode` and the `mode` arm of `session/set_config_option` consume + * the same source of truth: {@link isAcpModeId} narrows the wire string, and + * {@link acpModeToToggles} resolves the two underlying engine toggles (plan + * mode + permission mode) each ACP mode maps to. + */ + +import type { SessionMode } from '@agentclientprotocol/sdk'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2'; + +/** + * Canonical 4-mode taxonomy. Order matters: the array is rendered as-is by the + * client, so `default` must appear first and `yolo` last. + */ +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 host. */ +export type AcpModeId = 'default' | 'plan' | 'auto' | 'yolo'; + +/** Narrow an unknown wire string to {@link AcpModeId}. */ +export function isAcpModeId(value: unknown): value is AcpModeId { + return value === 'default' || value === 'plan' || value === 'auto' || value === 'yolo'; +} + +/** + * The two underlying engine toggles each ACP mode maps to. `plan` drives + * `IAgentPlanService` (enter/exit plan mode) and `permission` drives + * `IAgentPermissionModeService.setMode`. + */ +export interface AcpModeToggles { + readonly plan: boolean; + readonly permission: PermissionMode; +} + +/** + * Resolve an {@link AcpModeId} to its underlying engine toggles. The `switch` + * deliberately enumerates every arm of {@link AcpModeId} so the compiler + * enforces exhaustiveness — adding a 5th mode without extending this table is + * a typecheck error (the `never` fallthrough), not a silent runtime no-op. + */ +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-server/src/question.ts b/packages/acp-server/src/question.ts new file mode 100644 index 0000000000..fd51f38be6 --- /dev/null +++ b/packages/acp-server/src/question.ts @@ -0,0 +1,86 @@ +/** + * ACP `session/request_permission` ↔ agent-core-v2 ask-user mappers. + * + * ACP has no dedicated `session/request_question` method, so the AskUserQuestion + * tool's question request is bridged through the same `requestPermission` + * surface approvals use, with option ids tagged in a `q{n}_*` namespace so the + * round-trip is unambiguous. Pure mappers — no IO — so the mappings stay + * unit-testable without a live connection. + */ + +import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import type { QuestionAnswers, QuestionItem } from '@moonshot-ai/agent-core-v2'; + +/** + * `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 + * host is free to pick any stable string. The `questionIndex` is embedded in + * the prefix so future multi-question support does not need a wire-format + * change: `q0_opt_*` / `q1_opt_*` are already non-conflicting. + */ +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 surfaced back to the engine as a + * `QuestionAnswers` value). + * - One trailing `reject_once` "Skip" option so the user can dismiss the + * prompt without forcing an answer (the engine's ask-user tool resolves + * dismissal as `question_dismissed`). + * + * `questionIndex` is currently always `0` (the bridge degrades multi-question + * to single-question); the namespace is wired in so future multi-question + * support is a pure handler change with no wire-format break. + */ +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. + * + * 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; + if (optionId === skipOptionId(0)) return null; + 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-server/src/replay.ts b/packages/acp-server/src/replay.ts new file mode 100644 index 0000000000..6de77b683e --- /dev/null +++ b/packages/acp-server/src/replay.ts @@ -0,0 +1,191 @@ +/** + * `session/load` history replay — projects the main agent's persisted context + * history (`IAgentContextMemoryService.get()`) into an ordered batch of ACP + * `session/update` notifications so a loaded session re-renders its prior + * turns on the client. + * + * Pure projection: {@link projectHistoryToSessionUpdates} maps a + * `ContextMessage[]` to a `SessionNotification[]` with no IO, so the mapping + * is unit-testable without a live connection. The caller (`AcpSession`) + * awaits each push in order — replay is a one-shot batch whose completion + * ordering is what tells `loadSession` the response is safe to return. + * + * Turn / tool-call correlation: agent-core-v2 persists tool calls on the + * assistant message that issued them and tool results as separate `tool`-role + * messages. ACP needs a single `toolCallId` to correlate the create with its + * terminal update. Since the persisted history carries no real turn ids, the + * replay mints a synthetic monotonically-increasing `turnId` per assistant + * message and records `toolCallId → turnId` so the trailing `tool` messages + * can look up the id that issued them. + */ + +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { ContentPart, ContextMessage, ToolCall } from '@moonshot-ai/agent-core-v2'; + +import { + assistantDeltaToSessionUpdate, + thinkingDeltaToSessionUpdate, + toolCallStartToSessionUpdate, +} from './events-map'; + +/** + * Project a persisted context history into an ordered batch of ACP + * `session/update` notifications. Pure — no IO, never throws on a single + * malformed message (it is skipped). + */ +export function projectHistoryToSessionUpdates( + sessionId: string, + messages: readonly ContextMessage[], +): SessionNotification[] { + const out: SessionNotification[] = []; + let turnId = 0; + const toolCallTurnIds = new Map(); + + for (const message of messages) { + switch (message.role) { + case 'user': + for (const part of message.content) { + if (part.type === 'text' && part.text) { + out.push(userMessageChunk(sessionId, part.text)); + } + } + break; + case 'assistant': { + turnId += 1; + for (const part of message.content) { + const update = assistantContentPartToUpdate(part, sessionId, turnId); + if (update !== null) out.push(update); + } + for (const toolCall of message.toolCalls ?? []) { + toolCallTurnIds.set(toolCall.id, turnId); + out.push(syntheticToolCall(sessionId, turnId, toolCall)); + } + break; + } + case 'tool': { + const update = toolMessageToUpdate(message, sessionId, toolCallTurnIds); + if (update !== null) out.push(update); + break; + } + default: + // system / unknown roles — ACP has no analogue; skip. + break; + } + } + return out; +} + +function userMessageChunk(sessionId: string, text: string): SessionNotification { + return { + sessionId, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text }, + }, + }; +} + +function assistantContentPartToUpdate( + part: ContentPart, + sessionId: string, + turnId: number, +): SessionNotification | null { + if (part.type === 'text' && part.text) { + return assistantDeltaToSessionUpdate(sessionId, { + type: 'assistant.delta', + turnId, + delta: part.text, + }); + } + if (part.type === 'think' && part.think) { + return thinkingDeltaToSessionUpdate(sessionId, { + type: 'thinking.delta', + turnId, + delta: part.think, + }); + } + // image_url / audio_url / video_url belong to the user-input side and ACP + // has no dedicated assistant-media chunk; skip them. + return null; +} + +function syntheticToolCall( + sessionId: string, + turnId: number, + toolCall: ToolCall, +): SessionNotification { + return toolCallStartToSessionUpdate(sessionId, { + type: 'tool.call.started', + turnId, + toolCallId: toolCall.id, + name: toolCall.name, + args: parseToolCallArguments(toolCall.arguments), + }); +} + +function toolMessageToUpdate( + message: ContextMessage, + sessionId: string, + toolCallTurnIds: ReadonlyMap, +): SessionNotification | null { + const rawToolCallId = message.toolCallId; + if (!rawToolCallId) { + // Tool result with no correlation id — skip rather than crash; the + // on-disk session is the source of truth and we cannot synthesize a + // missing id. + return null; + } + const turnId = toolCallTurnIds.get(rawToolCallId); + if (turnId === undefined) { + // The matching assistant message was not in this history slice (e.g. the + // session was compacted). Skip — emitting an update for a tool_call the + // client never saw would orphan the card. + return null; + } + const isError = message.isError === true; + return { + sessionId, + update: { + sessionUpdate: 'tool_call_update', + toolCallId: `${turnId}:${rawToolCallId}`, + status: isError ? 'failed' : 'completed', + content: toolMessageContentToAcpToolCallContent(message.content), + }, + }; +} + +/** + * Parse a tool call's `arguments` field (a JSON string or `null`) into the + * structured object expected by {@link toolCallStartToSessionUpdate}. Falls + * back to the raw string when the payload is not valid JSON. + */ +function parseToolCallArguments(rawArguments: string | null): unknown { + if (rawArguments === null || rawArguments === '') return {}; + try { + return JSON.parse(rawArguments); + } catch { + return rawArguments; + } +} + +/** + * Project a `tool`-role message's content parts into the ACP + * `tool_call_update.content` shape. Text parts surface directly; anything else + * (image refs, etc.) becomes a `[type]` placeholder so the result card is not + * empty. + */ +function toolMessageContentToAcpToolCallContent( + parts: readonly ContentPart[], +): 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; + } + result.push({ type: 'content', content: { type: 'text', text: `[${part.type}]` } }); + } + return result; +} diff --git a/packages/acp-server/src/server.ts b/packages/acp-server/src/server.ts new file mode 100644 index 0000000000..8a37c6516b --- /dev/null +++ b/packages/acp-server/src/server.ts @@ -0,0 +1,404 @@ +/** + * ACP `AgentSideConnection` handler backed directly by `agent-core-v2`. + * + * `initialize`, the session lifecycle (`session/new`, `/load`, `/resume`, + * `/list`, `/close`), `session/prompt`, `session/cancel`, the config surface + * (model / mode / thinking), slash commands, skills, approval / question + * bridging (`session/request_permission`), and `session/load` history replay + * are wired to the `ISessionLifecycleService` / `ISessionIndex`, the + * per-session main agent, and the `ISessionInteractionService` kernel. MCP + * forwarding and terminal reverse-RPC land in later phases. + */ + +import { randomUUID } from 'node:crypto'; + +import { + type Agent, + type AgentCapabilities, + type AgentSideConnection, + type AuthenticateRequest, + type AuthenticateResponse, + type CancelNotification, + type ClientCapabilities, + type CloseSessionRequest, + type CloseSessionResponse, + type Implementation, + type InitializeRequest, + type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, + type LoadSessionRequest, + type LoadSessionResponse, + type NewSessionRequest, + type NewSessionResponse, + type PromptRequest, + type PromptResponse, + RequestError, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SessionInfo, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, + type SetSessionModeRequest, + type SetSessionModeResponse, + type SetSessionModelRequest, + type SetSessionModelResponse, +} from '@agentclientprotocol/sdk'; +import { + ensureMainAgent, + type IAgentScopeHandle, + IAuthSummaryService, + IConfigService, + IAgentProfileService, + ISessionIndex, + ISessionLifecycleService, + type ISessionScopeHandle, + type Scope, + type SessionSummary, +} from '@moonshot-ai/agent-core-v2'; + +import { buildTerminalAuthMethod, TERMINAL_AUTH_METHOD } from './auth-methods'; +import { log } from './log'; +import { isAcpModeId } from './modes'; +import { AcpSession } from './session'; + +export interface AcpServerOptions { + /** Agent identity advertised in `initialize.agentInfo`. */ + readonly agentInfo?: Implementation; + /** + * Bypass the auth gate (`IAuthSummaryService`). Intended for tests and local + * dev — production ACP hosts should leave this `false` so unauthenticated + * clients get a structured `auth_required` before any session is created. + */ + readonly disableAuth?: boolean; + /** + * 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 server uses (e.g. `{ KIMI_CODE_HOME: '/tmp/...' }` for + * sandboxed test setups). Leave undefined in production so the advertised + * env stays empty. + */ + readonly terminalAuthEnv?: Readonly>; + /** + * Absolute binary path advertised in `_meta['terminal-auth'].command` for + * clients that don't yet honor the first-class `type:'terminal'`. Defaults + * to undefined (the `_meta` fallback is omitted). + */ + readonly terminalAuthLegacyCommand?: string; +} + +export class AcpServer implements Agent { + private clientCapabilities: ClientCapabilities | undefined; + private readonly agentInfo: Implementation | undefined; + private readonly disableAuth: boolean; + private readonly terminalAuthEnv: Readonly> | undefined; + private readonly terminalAuthLegacyCommand: string | undefined; + private readonly sessions = new Map(); + + constructor( + private readonly conn: AgentSideConnection, + private readonly core: Scope, + opts: AcpServerOptions = {}, + ) { + this.agentInfo = opts.agentInfo; + this.disableAuth = opts.disableAuth ?? false; + this.terminalAuthEnv = opts.terminalAuthEnv; + this.terminalAuthLegacyCommand = opts.terminalAuthLegacyCommand; + } + + /** 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.clientCapabilities = params.clientCapabilities; + + const agentCapabilities: AgentCapabilities = { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + mcpCapabilities: { + http: true, + sse: true, + }, + sessionCapabilities: { + list: {}, + resume: {}, + close: {}, + }, + }; + + return { + protocolVersion: params.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 { + await this.ensureAuthed(); + const sessionId = `session_${randomUUID()}`; + const handle = await this.core.accessor + .get(ISessionLifecycleService) + .create({ sessionId, workDir: params.cwd }); + const acpSession = await this.wireSession(handle, sessionId); + this.sessions.set(sessionId, acpSession); + void acpSession.emitAvailableCommandsUpdate(); + return { sessionId, configOptions: acpSession.configOptions() }; + } + + async loadSession(params: LoadSessionRequest): Promise { + await this.ensureAuthed(); + const handle = await this.resumeHandle(params.sessionId); + const acpSession = await this.wireSession(handle, params.sessionId); + this.sessions.set(params.sessionId, acpSession); + // Replay the persisted history as an ordered batch of `session/update` + // notifications BEFORE settling, so the client re-renders prior turns + // before the load response lands. This is the one differentiator vs. + // `resumeSession`, which deliberately skips replay per the ACP spec. + await acpSession.replayHistory(); + void acpSession.emitAvailableCommandsUpdate(); + return { configOptions: acpSession.configOptions() }; + } + + async resumeSession(params: ResumeSessionRequest): Promise { + await this.ensureAuthed(); + const handle = await this.resumeHandle(params.sessionId); + const acpSession = await this.wireSession(handle, params.sessionId); + this.sessions.set(params.sessionId, acpSession); + void acpSession.emitAvailableCommandsUpdate(); + return { configOptions: acpSession.configOptions() }; + } + + async listSessions(params: ListSessionsRequest): Promise { + const cwd = params.cwd ?? undefined; + // ACP `cwd` ↔ session `cwd`. Filtering by workspaceId is a later phase; + // for now list everything (the client filters by cwd on its side). + void cwd; + const page = await this.core.accessor.get(ISessionIndex).list({}); + const sessions: SessionInfo[] = page.items.map(sessionSummaryToSessionInfo); + return { sessions, nextCursor: page.nextCursor ?? null }; + } + + /** + * Handle ACP `session/close`. Cancels any in-flight turn, tears down the + * per-session ACP resources (interaction bridge), and asks the engine to + * dispose the live session scope. Best-effort: an unknown or already-closed + * session id is not an error — `close` is a cleanup operation, and + * `ISessionLifecycleService.close` is a no-op for a session that is not + * currently live. + */ + async closeSession(params: CloseSessionRequest): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (acpSession !== undefined) { + acpSession.dispose(); + this.sessions.delete(params.sessionId); + } + await this.core.accessor.get(ISessionLifecycleService).close(params.sessionId); + } + + async authenticate(params: AuthenticateRequest): Promise { + if (params.methodId !== 'login') { + throw RequestError.invalidParams( + { methodId: params.methodId }, + `Unknown auth method: ${params.methodId}`, + ); + } + // Re-check the gate; clients spawn `kimi login` themselves via the + // terminal-auth method and re-invoke `authenticate('login')` to confirm the + // token landed. `void` = empty success body. + await this.ensureAuthed(); + } + + 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) { + // `session/cancel` is a notification — the spec forbids returning errors. + log.warn('acp: cancel for unknown sessionId', { sessionId: params.sessionId }); + return; + } + try { + acpSession.cancel(); + } catch (error) { + log.warn('acp: error while cancelling session', { + sessionId: params.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async setSessionMode(params: SetSessionModeRequest): Promise { + const acpSession = this.sessions.get(params.sessionId); + if (!acpSession) { + throw RequestError.invalidParams( + { sessionId: params.sessionId }, + `Unknown sessionId: ${params.sessionId}`, + ); + } + if (!isAcpModeId(params.modeId)) { + throw RequestError.invalidParams( + { modeId: params.modeId }, + `Unknown modeId: ${params.modeId}`, + ); + } + await acpSession.setMode(params.modeId); + } + + 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); + } + + 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': { + if (!isAcpModeId(value)) { + throw RequestError.invalidParams( + { modeId: value }, + `Unknown modeId: ${String(value)}`, + ); + } + await acpSession.setMode(value); + break; + } + case 'thinking': + await acpSession.setThinking(value === 'on'); + break; + default: + throw RequestError.invalidParams( + { configId: params.configId }, + `Unknown configId: ${params.configId}`, + ); + } + return { configOptions: acpSession.configOptions() }; + } + + async extMethod( + method: string, + _params: Record, + ): Promise> { + throw RequestError.methodNotFound(method); + } + + async extNotification(method: string, _params: Record): Promise { + throw RequestError.methodNotFound(method); + } + + /** + * Resume a persisted session into the live scope tree. Maps an unknown + * session id to ACP `invalid_params` (-32602) rather than a generic internal + * error. + */ + private async resumeHandle(sessionId: string): Promise { + const handle = await this.core.accessor.get(ISessionLifecycleService).resume(sessionId); + if (handle === undefined) { + throw RequestError.invalidParams({ sessionId }, `Unknown sessionId: ${sessionId}`); + } + return handle; + } + + /** + * Ensure the main agent exists for a session and bind the configured default + * model (best-effort — a missing default model leaves the agent unbound, and + * `prompt` settles gracefully until a model is set via `set_config_option`). + */ + private async wireSession(handle: ISessionScopeHandle, sessionId: string): Promise { + const main = await ensureMainAgent(handle); + await this.bindDefaultModel(main); + return new AcpSession(this.conn, handle, main, sessionId); + } + + private async bindDefaultModel(main: IAgentScopeHandle): Promise { + try { + const profile = main.accessor.get(IAgentProfileService); + if (profile.isRunnable()) return; + const inspected = this.core.accessor.get(IConfigService).inspect('defaultModel'); + const model = inspected.value; + if (typeof model === 'string' && model.length > 0) { + await profile.setModel(model); + } + } catch (error) { + log.warn('acp: default model binding skipped', { + error: error instanceof Error ? error.message : String(error), + }); + } + } + + /** Auth gate: throws `auth_required` unless authed (or `disableAuth`). */ + private async ensureAuthed(): Promise { + if (this.disableAuth) return; + const summaries = await this.core.accessor.get(IAuthSummaryService).summarize(); + const authed = summaries.some((s) => s.loggedIn); + if (!authed) { + throw RequestError.authRequired(); + } + } +} + +/** + * Project an agent-core-v2 {@link SessionSummary} into the ACP + * {@link SessionInfo} shape used by `session/list`. + */ +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.cwd ?? '', + title, + updatedAt, + }; +} diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts new file mode 100644 index 0000000000..1928370213 --- /dev/null +++ b/packages/acp-server/src/session.ts @@ -0,0 +1,410 @@ +/** + * ACP session (v2) — drives a single agent-core-v2 main agent over one ACP + * `sessionId`. + * + * `prompt` submits a user `ContextMessage` to the main agent's + * `IAgentPromptService`, subscribes the agent's `IEventBus` for the duration of + * the turn, and translates each `DomainEvent` into an ACP `session/update` + * notification via the helpers in `./events-map`. The promise settles on the + * `turn.ended` event (or, defensively, on the turn's `result` promise). + */ + +import type { + AgentSideConnection, + AvailableCommand, + ContentBlock, + PromptResponse, + SessionConfigOption, + SessionNotification, +} from '@agentclientprotocol/sdk'; +import { + type ContextMessage, + type DomainEvent, + type IAgentScopeHandle, + IAgentContextMemoryService, + IAgentPermissionModeService, + IAgentPlanService, + IAgentProfileService, + IAgentPromptService, + IAgentSkillService, + IEventBus, + IModelService, + ISessionSkillCatalog, + type ISessionScopeHandle, + type SkillCatalog, +} from '@moonshot-ai/agent-core-v2'; + +import { ACP_BUILTIN_SLASH_COMMANDS } from './builtin-commands'; +import { buildSessionConfigOptions } from './config-options'; +import { acpBlocksToContentParts } from './convert'; +import { + assistantDeltaToSessionUpdate, + availableCommandsUpdateNotification, + configOptionUpdateNotification, + planFromDisplayBlock, + stringifyArgs, + thinkingDeltaToSessionUpdate, + toolCallDeltaToSessionUpdate, + toolCallLazyCreateToSessionUpdate, + toolCallStartedUpgradeToSessionUpdate, + toolCallStartToSessionUpdate, + toolProgressToSessionUpdate, + toolResultToSessionUpdate, + turnEndReasonToStopReason, +} from './events-map'; +import { AcpInteractionBridge } from './interaction-bridge'; +import { log } from './log'; +import { projectModelCatalog } from './model-catalog'; +import { type AcpModeId, acpModeToToggles, DEFAULT_MODE_ID } from './modes'; +import { projectHistoryToSessionUpdates } from './replay'; +import { detectSlashIntent } from './slash'; + +/** Minimal handle to the in-flight turn, captured so `cancel` can abort it. */ +interface ActiveTurn { + readonly abortController: AbortController; +} + +/** The turn handle returned by the prompt / skill-activation drivers. */ +type AgentTurn = Awaited>; + +/** Leading text of the first text block, if any (used for slash detection). */ +function leadingText(blocks: readonly ContentBlock[]): string | undefined { + const first = blocks[0]; + if (first !== undefined && first.type === 'text') return first.text; + return undefined; +} + +/** + * Build a command-name → skill-name lookup from the session skill catalog. Both + * `/` and `/skill:` resolve to the same skill so either client + * convention works. + */ +function buildSkillCommandMap(catalog: SkillCatalog): Map { + const map = new Map(); + for (const skill of catalog.listInvocableSkills()) { + map.set(skill.name, skill.name); + map.set(`skill:${skill.name}`, skill.name); + } + return map; +} + +export class AcpSession { + private activeTurn: ActiveTurn | undefined; + + /** Currently-selected model id (bare, no suffix). Empty when unbound. */ + private currentModelId: string = ''; + /** Whether the thinking toggle is on for this session. */ + private currentThinkingEnabled: boolean = false; + /** Current ACP mode. */ + private currentModeId: AcpModeId = DEFAULT_MODE_ID; + /** Bridges engine approval / ask-user requests to the ACP client. */ + private readonly interactionBridge: AcpInteractionBridge; + + constructor( + private readonly conn: AgentSideConnection, + private readonly sessionHandle: ISessionScopeHandle, + private readonly mainAgent: IAgentScopeHandle, + readonly sessionId: string, + ) { + this.initConfigState(); + this.interactionBridge = new AcpInteractionBridge(conn, sessionHandle, sessionId); + } + + /** + * Tear down per-session resources. Cancels the in-flight turn (if any) and + * stops forwarding approval / ask-user requests to the client. Idempotent. + */ + dispose(): void { + this.cancel(); + this.interactionBridge.dispose(); + } + + /** + * Replay the main agent's persisted context history as an ordered batch of + * `session/update` notifications. Used by `session/load` so the client + * re-renders prior turns before the response settles. Awaits every push for + * ordering — replay is a one-shot batch, not a live stream. + */ + async replayHistory(): Promise { + let messages: readonly ContextMessage[]; + try { + messages = this.mainAgent.accessor.get(IAgentContextMemoryService).get(); + } catch (error) { + log.warn('acp: replayHistory could not read context memory', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + const updates = projectHistoryToSessionUpdates(this.sessionId, messages); + for (const update of updates) { + try { + await this.conn.sessionUpdate(update); + } catch (error) { + // A single transient push failure must not truncate the whole replay; + // log and continue so the rest of the history still lands. + log.warn('acp: replayHistory failed to push a session/update; continuing', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } + + /** The live session scope handle (for per-session service resolution). */ + get handle(): ISessionScopeHandle { + return this.sessionHandle; + } + + /** Seed config state from the main agent's profile (best-effort). */ + private initConfigState(): void { + try { + const data = this.mainAgent.accessor.get(IAgentProfileService).data(); + this.currentModelId = data.modelAlias ?? ''; + const level = data.thinkingLevel; + this.currentThinkingEnabled = + typeof level === 'string' && level.length > 0 && level !== 'off'; + } catch { + // keep defaults (unbound / no model) + } + } + + /** Resolve the session's invocable skills into a command-name → skill map. */ + private skillCommandMap(): ReadonlyMap { + const catalog = this.sessionHandle.accessor.get(ISessionSkillCatalog).catalog; + return buildSkillCommandMap(catalog); + } + + /** Build the `available_commands_update` payload (builtins + skills). */ + availableCommands(): AvailableCommand[] { + const catalog = this.sessionHandle.accessor.get(ISessionSkillCatalog).catalog; + const skills: AvailableCommand[] = catalog.listInvocableSkills().map((skill) => ({ + name: skill.name, + description: skill.description, + })); + return [...ACP_BUILTIN_SLASH_COMMANDS, ...skills]; + } + + /** Push the current `available_commands_update` to the client. */ + async emitAvailableCommandsUpdate(): Promise { + try { + await this.sessionHandle.accessor.get(ISessionSkillCatalog).ready; + await this.conn.sessionUpdate( + availableCommandsUpdateNotification(this.sessionId, this.availableCommands()), + ); + } catch (error) { + log.warn('acp: failed to push available_commands_update', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + async prompt(blocks: readonly ContentBlock[]): Promise { + const text = leadingText(blocks); + if (text !== undefined) { + const intent = detectSlashIntent(text, this.skillCommandMap()); + if (intent.kind === 'skill') { + return this.driveTurn(() => + this.mainAgent.accessor.get(IAgentSkillService).activate({ + name: intent.skillName, + args: intent.args.length > 0 ? intent.args : undefined, + }), + ); + } + // 'builtin' and 'unknown' commands fall through to a normal prompt for + // now — builtin command execution is a later phase. + } + + const content = acpBlocksToContentParts(blocks); + const message: ContextMessage = { + role: 'user', + content: [...content], + toolCalls: [], + origin: { kind: 'user' }, + }; + return this.driveTurn(() => + this.mainAgent.accessor.get(IAgentPromptService).prompt(message), + ); + } + + /** + * Drive a turn to completion: subscribe the main agent's `IEventBus` BEFORE + * launching (so no events are missed), translate each event into an ACP + * `session/update`, and settle on `turn.ended` (falling back to the turn's + * `result` promise). Used by both normal prompts and skill activations. + */ + private driveTurn(launch: () => Promise): Promise { + return new Promise((resolve, reject) => { + const eventBus = this.mainAgent.accessor.get(IEventBus); + let settled = false; + + // Per-tool-call streaming state, reset for every turn. + const accumulators = new Map(); + const lazyCreated = new Set(); + const started = new Set(); + + const settle = (action: () => void): void => { + if (settled) return; + settled = true; + sub.dispose(); + this.activeTurn = undefined; + action(); + }; + + const emit = (notification: SessionNotification | null): void => { + if (notification === null) return; + void this.conn.sessionUpdate(notification).catch((error) => { + log.warn('acp: failed to push session/update', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + }); + }; + + const handleEvent = (event: DomainEvent): void => { + switch (event.type) { + case 'assistant.delta': + emit(assistantDeltaToSessionUpdate(this.sessionId, event)); + break; + case 'thinking.delta': + emit(thinkingDeltaToSessionUpdate(this.sessionId, event)); + break; + case 'tool.call.delta': { + const key = event.toolCallId; + if (!started.has(key) && !lazyCreated.has(key)) { + lazyCreated.add(key); + accumulators.set(key, { args: event.argumentsPart ?? '' }); + emit(toolCallLazyCreateToSessionUpdate(this.sessionId, event)); + break; + } + const acc = accumulators.get(key) ?? { args: '' }; + accumulators.set(key, acc); + emit(toolCallDeltaToSessionUpdate(this.sessionId, event, acc)); + break; + } + case 'tool.call.started': { + const key = event.toolCallId; + started.add(key); + if (lazyCreated.has(key)) { + emit(toolCallStartedUpgradeToSessionUpdate(this.sessionId, event)); + } else { + emit(toolCallStartToSessionUpdate(this.sessionId, event)); + } + // (Re)seed the accumulator with the canonical args so any later + // delta appends correctly. + accumulators.set(key, { args: stringifyArgs(event.args) }); + if (event.display) { + emit(planFromDisplayBlock(this.sessionId, event.turnId, event.display)); + } + break; + } + case 'tool.progress': + emit(toolProgressToSessionUpdate(this.sessionId, event)); + break; + case 'tool.result': + emit(toolResultToSessionUpdate(this.sessionId, event)); + break; + case 'turn.ended': + settle(() => + resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) }), + ); + break; + default: + break; + } + }; + + const sub = eventBus.subscribe(handleEvent); + + launch().then( + (turn) => { + if (turn === undefined) { + // busy / not runnable / hook-blocked — no turn will emit + // `turn.ended`, so settle gracefully. + settle(() => resolve({ stopReason: 'end_turn' })); + return; + } + this.activeTurn = turn; + // Fallback settlement: `turn.ended` is the primary signal, but if the + // turn resolves/rejects without it, settle here so the prompt never + // hangs. + turn.result.then( + () => settle(() => resolve({ stopReason: 'end_turn' })), + (error) => settle(() => reject(error)), + ); + }, + (error) => settle(() => reject(error)), + ); + }); + } + + /** Cancel the in-flight turn, if any. Idempotent. */ + cancel(): void { + if (this.activeTurn !== undefined) { + this.activeTurn.abortController.abort(); + this.activeTurn = undefined; + } + } + + /** Build the current `configOptions` snapshot (model + thinking? + mode). */ + configOptions(): SessionConfigOption[] { + const models = projectModelCatalog(this.sessionHandle.accessor.get(IModelService).list()); + return buildSessionConfigOptions( + models, + this.currentModelId, + this.currentThinkingEnabled, + this.currentModeId, + ); + } + + /** Switch the active model. */ + async setModel(id: string): Promise { + await this.mainAgent.accessor.get(IAgentProfileService).setModel(id); + this.currentModelId = id; + await this.emitConfigOptionUpdate(); + } + + /** Flip the thinking toggle. */ + async setThinking(on: boolean): Promise { + const models = projectModelCatalog(this.sessionHandle.accessor.get(IModelService).list()); + const entry = models.find((m) => m.id === this.currentModelId); + const effort = on ? (entry?.defaultThinkingEffort ?? 'on') : 'off'; + this.mainAgent.accessor.get(IAgentProfileService).setThinking(effort); + this.currentThinkingEnabled = on; + await this.emitConfigOptionUpdate(); + } + + /** Switch the ACP mode (plan mode + permission mode). */ + async setMode(id: AcpModeId): Promise { + const { plan, permission } = acpModeToToggles(id); + this.mainAgent.accessor.get(IAgentPermissionModeService).setMode(permission); + try { + const planService = this.mainAgent.accessor.get(IAgentPlanService); + if (plan) await planService.enter(); + else planService.exit(); + } catch (error) { + log.warn('acp: plan mode toggle failed', { + sessionId: this.sessionId, + mode: id, + error: error instanceof Error ? error.message : String(error), + }); + } + this.currentModeId = id; + await this.emitConfigOptionUpdate(); + } + + /** Push a fresh `config_option_update` to the client. */ + private async emitConfigOptionUpdate(): Promise { + try { + await this.conn.sessionUpdate( + configOptionUpdateNotification(this.sessionId, this.configOptions()), + ); + } catch (error) { + log.warn('acp: failed to push config_option_update', { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } +} diff --git a/packages/acp-server/src/slash.ts b/packages/acp-server/src/slash.ts new file mode 100644 index 0000000000..e6f3a0c077 --- /dev/null +++ b/packages/acp-server/src/slash.ts @@ -0,0 +1,56 @@ +// Slash-command detection for ACP `session/prompt`. +// +// ACP only intercepts commands the host can execute directly: skills plus the +// small ACP-owned built-in command set. Other slash inputs are reported as +// unknown commands instead of being silently sent to the model as prompt text. + +import { + ACP_BUILTIN_SLASH_COMMAND_NAMES, + type AcpBuiltinSlashCommandName, +} from './builtin-commands'; + +export interface ParsedSlashInput { + readonly name: string; + readonly args: string; +} + +export type SlashIntent = + | { readonly kind: 'skill'; readonly skillName: string; readonly args: string } + | { readonly kind: 'builtin'; readonly name: AcpBuiltinSlashCommandName; readonly args: string } + | { readonly kind: 'unknown'; readonly name: string; readonly args: string } + | { readonly kind: 'passthrough' }; + +export function parseSlashInput(input: string): ParsedSlashInput | null { + if (!input.startsWith('/')) return null; + const trimmed = input.slice(1).trim(); + if (trimmed.length === 0) return null; + const spaceIdx = trimmed.indexOf(' '); + const name = spaceIdx === -1 ? trimmed : trimmed.slice(0, spaceIdx); + const args = spaceIdx === -1 ? '' : trimmed.slice(spaceIdx + 1).trim(); + if (name.includes('/')) return null; + return { name, args }; +} + +export function resolveSkillCommand( + skillCommandMap: ReadonlyMap, + commandName: string, +): string | undefined { + return skillCommandMap.get(commandName) ?? skillCommandMap.get(`skill:${commandName}`); +} + +export function detectSlashIntent( + text: string, + skillCommandMap: ReadonlyMap, + builtinCommandNames: ReadonlySet = ACP_BUILTIN_SLASH_COMMAND_NAMES, +): SlashIntent { + const parsed = parseSlashInput(text); + if (parsed === null) return { kind: 'passthrough' }; + const skillName = resolveSkillCommand(skillCommandMap, parsed.name); + if (skillName !== undefined) { + return { kind: 'skill', skillName, args: parsed.args }; + } + if (builtinCommandNames.has(parsed.name)) { + return { kind: 'builtin', name: parsed.name as AcpBuiltinSlashCommandName, args: parsed.args }; + } + return { kind: 'unknown', name: parsed.name, args: parsed.args }; +} diff --git a/packages/acp-server/src/start.ts b/packages/acp-server/src/start.ts new file mode 100644 index 0000000000..787cce14ae --- /dev/null +++ b/packages/acp-server/src/start.ts @@ -0,0 +1,136 @@ +/** + * acp-server bootstrap — wires `@moonshot-ai/agent-core-v2` (the DI × Scope + * engine) into an ACP (Agent Client Protocol) stdio server. + * + * Composition root: `bootstrap()` builds the App `Scope`; ACP method handlers + * resolve services through `core.accessor.get(IXxx)` and per-session scope + * handles. The ACP-backed `IHostFileSystem` (./acp-fs) is imported for its + * Session-scope registration side effect (see the import below). + */ + +import { Readable, Writable } from 'node:stream'; + +import { AgentSideConnection, ndJsonStream, type Stream } from '@agentclientprotocol/sdk'; +import { + bootstrap, + IAppendLogStore, + logSeed, + resolveConfigPath, + resolveKimiHome, + resolveLoggingConfig, + type Scope, + type ScopeSeed, +} from '@moonshot-ai/agent-core-v2'; + +import { AcpServer, type AcpServerOptions } from './server'; +// Importing the `acp-fs` barrel also registers the ACP-backed Session-scope +// `IHostFileSystem` and the App-scope `IAcpConnection` holder via the barrel's +// module side effects. `IAcpConnection` is used below to bind the ACP client +// connection. +import { IAcpConnection } from './acp-fs'; + +export interface RunAcpServerOptions extends AcpServerOptions { + readonly homeDir?: string; + readonly configPath?: string; + readonly input?: NodeJS.ReadableStream; + readonly output?: NodeJS.WritableStream; + /** + * Extra App-scope service seeds forwarded to `bootstrap()`. Intended for + * tests — e.g. seeding a scripted `IProtocolAdapterRegistry` to drive a + * deterministic turn without a real LLM. Seeds shadow any registered + * binding with the same service identifier. + */ + readonly extraSeeds?: ScopeSeed; +} + +export interface RunningAcpServer { + readonly core: Scope; + readonly conn: AgentSideConnection; + close(): Promise; +} + +/** + * Redirect `console.*` to stderr. Stdout is the ACP JSON-RPC channel; any stray + * write from a dependency would corrupt the protocol stream. + */ +function redirectConsoleToStderr(): void { + const sink = (...args: unknown[]): void => { + process.stderr.write(`${args.map(String).join(' ')}\n`); + }; + globalThis.console.log = sink; + globalThis.console.info = sink; + globalThis.console.warn = sink; + globalThis.console.debug = sink; +} + +/** + * Drive an {@link AcpServer} over an arbitrary ACP {@link Stream}. + * + * Boots `agent-core-v2`, binds the ACP client connection into + * {@link IAcpConnection} (so the `acp` `IHostFileSystem` can reverse-RPC file + * IO), and resolves when the connection closes. + */ +export async function runAcpServerWithStream( + stream: Stream, + opts: RunAcpServerOptions = {}, +): Promise { + const homeDir = resolveKimiHome(opts.homeDir); + const configPath = resolveConfigPath({ homeDir, configPath: opts.configPath }); + // `ILogOptions` (logSeed) is required by the Session-scoped log writer; any + // session creation would otherwise fail to instantiate the Session scope. + const logging = resolveLoggingConfig({ homeDir, env: process.env }); + // `bootstrap()` seeds `IFileSystemStorageService` with a `FileStorageService` + // rooted at `homeDir`, so session metadata, wire records, blobs, and the + // session index all persist to disk. + const { app: core } = bootstrap({ homeDir, configPath }, [ + ...logSeed(logging), + ...(opts.extraSeeds ?? []), + ]); + + const conn = new AgentSideConnection((c) => { + // Bind the process-wide ACP client connection before any session performs + // file IO. The `acp` `IHostFileSystem` reads it lazily via + // `IAcpConnection.get()`. + core.accessor.get(IAcpConnection).bind(c); + return new AcpServer(c, core, { + agentInfo: opts.agentInfo, + disableAuth: opts.disableAuth, + terminalAuthEnv: opts.terminalAuthEnv, + terminalAuthLegacyCommand: opts.terminalAuthLegacyCommand, + }); + }, stream); + + const close = async (): Promise => { + // Flush the append-log write-behind before disposing, so a clean shutdown + // never races a pending drain against teardown (and doesn't drop the last + // persisted ops). Best-effort: a flush failure must not block disposal. + try { + await core.accessor.get(IAppendLogStore).flush(); + } catch { + // ignore — disposal proceeds regardless + } + core.dispose(); + }; + + void conn.closed.then(() => { + void close(); + }); + + return { core, conn, close }; +} + +/** + * 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`. + */ +export async function runAcpServer(opts: RunAcpServerOptions = {}): Promise { + 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 server = await runAcpServerWithStream(stream, opts); + await server.conn.closed; + await server.close(); +} diff --git a/packages/acp-server/src/types.ts b/packages/acp-server/src/types.ts new file mode 100644 index 0000000000..d4f3126297 --- /dev/null +++ b/packages/acp-server/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-server/test/_helpers/acpClient.ts b/packages/acp-server/test/_helpers/acpClient.ts new file mode 100644 index 0000000000..5a4d438572 --- /dev/null +++ b/packages/acp-server/test/_helpers/acpClient.ts @@ -0,0 +1,175 @@ +import { PassThrough, Readable, Writable } from 'node:stream'; + +import { ndJsonStream } from '@agentclientprotocol/sdk'; + +import { runAcpServerWithStream, type RunningAcpServer, type RunAcpServerOptions } from '../../src/start'; + +interface RpcMessage { + readonly id?: number; + readonly method?: string; + readonly result?: unknown; + readonly error?: unknown; + readonly params?: unknown; +} + +/** Handler for an agent-initiated JSON-RPC request (reverse-RPC). */ +export type RequestHandler = (params: unknown) => unknown | Promise; + +export interface TestClient { + /** Send a JSON-RPC request and resolve with the `result` (rejects on `error`). */ + send(method: string, params?: unknown): Promise; + /** All messages received from the agent so far (responses + notifications + requests). */ + readonly received: readonly RpcMessage[]; + /** `session/update` notifications received so far. */ + sessionUpdates(): readonly RpcMessage[]; + /** Resolve once a `session/update` whose `update.sessionUpdate` matches arrives. */ + waitForSessionUpdate(sessionUpdate: string, timeoutMs?: number): Promise; + /** + * Register a handler for an agent-initiated request method (e.g. + * `session/request_permission`). The handler's return value is sent back as + * the JSON-RPC `result`; a thrown error is sent as a JSON-RPC `error`. + */ + onRequest(method: string, handler: RequestHandler): void; + readonly server: RunningAcpServer; + close(): Promise; +} + +/** + * Build an in-memory ACP client/server pair for tests. The server boots a real + * `agent-core-v2` rooted at `homeDir`; the client speaks raw ND-JSON JSON-RPC + * over a `PassThrough` stream pair. + */ +export async function createTestClient(opts: { + homeDir: string; + disableAuth?: boolean; + extraSeeds?: RunAcpServerOptions['extraSeeds']; +}): Promise { + const toAgent = new PassThrough(); + const toClient = new PassThrough(); + const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent)); + const server = await runAcpServerWithStream(stream, { + homeDir: opts.homeDir, + disableAuth: opts.disableAuth ?? true, + extraSeeds: opts.extraSeeds, + }); + + let nextId = 1; + const pending = new Map< + number, + { resolve: (value: unknown) => void; reject: (reason: unknown) => void } + >(); + const requestHandlers = new Map(); + const received: RpcMessage[] = []; + const waiters: Array<{ + sessionUpdate: string; + resolve: (msg: RpcMessage) => void; + reject: (error: Error) => void; + }> = []; + let buffer = ''; + + async function handleIncomingRequest( + id: number, + method: string, + params: unknown, + ): Promise { + const handler = requestHandlers.get(method); + try { + if (handler === undefined) { + throw new Error(`TestClient: no handler registered for agent request '${method}'`); + } + const result = await handler(params); + toAgent.write(`${JSON.stringify({ jsonrpc: '2.0', id, result })}\n`); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + toAgent.write( + `${JSON.stringify({ jsonrpc: '2.0', id, error: { code: -32603, message } })}\n`, + ); + } + } + + function dispatch(msg: RpcMessage): void { + received.push(msg); + // Incoming request from the agent (reverse-RPC): has both id and method. + if (msg.id !== undefined && msg.method !== undefined) { + void handleIncomingRequest(msg.id, msg.method, msg.params); + return; + } + // Notification (no id): check session/update waiters first. + if (msg.id === undefined && msg.method === 'session/update') { + const update = (msg.params as { update?: { sessionUpdate?: string } } | undefined)?.update; + const kind = update?.sessionUpdate; + for (let i = waiters.length - 1; i >= 0; i--) { + if (waiters[i]!.sessionUpdate === kind) { + const [w] = waiters.splice(i, 1); + w!.resolve(msg); + } + } + return; + } + // Response: resolve the pending request by id. + if (msg.id !== undefined && pending.has(msg.id)) { + const entry = pending.get(msg.id)!; + pending.delete(msg.id); + if (msg.error !== undefined) entry.reject(new Error(JSON.stringify(msg.error))); + else entry.resolve(msg.result); + } + } + + const reader = (async (): Promise => { + for await (const chunk of toClient) { + buffer += (chunk as Buffer).toString('utf8'); + let idx: number; + while ((idx = buffer.indexOf('\n')) >= 0) { + const line = buffer.slice(0, idx); + buffer = buffer.slice(idx + 1); + if (line.trim().length === 0) continue; + dispatch(JSON.parse(line) as RpcMessage); + } + } + })(); + + function send(method: string, params?: unknown): Promise { + const id = nextId++; + const request = { jsonrpc: '2.0', id, method, params: params ?? {} }; + toAgent.write(`${JSON.stringify(request)}\n`); + return new Promise((resolve, reject) => { + pending.set(id, { resolve, reject }); + }); + } + + function sessionUpdates(): readonly RpcMessage[] { + return received.filter((m) => m.method === 'session/update'); + } + + function waitForSessionUpdate(sessionUpdate: string, timeoutMs = 5_000): Promise { + const existing = sessionUpdates().find((m) => { + const update = (m.params as { update?: { sessionUpdate?: string } } | undefined)?.update; + return update?.sessionUpdate === sessionUpdate; + }); + if (existing !== undefined) return Promise.resolve(existing); + return new Promise((resolve, reject) => { + const waiter = { sessionUpdate, resolve, reject }; + waiters.push(waiter); + setTimeout(() => { + const i = waiters.indexOf(waiter); + if (i >= 0) { + waiters.splice(i, 1); + reject(new Error(`timed out waiting for session/update '${sessionUpdate}'`)); + } + }, timeoutMs); + }); + } + + async function close(): Promise { + await server.close(); + toAgent.end(); + toClient.end(); + await reader; + } + + function onRequest(method: string, handler: RequestHandler): void { + requestHandlers.set(method, handler); + } + + return { send, received, sessionUpdates, waitForSessionUpdate, onRequest, server, close }; +} diff --git a/packages/acp-server/test/_helpers/fakeModelConfig.ts b/packages/acp-server/test/_helpers/fakeModelConfig.ts new file mode 100644 index 0000000000..3976e20cb7 --- /dev/null +++ b/packages/acp-server/test/_helpers/fakeModelConfig.ts @@ -0,0 +1,34 @@ +/** + * Write a minimal `config.toml` that declares a single fake model backed by the + * scripted-provider seam, so `AcpServer.bindDefaultModel()` binds it on + * `session/new` and the turn loop resolves a runnable `Model`. + * + * Uses the flat model path (`baseUrl` + inline `apiKey` on the Model) so no + * `[providers.*]` entry is required; the resolver synthesizes a Provider from + * the `baseUrl` origin and builds a `StaticAuthProvider` from the inline key. + * The `protocol` is any valid enum value — the scripted registry ignores it. + */ + +import { mkdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +export const FAKE_MODEL_ID = 'fake'; + +const CONFIG_TOML = `defaultModel = "${FAKE_MODEL_ID}" + +[models.${FAKE_MODEL_ID}] +name = "fake-model" +protocol = "kimi" +baseUrl = "http://localhost" +apiKey = "test-token" +maxContextSize = 8192 +`; + +/** + * Write the fake-model `config.toml` into `/config.toml`. Call BEFORE + * booting the server (the ConfigService reads the file at first access). + */ +export async function writeFakeModelConfig(homeDir: string): Promise { + await mkdir(homeDir, { recursive: true }); + await writeFile(join(homeDir, 'config.toml'), CONFIG_TOML, 'utf8'); +} diff --git a/packages/acp-server/test/_helpers/scriptedProvider.ts b/packages/acp-server/test/_helpers/scriptedProvider.ts new file mode 100644 index 0000000000..deb385106c --- /dev/null +++ b/packages/acp-server/test/_helpers/scriptedProvider.ts @@ -0,0 +1,171 @@ +/** + * Scripted LLM provider seam for "real" ACP turn tests. + * + * Boots the full engine + ACP wire but replaces the wire `ChatProvider` with a + * deterministic one that replays a FIFO queue of scripted responses. This keeps + * the entire real stack — JSON-RPC, `AcpSession`, the agent turn loop, + * `ModelImpl.request`, the real `generate()` stream-merge, `IEventBus` + * `assistant.delta` → ACP `session/update`, tool execution, and the + * approval / question bridge — and fakes only the network LLM call. + * + * Usage: + * const { seed, mockNextResponse } = createScriptedProvider(); + * mockNextResponse({ type: 'text', text: 'hi' }); + * const client = await createTestClient({ homeDir, extraSeeds: [seed] }); + * + * The seed shadows the App-scope `IProtocolAdapterRegistry`, so every Model the + * resolver builds routes its `createChatProvider()` call into the scripted + * provider regardless of protocol. + */ + +import { + type FinishReason, + IProtocolAdapterRegistry, + type IProtocolAdapterRegistry as IProtocolAdapterRegistryType, + type Message, + type ProtocolAdapterConfig, + type StreamedMessagePart, + type TokenUsage, + type Tool, +} from '@moonshot-ai/agent-core-v2'; + +interface ScriptedResponse { + readonly parts: readonly StreamedMessagePart[]; + readonly finishReason?: FinishReason | null; + readonly rawFinishReason?: string | null; +} + +const SUPPORTED_PROTOCOLS = [ + 'kimi', + 'anthropic', + 'openai', + 'openai_responses', + 'google-genai', + 'vertexai', +] as const; + +const ZERO_USAGE: TokenUsage = { + inputOther: 0, + output: 0, + inputCacheRead: 0, + inputCacheCreation: 0, +}; + +/** + * Async-iterable `StreamedMessage` backed by a fixed part list. Terminal fields + * (`id` / `usage` / `finishReason` / `rawFinishReason`) are populated when the + * iterator completes — matching the real `generate()` driver, which reads them + * after its `for await` loop drains the stream. + */ +class ScriptedStream { + id: string | null = null; + usage: TokenUsage | null = null; + finishReason: FinishReason | null = null; + rawFinishReason: string | null = null; + + constructor( + private readonly parts: readonly StreamedMessagePart[], + private readonly response: ScriptedResponse, + private readonly index: number, + ) {} + + async *[Symbol.asyncIterator](): AsyncIterator { + for (const part of this.parts) { + yield part; + } + const hasToolCall = this.parts.some((p) => p.type === 'function'); + this.id = `scripted-${String(this.index)}`; + this.usage = { ...ZERO_USAGE, output: this.parts.length }; + this.finishReason = + this.response.finishReason ?? (hasToolCall ? 'tool_calls' : 'completed'); + this.rawFinishReason = + this.response.rawFinishReason ?? (this.finishReason === 'completed' ? 'stop' : this.finishReason); + } +} + +class ScriptedChatProvider { + readonly name = 'scripted'; + readonly modelName = 'scripted'; + readonly thinkingEffort = null; + + constructor( + private readonly queue: ScriptedResponse[], + private readonly calls: Array, + ) {} + + async generate( + _systemPrompt: string, + _tools: readonly Tool[], + history: readonly Message[], + options?: { signal?: AbortSignal }, + ): Promise { + options?.signal?.throwIfAborted(); + const response = this.queue.shift(); + if (response === undefined) { + throw new Error( + `scriptedProvider: unexpected generate() call #${String(this.calls.length + 1)} — ` + + `queue exhausted. Push another response via mockNextResponse().`, + ); + } + this.calls.push(history); + return new ScriptedStream(response.parts, response, this.calls.length); + } + + withThinking(): ScriptedChatProvider { + return this; + } + + withMaxCompletionTokens(): ScriptedChatProvider { + return this; + } +} + +export interface ScriptedProvider { + /** App-scope seed tuple to pass as `extraSeeds: [seed]`. */ + readonly seed: readonly [typeof IProtocolAdapterRegistry, IProtocolAdapterRegistryType]; + /** Push a text-only assistant response onto the queue. */ + mockNextText(text: string): void; + /** Push a response assembled from arbitrary streamed parts. */ + mockNextResponse(...parts: StreamedMessagePart[]): void; + /** Push a response with an explicit finish reason. */ + mockNextProviderResponse(response: { + readonly parts?: readonly StreamedMessagePart[]; + readonly finishReason?: FinishReason | null; + readonly rawFinishReason?: string | null; + }): void; + /** Number of `generate()` calls the engine has made so far. */ + callCount(): number; +} + +export function createScriptedProvider(): ScriptedProvider { + const queue: ScriptedResponse[] = []; + const calls: Array = []; + // Single shared provider so every ModelImpl in the process (main agent, + // sub-agents) draws from the same FIFO queue. + const provider = new ScriptedChatProvider(queue, calls); + const registry: IProtocolAdapterRegistryType = { + _serviceBrand: undefined, + supportedProtocols: () => SUPPORTED_PROTOCOLS, + // `createChatProvider` is called by `ModelImpl` (a package-internal method + // not on the public interface); present at runtime, cast for the type gap. + createChatProvider: (_input: ProtocolAdapterConfig) => provider, + } as unknown as IProtocolAdapterRegistryType; + + return { + seed: [IProtocolAdapterRegistry, registry], + mockNextText: (text) => { + queue.push({ parts: [{ type: 'text', text }] }); + }, + mockNextResponse: (...parts) => { + queue.push({ parts: structuredClone(parts) }); + }, + mockNextProviderResponse: (response) => { + queue.push({ + parts: structuredClone(response.parts ?? []), + finishReason: response.finishReason, + rawFinishReason: response.rawFinishReason, + }); + }, + callCount: () => calls.length, + }; +} diff --git a/packages/acp-server/test/approval.test.ts b/packages/acp-server/test/approval.test.ts new file mode 100644 index 0000000000..c004f0a122 --- /dev/null +++ b/packages/acp-server/test/approval.test.ts @@ -0,0 +1,185 @@ +import { describe, expect, it } from 'vitest'; + +import { + APPROVE_ALWAYS_OPTION_ID, + APPROVE_ONCE_OPTION_ID, + approvalRequestToPermissionOptions, + attachSelectedLabel, + buildPermissionToolCallUpdate, + permissionResponseToApprovalResponse, + PLAN_APPROVE_OPTION_ID, + PLAN_REJECT_AND_EXIT_OPTION_ID, + PLAN_REVISE_OPTION_ID, + REJECT_OPTION_ID, +} from '../src/approval'; + +import type { PermissionOption, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import type { SessionApprovalRequest } from '@moonshot-ai/agent-core-v2'; +import type { ToolInputDisplay } from '@moonshot-ai/protocol'; + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } }; + +const commandDisplay: ToolInputDisplay = { + kind: 'command', + command: 'echo hi', +} as unknown as ToolInputDisplay; + +function makeRequest(display: ToolInputDisplay, turnId?: number): SessionApprovalRequest { + return { + toolName: 'Bash', + action: 'run `echo hi`', + toolCallId: 'call_1', + display, + turnId, + }; +} + +describe('approvalRequestToPermissionOptions', () => { + it('returns the canonical 3 options for a non-plan_review request', () => { + const options = approvalRequestToPermissionOptions(makeRequest(commandDisplay)); + expect(options.map((o) => o.optionId)).toEqual([ + APPROVE_ONCE_OPTION_ID, + APPROVE_ALWAYS_OPTION_ID, + REJECT_OPTION_ID, + ]); + }); + + it('expands plan_review into per-option allows plus revise/reject-and-exit', () => { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: 'do the thing', + options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }], + } as unknown as ToolInputDisplay; + const options = approvalRequestToPermissionOptions(makeRequest(display)); + expect(options.map((o) => o.optionId)).toEqual([ + 'plan_opt_0', + 'plan_opt_1', + 'plan_opt_2', + PLAN_REVISE_OPTION_ID, + PLAN_REJECT_AND_EXIT_OPTION_ID, + ]); + expect(options[0]).toMatchObject({ name: 'A', kind: 'allow_once' }); + }); + + it('falls back to a single plan_approve when fewer than 2 options', () => { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: 'do the thing', + } as unknown as ToolInputDisplay; + const options = approvalRequestToPermissionOptions(makeRequest(display)); + expect(options[0]?.optionId).toBe(PLAN_APPROVE_OPTION_ID); + }); +}); + +describe('permissionResponseToApprovalResponse', () => { + it('maps cancelled to decision cancelled', () => { + expect(permissionResponseToApprovalResponse(makeRequest(commandDisplay), cancelled)).toEqual({ + decision: 'cancelled', + }); + }); + + it('maps approve_once to approved with no scope', () => { + expect( + permissionResponseToApprovalResponse( + makeRequest(commandDisplay), + selected(APPROVE_ONCE_OPTION_ID), + ), + ).toEqual({ decision: 'approved' }); + }); + + it('maps approve_always to approved with session scope', () => { + expect( + permissionResponseToApprovalResponse( + makeRequest(commandDisplay), + selected(APPROVE_ALWAYS_OPTION_ID), + ), + ).toEqual({ decision: 'approved', scope: 'session' }); + }); + + it('maps reject to rejected', () => { + expect( + permissionResponseToApprovalResponse(makeRequest(commandDisplay), selected(REJECT_OPTION_ID)), + ).toEqual({ decision: 'rejected' }); + }); + + it('maps an unknown optionId to rejected (defensive)', () => { + expect( + permissionResponseToApprovalResponse(makeRequest(commandDisplay), selected('mystery')), + ).toEqual({ decision: 'rejected' }); + }); + + it('maps plan_opt_ to approved with the option label as selectedLabel', () => { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: 'p', + options: [{ label: 'Alpha' }, { label: 'Beta' }], + } as unknown as ToolInputDisplay; + expect(permissionResponseToApprovalResponse(makeRequest(display), selected('plan_opt_1'))).toEqual({ + decision: 'approved', + selectedLabel: 'Beta', + }); + }); + + it('maps plan_revise / plan_reject_and_exit to rejected with labels', () => { + const display: ToolInputDisplay = { + kind: 'plan_review', + plan: 'p', + options: [{ label: 'A' }, { label: 'B' }], + } as unknown as ToolInputDisplay; + expect( + permissionResponseToApprovalResponse(makeRequest(display), selected(PLAN_REVISE_OPTION_ID)), + ).toEqual({ decision: 'rejected', selectedLabel: 'Revise' }); + expect( + permissionResponseToApprovalResponse( + makeRequest(display), + selected(PLAN_REJECT_AND_EXIT_OPTION_ID), + ), + ).toEqual({ decision: 'rejected', selectedLabel: 'Reject and Exit' }); + }); +}); + +describe('buildPermissionToolCallUpdate', () => { + it('prefixes the toolCallId with the turnId when present', () => { + const update = buildPermissionToolCallUpdate(makeRequest(commandDisplay, 7)); + expect(update.toolCallId).toBe('7:call_1'); + expect(update.title).toBe('Bash'); + }); + + it('falls back to the raw id when turnId is absent', () => { + const update = buildPermissionToolCallUpdate(makeRequest(commandDisplay)); + expect(update.toolCallId).toBe('call_1'); + }); + + it('always appends an action-summary content entry', () => { + const update = buildPermissionToolCallUpdate(makeRequest(commandDisplay, 1)); + const last = update.content?.at(-1); + expect(last).toMatchObject({ + type: 'content', + content: { type: 'text', text: 'Requesting approval to run `echo hi`' }, + }); + }); +}); + +describe('attachSelectedLabel', () => { + const options: readonly PermissionOption[] = [ + { optionId: APPROVE_ONCE_OPTION_ID, name: 'Approve once', kind: 'allow_once' }, + ]; + + it('attaches the matched option name as selectedLabel', () => { + const result = attachSelectedLabel( + selected(APPROVE_ONCE_OPTION_ID), + { decision: 'approved' }, + options, + ); + expect(result).toEqual({ decision: 'approved', selectedLabel: 'Approve once' }); + }); + + it('is a no-op for cancelled outcomes', () => { + const result = attachSelectedLabel(cancelled, { decision: 'cancelled' }, options); + expect(result).toEqual({ decision: 'cancelled' }); + }); +}); diff --git a/packages/acp-server/test/close.test.ts b/packages/acp-server/test/close.test.ts new file mode 100644 index 0000000000..832f5948fe --- /dev/null +++ b/packages/acp-server/test/close.test.ts @@ -0,0 +1,62 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createTestClient, type TestClient } from './_helpers/acpClient'; + +describe('acp-server session/close', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-close-')); + client = await createTestClient({ homeDir }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + it( + 'advertises the close capability and closes a live session', + async () => { + const c = await boot(); + const init = (await c.send('initialize', { protocolVersion: 1, clientCapabilities: {} })) as { + agentCapabilities?: { sessionCapabilities?: { close?: unknown } }; + }; + expect(init.agentCapabilities?.sessionCapabilities?.close).toBeDefined(); + + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + await c.send('session/close', { sessionId: created.sessionId }); + + // After close the server no longer routes the session — a follow-up + // prompt must surface invalid_params for the now-unknown sessionId. + await expect( + c.send('session/prompt', { sessionId: created.sessionId, prompt: [] }), + ).rejects.toThrow(); + }, + 30_000, + ); + + it( + 'closing an unknown sessionId is a best-effort no-op', + async () => { + const c = await boot(); + await expect(c.send('session/close', { sessionId: 'does-not-exist' })).resolves.toEqual({}); + }, + 30_000, + ); +}); diff --git a/packages/acp-server/test/config.test.ts b/packages/acp-server/test/config.test.ts new file mode 100644 index 0000000000..475b544c62 --- /dev/null +++ b/packages/acp-server/test/config.test.ts @@ -0,0 +1,110 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createTestClient, type TestClient } from './_helpers/acpClient'; + +interface ConfigOption { + readonly id: string; + readonly currentValue: string; +} + +interface NewSessionResult { + readonly sessionId: string; + readonly configOptions: readonly ConfigOption[]; +} + +describe('acp-server config surface', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-config-')); + client = await createTestClient({ homeDir }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + async function newSession(): Promise { + return (await client!.send('session/new', { + cwd: homeDir, + mcpServers: [], + })) as NewSessionResult; + } + + it( + 'session/new advertises mode + model pickers (no thinking without a model)', + async () => { + await boot(); + const { configOptions } = await newSession(); + const ids = configOptions.map((o) => o.id); + expect(ids).toContain('mode'); + expect(ids).toContain('model'); + expect(ids).not.toContain('thinking'); + const mode = configOptions.find((o) => o.id === 'mode')!; + expect(mode.currentValue).toBe('default'); + }, + 30_000, + ); + + it( + 'session/set_config_option mode updates the returned snapshot', + async () => { + await boot(); + const { sessionId } = await newSession(); + const result = (await client!.send('session/set_config_option', { + sessionId, + configId: 'mode', + value: 'yolo', + })) as { configOptions: readonly ConfigOption[] }; + const mode = result.configOptions.find((o) => o.id === 'mode')!; + expect(mode.currentValue).toBe('yolo'); + }, + 30_000, + ); + + it( + 'session/set_config_option rejects an unknown modeId', + async () => { + await boot(); + const { sessionId } = await newSession(); + await expect( + client!.send('session/set_config_option', { + sessionId, + configId: 'mode', + value: 'bogus', + }), + ).rejects.toThrow(); + }, + 30_000, + ); + + it( + 'session/set_config_option rejects an unknown configId', + async () => { + await boot(); + const { sessionId } = await newSession(); + await expect( + client!.send('session/set_config_option', { + sessionId, + configId: 'nope', + value: 'x', + }), + ).rejects.toThrow(); + }, + 30_000, + ); +}); diff --git a/packages/acp-server/test/e2e-turn.test.ts b/packages/acp-server/test/e2e-turn.test.ts new file mode 100644 index 0000000000..8718cf68e9 --- /dev/null +++ b/packages/acp-server/test/e2e-turn.test.ts @@ -0,0 +1,141 @@ +/** + * "Real" end-to-end ACP turn test. + * + * Unlike the mapper / wiring unit tests, this boots the FULL agent-core-v2 + * engine and the real ACP wire (ND-JSON over an in-memory stream), drives an + * actual `session/prompt` turn, and only fakes the network LLM call via the + * scripted-provider seam. Every layer is exercised for real: the agent turn + * loop, `ModelImpl.request`, the `generate()` stream merge, `IEventBus` + * `assistant.delta` → ACP `session/update` translation, and turn settlement. + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createTestClient, type TestClient } from './_helpers/acpClient'; +import { writeFakeModelConfig } from './_helpers/fakeModelConfig'; +import { createScriptedProvider, type ScriptedProvider } from './_helpers/scriptedProvider'; + +describe('acp-server real prompt turn (scripted LLM)', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + let scripted: ScriptedProvider | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-e2e-turn-')); + await writeFakeModelConfig(homeDir); + scripted = createScriptedProvider(); + client = await createTestClient({ homeDir, extraSeeds: [scripted.seed] }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + it( + 'drives initialize → new → prompt and streams the assistant text as agent_message_chunk', + async () => { + const c = await boot(); + scripted!.mockNextText('hello from the scripted model'); + + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + expect(created.sessionId).toMatch(/^session_/); + // Drain the post-new available_commands_update so prompt assertions only + // see turn traffic. + await c.waitForSessionUpdate('available_commands_update', 10_000); + + const promptPromise = c.send('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'say hi' }], + }); + + const chunk = await c.waitForSessionUpdate('agent_message_chunk', 10_000); + const update = (chunk.params as { update?: { content?: { text?: string } } }).update; + expect(update?.content?.text).toContain('hello from the scripted model'); + + const result = (await promptPromise) as { stopReason: string }; + expect(result.stopReason).toBe('end_turn'); + expect(scripted!.callCount()).toBe(1); + }, + 30_000, + ); + + it( + 'runs a tool call and bridges the approval request to the client', + async () => { + const c = await boot(); + // First model response: a Bash tool call. Second: a short text wrap-up + // after the tool result is fed back to the model. + scripted!.mockNextResponse({ + type: 'function', + id: 'call_1', + name: 'Bash', + arguments: '{"command":"echo hello_from_bash"}', + }); + scripted!.mockNextText('ran it'); + + // Auto-approve any permission request and record it so we can assert the + // bridge forwarded the engine's approval to the ACP client. + const permissionRequests: unknown[] = []; + c.onRequest('session/request_permission', (params) => { + permissionRequests.push(params); + return { outcome: { outcome: 'selected', optionId: 'approve_once' } }; + }); + + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + await c.waitForSessionUpdate('available_commands_update', 10_000); + + const promptPromise = c.send('session/prompt', { + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'run echo' }], + }); + + // The tool call must be created and then completed (Bash actually ran). + await c.waitForSessionUpdate('tool_call', 10_000); + await c.waitForSessionUpdate('tool_call_update', 10_000); + + const result = (await promptPromise) as { stopReason: string }; + expect(result.stopReason).toBe('end_turn'); + // Two model calls: the tool-call response and the post-tool text response. + expect(scripted!.callCount()).toBe(2); + + // The default (manual) permission mode asks before running Bash, so the + // bridge must have forwarded exactly one approval request to the client. + expect(permissionRequests).toHaveLength(1); + const req = permissionRequests[0] as { toolCall?: { toolCallId?: string } }; + expect(req.toolCall?.toolCallId).toContain('call_1'); + + // The terminal tool_call_update must report success and include the + // command's output. + type ToolCallUpdate = { + sessionUpdate?: string; + status?: string; + content?: Array<{ content?: { text?: string } }>; + }; + const terminal = c + .sessionUpdates() + .map((m) => (m.params as { update?: ToolCallUpdate }).update) + .find((u) => u?.sessionUpdate === 'tool_call_update' && u?.status === 'completed'); + expect(terminal).toBeDefined(); + const text = terminal?.content?.map((c) => c.content?.text ?? '').join('\n') ?? ''; + expect(text).toContain('hello_from_bash'); + }, + 30_000, + ); +}); diff --git a/packages/acp-server/test/initialize.test.ts b/packages/acp-server/test/initialize.test.ts new file mode 100644 index 0000000000..7b91d59777 --- /dev/null +++ b/packages/acp-server/test/initialize.test.ts @@ -0,0 +1,119 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { PassThrough, Readable, Writable } from 'node:stream'; + +import { ndJsonStream } from '@agentclientprotocol/sdk'; +import { describe, expect, it } from 'vitest'; + +import { runAcpServerWithStream } from '../src/start'; + +interface JsonRpcMessage { + readonly jsonrpc?: string; + readonly id?: number | string; + readonly method?: string; + readonly result?: unknown; + readonly error?: unknown; +} + +/** Read a single ND-JSON JSON-RPC message off a readable stream. */ +async function readOneMessage(readable: Readable): Promise { + let buf = ''; + for await (const chunk of readable) { + buf += (chunk as Buffer).toString('utf8'); + const idx = buf.indexOf('\n'); + if (idx >= 0) { + return JSON.parse(buf.slice(0, idx)) as JsonRpcMessage; + } + } + throw new Error('stream closed before a full JSON-RPC message was received'); +} + +describe('acp-server initialize handshake', () => { + it( + 'boots agent-core-v2 and answers the ACP initialize request', + async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'acp-server-init-')); + // One PassThrough per direction: writes on one side appear on the other. + const toAgent = new PassThrough(); + const toClient = new PassThrough(); + try { + const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent)); + const server = await runAcpServerWithStream(stream, { homeDir }); + + const request = { + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1, clientCapabilities: {} }, + }; + toAgent.write(`${JSON.stringify(request)}\n`); + + const response = await readOneMessage(toClient); + expect(response.id).toBe(1); + expect(response.error).toBeUndefined(); + expect(response.result).toMatchObject({ + agentCapabilities: { loadSession: true }, + }); + + await server.close(); + toAgent.end(); + toClient.end(); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }, + 30_000, + ); + + it( + 'advertises terminal-auth with forwarded env and the legacy _meta fallback', + async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'acp-server-auth-')); + const toAgent = new PassThrough(); + const toClient = new PassThrough(); + try { + const stream = ndJsonStream(Writable.toWeb(toClient), Readable.toWeb(toAgent)); + const server = await runAcpServerWithStream(stream, { + homeDir, + terminalAuthEnv: { KIMI_CODE_HOME: '/tmp/sandbox' }, + terminalAuthLegacyCommand: '/opt/kimi/bin/kimi', + }); + + toAgent.write( + `${JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: 1, clientCapabilities: {} }, + })}\n`, + ); + + const response = await readOneMessage(toClient); + const authMethods = (response.result as { authMethods?: unknown[] })?.authMethods; + expect(Array.isArray(authMethods)).toBe(true); + const method = authMethods?.[0] as { + type: string; + args: string[]; + env: Record; + _meta?: { 'terminal-auth'?: { command: string; args: string[]; env: Record } }; + }; + expect(method.type).toBe('terminal'); + expect(method.args).toEqual(['--login']); + expect(method.env).toEqual({ KIMI_CODE_HOME: '/tmp/sandbox' }); + expect(method._meta?.['terminal-auth']).toMatchObject({ + command: '/opt/kimi/bin/kimi', + args: ['login'], + env: { KIMI_CODE_HOME: '/tmp/sandbox' }, + }); + + await server.close(); + toAgent.end(); + toClient.end(); + } finally { + await rm(homeDir, { recursive: true, force: true }); + } + }, + 30_000, + ); +}); diff --git a/packages/acp-server/test/interaction-bridge.test.ts b/packages/acp-server/test/interaction-bridge.test.ts new file mode 100644 index 0000000000..e67fe69108 --- /dev/null +++ b/packages/acp-server/test/interaction-bridge.test.ts @@ -0,0 +1,199 @@ +import { describe, expect, it } from 'vitest'; + +import { AcpInteractionBridge } from '../src/interaction-bridge'; + +import type { AgentSideConnection, RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import { + type Interaction, + type ISessionInteractionService, + type ISessionScopeHandle, + ISessionInteractionService as ISessionInteractionServiceId, +} from '@moonshot-ai/agent-core-v2'; +import type { ToolInputDisplay } from '@moonshot-ai/protocol'; + +const SESSION_ID = 'session_test'; + +const commandDisplay: ToolInputDisplay = { + kind: 'command', + command: 'echo hi', +} as unknown as ToolInputDisplay; + +interface FakeInteraction { + readonly svc: ISessionInteractionService; + readonly responses: Array<{ id: string; response: unknown }>; + setPending(pending: readonly Interaction[]): void; + fire(): void; +} + +function makeFakeInteraction(): FakeInteraction { + let listener: (() => void) | undefined; + let pending: readonly Interaction[] = []; + const responses: Array<{ id: string; response: unknown }> = []; + const svc = { + onDidChangePending: (l: () => void) => { + listener = l; + return { dispose: () => { listener = undefined; } }; + }, + listPending: () => pending, + respond: (id: string, response: unknown) => { + responses.push({ id, response }); + }, + // Unused interface members stubbed for completeness. + request: () => Promise.resolve(undefined), + enqueue: () => ({ id: '', kind: 'approval', payload: undefined, origin: {}, createdAt: 0 }), + isRecentlyResolved: () => false, + onDidResolve: () => ({ dispose: () => {} }), + } as unknown as ISessionInteractionService; + return { + svc, + responses, + setPending: (p) => { pending = p; }, + fire: () => listener?.(), + }; +} + +interface FakeConn { + readonly conn: AgentSideConnection; + readonly calls: Array>; +} + +function makeFakeConn(handler: (params: Record) => RequestPermissionResponse): FakeConn { + const calls: Array> = []; + const conn = { + requestPermission: async (params: Record) => { + calls.push(params); + return handler(params); + }, + } as unknown as AgentSideConnection; + return { conn, calls }; +} + +function makeSessionHandle(svc: ISessionInteractionService): ISessionScopeHandle { + return { + accessor: { + get: (id: unknown) => { + if (id === ISessionInteractionServiceId) return svc; + throw new Error(`unexpected service request: ${String(id)}`); + }, + }, + } as unknown as ISessionScopeHandle; +} + +async function flush(): Promise { + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +const approvalInteraction: Interaction = { + id: 'approval-1', + kind: 'approval', + payload: { + toolName: 'Bash', + action: 'run `echo hi`', + toolCallId: 'call_1', + turnId: 3, + display: commandDisplay, + }, + origin: { turnId: 3 }, + createdAt: 0, +}; + +describe('AcpInteractionBridge', () => { + it('forwards an approval request to the client and responds with the decision', async () => { + const interaction = makeFakeInteraction(); + const { conn, calls } = makeFakeConn(() => ({ outcome: { outcome: 'selected', optionId: 'approve_once' } })); + interaction.setPending([approvalInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + await flush(); + + expect(calls).toHaveLength(1); + expect(calls[0]).toMatchObject({ + sessionId: SESSION_ID, + toolCall: { toolCallId: '3:call_1', title: 'Bash' }, + }); + expect(interaction.responses).toEqual([{ id: 'approval-1', response: { decision: 'approved', selectedLabel: 'Approve once' } }]); + bridge.dispose(); + }); + + it('maps approve_always to a session-scoped approval', async () => { + const interaction = makeFakeInteraction(); + const { conn } = makeFakeConn(() => ({ outcome: { outcome: 'selected', optionId: 'approve_always' } })); + interaction.setPending([approvalInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + await flush(); + + expect(interaction.responses[0]?.response).toEqual({ + decision: 'approved', + scope: 'session', + selectedLabel: 'Approve for this session', + }); + bridge.dispose(); + }); + + it('responds rejected when the client RPC fails', async () => { + const interaction = makeFakeInteraction(); + const conn = { + requestPermission: async () => { throw new Error('transport dropped'); }, + } as unknown as AgentSideConnection; + interaction.setPending([approvalInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + await flush(); + + expect(interaction.responses).toEqual([{ id: 'approval-1', response: { decision: 'rejected' } }]); + bridge.dispose(); + }); + + it('forwards a question request and responds with the answer', async () => { + const interaction = makeFakeInteraction(); + const { conn, calls } = makeFakeConn(() => ({ outcome: { outcome: 'selected', optionId: 'q0_opt_0' } })); + const questionInteraction: Interaction = { + id: 'question-1', + kind: 'question', + payload: { + toolCallId: 'tc_q', + turnId: 5, + questions: [{ question: 'Pick one', options: [{ label: 'A' }, { label: 'B' }] }], + }, + origin: { turnId: 5 }, + createdAt: 0, + }; + interaction.setPending([questionInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + await flush(); + + expect(calls[0]).toMatchObject({ toolCall: { toolCallId: '5:tc_q', title: 'AskUserQuestion' } }); + expect(interaction.responses).toEqual([{ id: 'question-1', response: { 'Pick one': 'A' } }]); + bridge.dispose(); + }); + + it('ignores non-approval/question interactions', async () => { + const interaction = makeFakeInteraction(); + const { conn, calls } = makeFakeConn(() => ({ outcome: { outcome: 'cancelled' } })); + const userToolInteraction: Interaction = { + id: 'ut-1', + kind: 'user_tool', + payload: {}, + origin: {}, + createdAt: 0, + }; + interaction.setPending([userToolInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + await flush(); + + expect(calls).toHaveLength(0); + expect(interaction.responses).toEqual([]); + bridge.dispose(); + }); + + it('does not double-handle the same pending id across change events', async () => { + const interaction = makeFakeInteraction(); + const { conn, calls } = makeFakeConn(() => ({ outcome: { outcome: 'selected', optionId: 'approve_once' } })); + interaction.setPending([approvalInteraction]); + const bridge = new AcpInteractionBridge(conn, makeSessionHandle(interaction.svc), SESSION_ID); + interaction.fire(); + interaction.fire(); + await flush(); + + expect(calls).toHaveLength(1); + bridge.dispose(); + }); +}); diff --git a/packages/acp-server/test/lifecycle.test.ts b/packages/acp-server/test/lifecycle.test.ts new file mode 100644 index 0000000000..5b2b07b892 --- /dev/null +++ b/packages/acp-server/test/lifecycle.test.ts @@ -0,0 +1,88 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createTestClient, type TestClient } from './_helpers/acpClient'; + +describe('acp-server session lifecycle', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-lifecycle-')); + client = await createTestClient({ homeDir }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + it( + 'session/new creates a live session and session/list returns it', + async () => { + const c = await boot(); + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + expect(created.sessionId).toMatch(/^session_/); + + const listed = (await c.send('session/list', {})) as { + sessions: { sessionId: string }[]; + }; + expect(listed.sessions.some((s) => s.sessionId === created.sessionId)).toBe(true); + }, + 30_000, + ); + + it( + 'session/resume on an unknown sessionId fails with invalid_params', + async () => { + const c = await boot(); + await expect( + c.send('session/resume', { sessionId: 'does-not-exist', cwd: homeDir, mcpServers: [] }), + ).rejects.toThrow(); + }, + 30_000, + ); + + it( + 'session/load replays (empty) history and returns configOptions', + async () => { + const c = await boot(); + const created = (await c.send('session/new', { cwd: homeDir, mcpServers: [] })) as { + sessionId: string; + }; + // Drain the available_commands_update pushed after new so the load + // replay assertion only sees load-time notifications. + await c.waitForSessionUpdate('available_commands_update', 10_000); + const before = c.sessionUpdates().length; + + const loaded = (await c.send('session/load', { + sessionId: created.sessionId, + cwd: homeDir, + mcpServers: [], + })) as { configOptions?: unknown[] }; + expect(Array.isArray(loaded.configOptions)).toBe(true); + // A brand-new session has no persisted history, so load must not emit + // any user/agent/tool replay chunks (only the post-load commands push). + const replayed = c + .sessionUpdates() + .slice(before) + .map((m) => (m.params as { update?: { sessionUpdate?: string } }).update?.sessionUpdate) + .filter((k) => k !== 'available_commands_update'); + expect(replayed).toEqual([]); + }, + 30_000, + ); +}); diff --git a/packages/acp-server/test/question.test.ts b/packages/acp-server/test/question.test.ts new file mode 100644 index 0000000000..a64259ff69 --- /dev/null +++ b/packages/acp-server/test/question.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; + +import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from '../src/question'; + +import type { RequestPermissionResponse } from '@agentclientprotocol/sdk'; +import type { QuestionItem } from '@moonshot-ai/agent-core-v2'; + +function selected(optionId: string): RequestPermissionResponse { + return { outcome: { outcome: 'selected', optionId } }; +} + +const cancelled: RequestPermissionResponse = { outcome: { outcome: 'cancelled' } }; + +const sampleQuestion: QuestionItem = { + question: 'Pick a color', + options: [{ label: 'Red' }, { label: 'Green' }, { label: 'Blue' }], +}; + +describe('questionItemToPermissionOptions', () => { + it('maps each option to an allow_once plus a trailing Skip reject', () => { + const options = questionItemToPermissionOptions(sampleQuestion, 0); + expect(options.map((o) => o.optionId)).toEqual([ + 'q0_opt_0', + 'q0_opt_1', + 'q0_opt_2', + 'q0_skip', + ]); + expect(options[0]).toMatchObject({ name: 'Red', kind: 'allow_once' }); + expect(options.at(-1)).toMatchObject({ name: 'Skip', kind: 'reject_once' }); + }); +}); + +describe('outcomeToQuestionAnswer', () => { + it('returns the selected label keyed by the question text', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_1'))).toEqual({ + 'Pick a color': 'Green', + }); + }); + + it('returns null on cancel', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, cancelled)).toBeNull(); + }); + + it('returns null on skip', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_skip'))).toBeNull(); + }); + + it('returns null on an out-of-bounds or unknown optionId', () => { + expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_99'))).toBeNull(); + expect(outcomeToQuestionAnswer(sampleQuestion, selected('mystery'))).toBeNull(); + }); +}); diff --git a/packages/acp-server/test/replay.test.ts b/packages/acp-server/test/replay.test.ts new file mode 100644 index 0000000000..9db7fb4869 --- /dev/null +++ b/packages/acp-server/test/replay.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; + +import { projectHistoryToSessionUpdates } from '../src/replay'; + +import type { SessionNotification } from '@agentclientprotocol/sdk'; +import type { ContextMessage } from '@moonshot-ai/agent-core-v2'; + +const SESSION_ID = 'session_test'; + +function kinds(updates: readonly SessionNotification[]): string[] { + return updates.map((u) => u.update.sessionUpdate); +} + +describe('projectHistoryToSessionUpdates', () => { + it('returns an empty array for an empty history', () => { + expect(projectHistoryToSessionUpdates(SESSION_ID, [])).toEqual([]); + }); + + it('projects a user text message to a user_message_chunk', () => { + const messages: ContextMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'hi' }], toolCalls: [] }, + ]; + const updates = projectHistoryToSessionUpdates(SESSION_ID, messages); + expect(kinds(updates)).toEqual(['user_message_chunk']); + expect(updates[0]?.update).toMatchObject({ + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hi' }, + }); + }); + + it('projects an assistant text + tool call and correlates the tool result', () => { + const messages: ContextMessage[] = [ + { role: 'user', content: [{ type: 'text', text: 'read a.ts' }], toolCalls: [] }, + { + role: 'assistant', + content: [{ type: 'text', text: 'reading' }], + toolCalls: [{ type: 'function', id: 'c1', name: 'Read', arguments: '{"path":"a.ts"}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'file body' }], + toolCalls: [], + toolCallId: 'c1', + }, + ]; + const updates = projectHistoryToSessionUpdates(SESSION_ID, messages); + expect(kinds(updates)).toEqual([ + 'user_message_chunk', + 'agent_message_chunk', + 'tool_call', + 'tool_call_update', + ]); + const create = updates[2]?.update; + expect(create).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: '1:c1', + status: 'in_progress', + }); + const done = updates[3]?.update; + expect(done).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: '1:c1', + status: 'completed', + }); + }); + + it('marks an errored tool result as failed', () => { + const messages: ContextMessage[] = [ + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'c1', name: 'Bash', arguments: '{}' }], + }, + { + role: 'tool', + content: [{ type: 'text', text: 'boom' }], + toolCalls: [], + toolCallId: 'c1', + isError: true, + }, + ]; + const updates = projectHistoryToSessionUpdates(SESSION_ID, messages); + expect(updates.at(-1)?.update).toMatchObject({ status: 'failed' }); + }); + + it('projects a think part to an agent_thought_chunk', () => { + const messages: ContextMessage[] = [ + { + role: 'assistant', + content: [{ type: 'think', think: 'hmm' }], + toolCalls: [], + }, + ]; + const updates = projectHistoryToSessionUpdates(SESSION_ID, messages); + expect(kinds(updates)).toEqual(['agent_thought_chunk']); + }); + + it('skips a tool message whose call was never issued in this slice', () => { + const messages: ContextMessage[] = [ + { + role: 'tool', + content: [{ type: 'text', text: 'orphan' }], + toolCalls: [], + toolCallId: 'unknown', + }, + ]; + expect(projectHistoryToSessionUpdates(SESSION_ID, messages)).toEqual([]); + }); + + it('increments the synthetic turnId per assistant message', () => { + const messages: ContextMessage[] = [ + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'a', name: 'Read', arguments: '{}' }], + }, + { + role: 'assistant', + content: [], + toolCalls: [{ type: 'function', id: 'b', name: 'Read', arguments: '{}' }], + }, + ]; + const updates = projectHistoryToSessionUpdates(SESSION_ID, messages); + const ids = updates + .filter((u) => u.update.sessionUpdate === 'tool_call') + .map((u) => (u.update as { toolCallId: string }).toolCallId); + expect(ids).toEqual(['1:a', '2:b']); + }); +}); diff --git a/packages/acp-server/test/skills.test.ts b/packages/acp-server/test/skills.test.ts new file mode 100644 index 0000000000..fe9ede90c1 --- /dev/null +++ b/packages/acp-server/test/skills.test.ts @@ -0,0 +1,53 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { createTestClient, type TestClient } from './_helpers/acpClient'; + +interface AvailableCommand { + readonly name: string; +} + +describe('acp-server skills / available commands', () => { + let homeDir: string | undefined; + let client: TestClient | undefined; + + afterEach(async () => { + if (client !== undefined) { + await client.close(); + client = undefined; + } + if (homeDir !== undefined) { + await rm(homeDir, { recursive: true, force: true }); + homeDir = undefined; + } + }); + + async function boot(): Promise { + homeDir = await mkdtemp(join(tmpdir(), 'acp-skills-')); + client = await createTestClient({ homeDir }); + await client.send('initialize', { protocolVersion: 1, clientCapabilities: {} }); + return client; + } + + it( + 'session/new pushes an available_commands_update containing the builtin commands', + async () => { + const c = await boot(); + await c.send('session/new', { cwd: homeDir, mcpServers: [] }); + + const notification = await c.waitForSessionUpdate('available_commands_update', 10_000); + const params = notification.params as { + update: { availableCommands: readonly AvailableCommand[] }; + }; + const names = params.update.availableCommands.map((command) => command.name); + // The ACP-owned builtin commands are always advertised. + expect(names).toContain('compact'); + expect(names).toContain('status'); + expect(names).toContain('help'); + }, + 30_000, + ); +}); diff --git a/packages/acp-server/tsconfig.json b/packages/acp-server/tsconfig.json new file mode 100644 index 0000000000..ebeba8004b --- /dev/null +++ b/packages/acp-server/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "experimentalDecorators": true + }, + "include": ["src", "test", "../agent-core-v2/src/env.d.ts"] +} diff --git a/packages/acp-server/tsdown.config.ts b/packages/acp-server/tsdown.config.ts new file mode 100644 index 0000000000..cb99d9ffb8 --- /dev/null +++ b/packages/acp-server/tsdown.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from 'tsdown'; + +export default defineConfig({ + entry: ['./src/index.ts'], + format: ['esm'], + dts: true, + outDir: 'dist', + clean: true, +}); diff --git a/packages/acp-server/vitest.config.ts b/packages/acp-server/vitest.config.ts new file mode 100644 index 0000000000..80c7216750 --- /dev/null +++ b/packages/acp-server/vitest.config.ts @@ -0,0 +1,53 @@ +import { existsSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +import { defineConfig, type Plugin } from 'vitest/config'; + +import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; + +function findPackageRoot(importer: string | undefined): string | undefined { + if (!importer) return undefined; + let dir = dirname(importer.split('?')[0] ?? importer); + for (;;) { + if (existsSync(join(dir, 'package.json'))) return dir; + const parent = dirname(dir); + if (parent === dir) return undefined; + dir = parent; + } +} + +/** + * Resolve `#/` subpath imports the way Node's package.json `imports` field does, + * scoped to the importer's owning package. acp-server pulls in + * `@moonshot-ai/agent-core-v2` source (the full barrel), whose internal `#/foo` + * imports must resolve against that package's own `src/`. + * + * Mirrors `packages/server-v2/vitest.config.ts`. + */ +function hashImportsPlugin(): Plugin { + return { + name: 'resolve-hash-imports', + enforce: 'pre', + resolveId(id, importer) { + if (!id.startsWith('#/')) return null; + const pkgRoot = findPackageRoot(importer); + if (!pkgRoot) return null; + const sub = id.slice(2); + for (const candidate of [`src/${sub}.ts`, `src/${sub}/index.ts`]) { + const full = join(pkgRoot, candidate); + if (existsSync(full)) return full; + } + return null; + }, + }; +} + +export default defineConfig({ + // `rawTextPlugin` is required because acp-server pulls in agent-core-v2's + // full barrel, which imports `*.md?raw` prompt templates. + plugins: [rawTextPlugin(), hashImportsPlugin()], + test: { + name: 'acp-server', + include: ['test/**/*.{test,e2e}.ts'], + }, +}); diff --git a/packages/agent-core-v2/src/index.ts b/packages/agent-core-v2/src/index.ts index e025fb8fc7..3774152df2 100644 --- a/packages/agent-core-v2/src/index.ts +++ b/packages/agent-core-v2/src/index.ts @@ -249,7 +249,12 @@ export * from '#/session/sessionActivity/sessionActivityService'; import '#/session/approval/approval'; import '#/session/approval/approvalService'; -export { ISessionApprovalService } from '#/session/approval/approval'; +export { + ISessionApprovalService, + type ApprovalDecision, + type ApprovalRequest as SessionApprovalRequest, + type ApprovalResponse as SessionApprovalResponse, +} from '#/session/approval/approval'; export * from '#/session/question/question'; export * from '#/session/question/questionService'; import '#/agent/questionTools/tools/ask-user'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6813e48eb7..3961e57a37 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -75,6 +75,9 @@ importers: '@moonshot-ai/acp-adapter': specifier: workspace:^ version: link:../../packages/acp-adapter + '@moonshot-ai/acp-server': + specifier: workspace:^ + version: link:../../packages/acp-server '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../../packages/agent-core-v2 @@ -358,6 +361,18 @@ importers: specifier: ^1.6.1 version: 1.6.1 + packages/acp-server: + dependencies: + '@agentclientprotocol/sdk': + specifier: ^0.23.0 + version: 0.23.0(zod@4.3.6) + '@moonshot-ai/agent-core-v2': + specifier: workspace:^ + version: link:../agent-core-v2 + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol + packages/agent-core: dependencies: '@antfu/utils': From 9d71843e437bb2e921d71fa0605060be3835abac Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 13:07:09 +0800 Subject: [PATCH 2/6] test: use neutral example domains in test fixtures and docs - replace placeholder hostnames (evil.com, foo.com, internal.corp, real.corp) with example.test / example.com in agent-core-v2 and kap-server tests - replace fixture emails (x@y.com, a@x.com) with example addresses in minidb tests and README --- .../test/_base/utils/proxy.test.ts | 8 +++--- .../test/session/sessionFs/gitContext.test.ts | 4 +-- packages/kap-server/test/hostnames.test.ts | 13 +++++---- packages/kap-server/test/origin.test.ts | 27 +++++++++--------- packages/minidb/README.md | 4 +-- packages/minidb/test/batch.test.ts | 4 +-- .../minidb/test/e2e/index-consistency.test.ts | 2 +- packages/minidb/test/indexes.test.ts | 6 ++-- packages/minidb/test/review-fixes.test.ts | 10 +++---- packages/minidb/test/review-round2.test.ts | 28 +++++++++---------- 10 files changed, 55 insertions(+), 51 deletions(-) diff --git a/packages/agent-core-v2/test/_base/utils/proxy.test.ts b/packages/agent-core-v2/test/_base/utils/proxy.test.ts index 404e47dd6d..3e7f0d6c3e 100644 --- a/packages/agent-core-v2/test/_base/utils/proxy.test.ts +++ b/packages/agent-core-v2/test/_base/utils/proxy.test.ts @@ -63,7 +63,7 @@ describe('proxy utilities', () => { expect(bypass('example.com')).toBe(true); expect(bypass('sub.example.com')).toBe(true); expect(bypass('[::1]')).toBe(true); - expect(bypass('other.com')).toBe(false); + expect(bypass('other.example.test')).toBe(false); const portBypass = makeNoProxyMatcher('api.example.com:443'); expect(portBypass('api.example.com', 443)).toBe(true); @@ -140,8 +140,8 @@ describe('proxy utilities', () => { NO_PROXY: 'aug', no_proxy: 'aug', }; - reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'real.corp' }); - expect(childEnv['NO_PROXY']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); - expect(childEnv['no_proxy']).toBe('real.corp,localhost,127.0.0.1,::1,[::1]'); + reconcileChildNoProxy(childEnv, { no_proxy: '', NO_PROXY: 'internal.example.test' }); + expect(childEnv['NO_PROXY']).toBe('internal.example.test,localhost,127.0.0.1,::1,[::1]'); + expect(childEnv['no_proxy']).toBe('internal.example.test,localhost,127.0.0.1,::1,[::1]'); }); }); diff --git a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts b/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts index 64b3b40bc3..29f7409a50 100644 --- a/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts +++ b/packages/agent-core-v2/test/session/sessionFs/gitContext.test.ts @@ -163,7 +163,7 @@ describe('collectGitContext', () => { it('omits both Remote and Project for a disallowed remote host', async () => { const { runner } = gitRunner({ 'rev-parse --is-inside-work-tree': { stdout: 'true' }, - 'remote get-url origin': { stdout: 'git@internal.corp:secret/repo.git' }, + 'remote get-url origin': { stdout: 'git@internal.example.test:secret/repo.git' }, 'symbolic-ref --short HEAD': { stdout: 'main' }, 'status --porcelain': { stdout: '' }, 'log -3 --format=%h %s': { stdout: '' }, @@ -286,7 +286,7 @@ describe('remote url helpers', () => { }); it('rejects private hosts', () => { - expect(sanitizeRemoteUrl('https://git.example.internal/owner/repo.git')).toBeNull(); + expect(sanitizeRemoteUrl('https://git.example.test/owner/repo.git')).toBeNull(); }); it('parses project names from ssh and https urls', () => { diff --git a/packages/kap-server/test/hostnames.test.ts b/packages/kap-server/test/hostnames.test.ts index 1a59f06728..507c241343 100644 --- a/packages/kap-server/test/hostnames.test.ts +++ b/packages/kap-server/test/hostnames.test.ts @@ -50,7 +50,7 @@ describe('isAllowedHost (default allow set)', () => { }); } - const deny = ['evil.com', 'evil.com:80', '127.0.0.1.evil.com']; + const deny = ['evil.example.test', 'evil.example.test:80', '127.0.0.1.evil.example.test']; for (const host of deny) { it(`denies ${host}`, () => { @@ -97,13 +97,16 @@ describe('isAllowedHost (extra)', () => { describe('isAllowedHost (disable)', () => { it('allows everything when disabled', () => { - expect(isAllowedHost('evil.com', { disable: true })).toBe(true); + expect(isAllowedHost('evil.example.test', { disable: true })).toBe(true); }); }); describe('parseAllowedHosts', () => { it('splits, trims, and drops empties', () => { - expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.com, ' })).toEqual(['a', '.b.com']); + expect(parseAllowedHosts({ KIMI_CODE_ALLOWED_HOSTS: ' a, .b.example.com, ' })).toEqual([ + 'a', + '.b.example.com', + ]); }); it('returns [] when unset', () => { @@ -139,13 +142,13 @@ describe('createHostCheck (onRequest hook)', () => { const res = await app.inject({ method: 'GET', url: '/api/v1/probe', - headers: { host: 'evil.com' }, + headers: { host: 'evil.example.test' }, }); expect(res.statusCode).toBe(403); const body = res.json() as Record; expect(body['code']).toBe(40301); expect(body['msg']).toBe( - "Invalid Host header: evil.com; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.com or 'kimi server run --allowed-host evil.com'.", + "Invalid Host header: evil.example.test; allow this host with KIMI_CODE_ALLOWED_HOSTS=evil.example.test or 'kimi server run --allowed-host evil.example.test'.", ); expect(body['data']).toBeNull(); expect(typeof body['request_id']).toBe('string'); diff --git a/packages/kap-server/test/origin.test.ts b/packages/kap-server/test/origin.test.ts index 2fac324205..9af7e96231 100644 --- a/packages/kap-server/test/origin.test.ts +++ b/packages/kap-server/test/origin.test.ts @@ -10,7 +10,7 @@ import { describe('originHost', () => { it('returns the host for a valid origin', () => { - expect(originHost('https://foo.com')).toBe('foo.com'); + expect(originHost('https://foo.example.com')).toBe('foo.example.com'); }); it('drops the default port', () => { @@ -36,11 +36,11 @@ describe('isOriginAllowed', () => { }); it('denies cross-origin that is not whitelisted', () => { - expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false); + expect(isOriginAllowed('http://evil.example.test', 'localhost:80', [])).toBe(false); }); it('allows cross-origin that is whitelisted', () => { - expect(isOriginAllowed('https://foo.com', 'localhost:80', ['https://foo.com'])).toBe(true); + expect(isOriginAllowed('https://foo.example.com', 'localhost:80', ['https://foo.example.com'])).toBe(true); }); it('allows an absent origin', () => { @@ -64,7 +64,7 @@ describe('isOriginAllowed', () => { }); it('still denies a non-loopback cross-origin that is not whitelisted', () => { - expect(isOriginAllowed('http://evil.com', 'localhost:80', [])).toBe(false); + expect(isOriginAllowed('http://evil.example.test', 'localhost:80', [])).toBe(false); }); it('does not widen to a public host even when the origin is loopback', () => { @@ -74,10 +74,11 @@ describe('isOriginAllowed', () => { describe('parseCorsOrigins', () => { it('splits, trims, and drops empties', () => { - expect(parseCorsOrigins({ KIMI_CODE_CORS_ORIGINS: ' https://a.com, https://b.com, ' })).toEqual([ - 'https://a.com', - 'https://b.com', - ]); + expect( + parseCorsOrigins({ + KIMI_CODE_CORS_ORIGINS: ' https://a.example.com, https://b.example.com, ', + }), + ).toEqual(['https://a.example.com', 'https://b.example.com']); }); it('returns [] when unset', () => { @@ -90,7 +91,7 @@ describe('createOriginHook (onRequest hook)', () => { beforeEach(async () => { app = Fastify(); - app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.com'] })); + app.addHook('onRequest', createOriginHook({ allowedOrigins: ['https://foo.example.com'] })); app.get('/api/v1/probe', async () => ({ ok: true })); app.options('/api/v1/probe', async () => ({ ok: true })); await app.ready(); @@ -114,10 +115,10 @@ describe('createOriginHook (onRequest hook)', () => { const res = await app.inject({ method: 'OPTIONS', url: '/api/v1/probe', - headers: { origin: 'https://foo.com', host: 'localhost:80' }, + headers: { origin: 'https://foo.example.com', host: 'localhost:80' }, }); expect(res.statusCode).toBe(204); - expect(res.headers['access-control-allow-origin']).toBe('https://foo.com'); + expect(res.headers['access-control-allow-origin']).toBe('https://foo.example.com'); expect(res.headers['access-control-allow-methods']).toBe( 'GET, POST, PUT, PATCH, DELETE, OPTIONS', ); @@ -127,7 +128,7 @@ describe('createOriginHook (onRequest hook)', () => { const res = await app.inject({ method: 'GET', url: '/api/v1/probe', - headers: { origin: 'http://evil.com', host: 'localhost:80' }, + headers: { origin: 'http://evil.example.test', host: 'localhost:80' }, }); expect(res.statusCode).toBe(200); expect(res.headers['access-control-allow-origin']).toBeUndefined(); @@ -137,7 +138,7 @@ describe('createOriginHook (onRequest hook)', () => { const res = await app.inject({ method: 'OPTIONS', url: '/api/v1/probe', - headers: { origin: 'http://evil.com', host: 'localhost:80' }, + headers: { origin: 'http://evil.example.test', host: 'localhost:80' }, }); expect(res.statusCode).toBe(204); expect(res.headers['access-control-allow-origin']).toBeUndefined(); diff --git a/packages/minidb/README.md b/packages/minidb/README.md index adb55ea97c..91708fa4c1 100644 --- a/packages/minidb/README.md +++ b/packages/minidb/README.md @@ -106,8 +106,8 @@ await db.createIndex('byCity', { field: 'city' }); // equality await db.createIndex('byAge', { field: 'age', type: 'range' }); // range (skip list) await db.createIndex('byMail', { field: 'email', unique: true }); // unique -await db.set('u1', { name: 'Ann', city: 'Paris', age: 30, email: 'a@x.com' }); -await db.set('u2', { name: 'Bob', city: 'Paris', age: 41, email: 'b@x.com' }); +await db.set('u1', { name: 'Ann', city: 'Paris', age: 30, email: 'ann@example.com' }); +await db.set('u2', { name: 'Bob', city: 'Paris', age: 41, email: 'bob@example.com' }); db.findEq('byCity', 'Paris'); // [{ key:'u1', value:{...} }, { key:'u2', ... }] db.findRange('byAge', { min: 30, max: 40, count: 10 }); diff --git a/packages/minidb/test/batch.test.ts b/packages/minidb/test/batch.test.ts index a03e4aed7b..d33c4c768f 100644 --- a/packages/minidb/test/batch.test.ts +++ b/packages/minidb/test/batch.test.ts @@ -49,10 +49,10 @@ test('batch unique violation rejects the whole batch', async () => { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byEmail', { field: 'email', unique: true }); - await db.set('a', { email: 'x@y.com' }); + await db.set('a', { email: 'duplicate@example.test' }); try { await assert.rejects( - () => db.batch([{ op: 'set', key: 'b', value: { email: 'ok@y.com' } }, { op: 'set', key: 'c', value: { email: 'x@y.com' } }]), + () => db.batch([{ op: 'set', key: 'b', value: { email: 'unique@example.test' } }, { op: 'set', key: 'c', value: { email: 'duplicate@example.test' } }]), /unique/, ); // neither op applied diff --git a/packages/minidb/test/e2e/index-consistency.test.ts b/packages/minidb/test/e2e/index-consistency.test.ts index b83bbbcb3c..4f5a5538ee 100644 --- a/packages/minidb/test/e2e/index-consistency.test.ts +++ b/packages/minidb/test/e2e/index-consistency.test.ts @@ -18,7 +18,7 @@ function randomDoc(rng) { return { city: pick(rng, CITIES), age: randInt(rng, 60), - email: 'u' + randInt(rng, 100000) + '@x.com', + email: 'u' + randInt(rng, 100000) + '@example.test', bio: pick(rng, BIOS), }; } diff --git a/packages/minidb/test/indexes.test.ts b/packages/minidb/test/indexes.test.ts index 1fbef4f411..5ef04b0cb3 100644 --- a/packages/minidb/test/indexes.test.ts +++ b/packages/minidb/test/indexes.test.ts @@ -46,10 +46,10 @@ test('unique index rejects duplicates', async () => { try { const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byEmail', { field: 'email', unique: true }); - await db.set('a', { email: 'x@y.com' }); - await assert.rejects(() => db.set('b', { email: 'x@y.com' }), UniqueViolationError); + await db.set('a', { email: 'user@example.test' }); + await assert.rejects(() => db.set('b', { email: 'user@example.test' }), UniqueViolationError); // Re-setting the same key with the same value is allowed. - await db.set('a', { email: 'x@y.com' }); + await db.set('a', { email: 'user@example.test' }); assert.equal(db.size, 1); await db.close(); } finally { diff --git a/packages/minidb/test/review-fixes.test.ts b/packages/minidb/test/review-fixes.test.ts index 0b761bab35..fa5fd4ace4 100644 --- a/packages/minidb/test/review-fixes.test.ts +++ b/packages/minidb/test/review-fixes.test.ts @@ -107,8 +107,8 @@ test('batch() rejects intra-batch unique violations', async () => { await db.createIndex('byMail', { field: 'email', unique: true }); await assert.rejects( db.batch([ - { op: 'set', key: 'a', value: { email: 'dup@x.com' } }, - { op: 'set', key: 'b', value: { email: 'dup@x.com' } }, + { op: 'set', key: 'a', value: { email: 'duplicate@example.test' } }, + { op: 'set', key: 'b', value: { email: 'duplicate@example.test' } }, ]), /unique/i, ); @@ -150,11 +150,11 @@ test('concurrent sets cannot both commit the same unique value', async () => { const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byMail', { field: 'email', unique: true }); const results = await Promise.allSettled([ - db.set('a', { email: 'same@x.com' }), - db.set('b', { email: 'same@x.com' }), + db.set('a', { email: 'shared@example.test' }), + db.set('b', { email: 'shared@example.test' }), ]); const committed = results.filter((r) => r.status === 'fulfilled').length; - const hits = db.findEq('byMail', 'same@x.com'); + const hits = db.findEq('byMail', 'shared@example.test'); await db.close(); assert.ok(committed <= 1, `both committed: ${JSON.stringify(hits)}`); assert.ok(hits.length <= 1, `unique violated: ${JSON.stringify(hits)}`); diff --git a/packages/minidb/test/review-round2.test.ts b/packages/minidb/test/review-round2.test.ts index ff29d813ea..9171b12f7d 100644 --- a/packages/minidb/test/review-round2.test.ts +++ b/packages/minidb/test/review-round2.test.ts @@ -190,14 +190,14 @@ test('batch allows swapping a unique value between two keys', async () => { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byMail', { field: 'email', unique: true }); - await db.set('u1', { email: 'a@x.com' }); - await db.set('u2', { email: 'b@x.com' }); + await db.set('u1', { email: 'alice@example.test' }); + await db.set('u2', { email: 'bob@example.test' }); await db.batch([ - { op: 'set', key: 'u1', value: { email: 'b@x.com' } }, - { op: 'set', key: 'u2', value: { email: 'a@x.com' } }, + { op: 'set', key: 'u1', value: { email: 'bob@example.test' } }, + { op: 'set', key: 'u2', value: { email: 'alice@example.test' } }, ]); - assert.equal(db.get('u1')?.email, 'b@x.com'); - assert.equal(db.get('u2')?.email, 'a@x.com'); + assert.equal(db.get('u1')?.email, 'bob@example.test'); + assert.equal(db.get('u2')?.email, 'alice@example.test'); await db.close(); await fs.rm(dir, { recursive: true, force: true }); }); @@ -206,14 +206,14 @@ test('batch allows del(u1) + set(u2) reusing u1 unique value', async () => { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byMail', { field: 'email', unique: true }); - await db.set('u1', { email: 'x@y.com' }); + await db.set('u1', { email: 'shared@example.test' }); await db.batch([ { op: 'del', key: 'u1' }, - { op: 'set', key: 'u2', value: { email: 'x@y.com' } }, + { op: 'set', key: 'u2', value: { email: 'shared@example.test' } }, ]); assert.equal(db.get('u1'), undefined); - assert.equal(db.get('u2')?.email, 'x@y.com'); - assert.deepEqual(db.findEq('byMail', 'x@y.com').map((r) => r.key), ['u2']); + assert.equal(db.get('u2')?.email, 'shared@example.test'); + assert.deepEqual(db.findEq('byMail', 'shared@example.test').map((r) => r.key), ['u2']); await db.close(); await fs.rm(dir, { recursive: true, force: true }); }); @@ -222,8 +222,8 @@ test('batch still rejects genuine unique violations', async () => { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'json' }); await db.createIndex('byMail', { field: 'email', unique: true }); - await db.set('u1', { email: 'x@y.com' }); - await assert.rejects(db.batch([{ op: 'set', key: 'u2', value: { email: 'x@y.com' } }]), /unique/i); + await db.set('u1', { email: 'duplicate@example.test' }); + await assert.rejects(db.batch([{ op: 'set', key: 'u2', value: { email: 'duplicate@example.test' } }]), /unique/i); assert.equal(db.get('u2'), undefined); await db.close(); await fs.rm(dir, { recursive: true, force: true }); @@ -234,8 +234,8 @@ test('batch still rejects genuine unique violations', async () => { test('createIndex(unique) rejects existing duplicate data', async () => { const dir = await tmpDir(); const db = await MiniDb.open({ dir, valueCodec: 'json' }); - await db.set('a', { email: 'dup@x.com' }); - await db.set('b', { email: 'dup@x.com' }); + await db.set('a', { email: 'duplicate@example.test' }); + await db.set('b', { email: 'duplicate@example.test' }); await assert.rejects(db.createIndex('byMail', { field: 'email', unique: true }), /unique/i); assert.equal(db.listIndexes().length, 0, 'index must not persist after failed create'); await db.close(); From 6bcb4e00a80c5a8396769d2f31526e0f774b6250 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 16:33:04 +0800 Subject: [PATCH 3/6] fix(acp): align acp-server with agent-core-v2 interfaces and address review - add missing appendText to AcpHostFileSystem (IHostFileSystem drift) - replace IAgentPromptService.prompt with inject - use Turn.cancel() instead of abortController - gate FS reverse-RPCs on client capabilities, fallback to local FS - return PROTOCOL_VERSION constant instead of echoing client version - remove misleading mcpCapabilities from initialize response - dispose old session wrapper before replacing on load/resume - fix object stringification lint error in convert.ts - add acp-v2 to expected CLI sub-command list in test --- apps/kimi-code/test/cli/options.test.ts | 1 + .../acp-server/src/acp-fs/acpConnection.ts | 21 +++++++++++++++++++ .../acp-server/src/acp-fs/acpFsService.ts | 12 ++++++++++- packages/acp-server/src/convert.ts | 2 +- packages/acp-server/src/server.ts | 13 +++++++----- packages/acp-server/src/session.ts | 6 +++--- 6 files changed, 45 insertions(+), 10 deletions(-) diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index b15ed315fe..f5ed0d7e57 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -462,6 +462,7 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', + 'acp-v2', 'server', 'web', 'login', diff --git a/packages/acp-server/src/acp-fs/acpConnection.ts b/packages/acp-server/src/acp-fs/acpConnection.ts index b89b098228..8d3d98412c 100644 --- a/packages/acp-server/src/acp-fs/acpConnection.ts +++ b/packages/acp-server/src/acp-fs/acpConnection.ts @@ -45,6 +45,12 @@ export interface IAcpConnection { get(): IAcpFsClient; /** Whether a client connection has been bound. */ readonly bound: boolean; + /** Bind the client's FS capabilities (from the `initialize` handshake). */ + bindFsCapabilities(fs: { readTextFile?: boolean; writeTextFile?: boolean } | undefined): void; + /** Whether the client supports `fs.readTextFile`. */ + readonly fsReadTextFile: boolean; + /** Whether the client supports `fs.writeTextFile`. */ + readonly fsWriteTextFile: boolean; } export const IAcpConnection: ServiceIdentifier = @@ -54,6 +60,8 @@ export class AcpConnection implements IAcpConnection { declare readonly _serviceBrand: undefined; private client: IAcpFsClient | undefined; + private _fsReadTextFile = false; + private _fsWriteTextFile = false; bind(client: IAcpFsClient): void { this.client = client; @@ -71,6 +79,19 @@ export class AcpConnection implements IAcpConnection { get bound(): boolean { return this.client !== undefined; } + + bindFsCapabilities(fs: { readTextFile?: boolean; writeTextFile?: boolean } | undefined): void { + this._fsReadTextFile = fs?.readTextFile === true; + this._fsWriteTextFile = fs?.writeTextFile === true; + } + + get fsReadTextFile(): boolean { + return this._fsReadTextFile; + } + + get fsWriteTextFile(): boolean { + return this._fsWriteTextFile; + } } registerScopedService( diff --git a/packages/acp-server/src/acp-fs/acpFsService.ts b/packages/acp-server/src/acp-fs/acpFsService.ts index 8e294e78a3..2bd6d8f139 100644 --- a/packages/acp-server/src/acp-fs/acpFsService.ts +++ b/packages/acp-server/src/acp-fs/acpFsService.ts @@ -60,7 +60,10 @@ export class AcpHostFileSystem implements IHostFileSystem { @IAcpConnection private readonly connection: IAcpConnection, ) {} - async readText(path: string, _options?: ReadTextOptions): Promise { + async readText(path: string, options?: ReadTextOptions): Promise { + if (!this.connection.fsReadTextFile) { + return this.inner.readText(path, options); + } // ACP `fs.readTextFile` returns already-decoded UTF-8 text, so the // `encoding`/`errors` decode options are a no-op here. const { content } = await this.connection @@ -70,11 +73,18 @@ export class AcpHostFileSystem implements IHostFileSystem { } async writeText(path: string, data: string): Promise { + if (!this.connection.fsWriteTextFile) { + return this.inner.writeText(path, data); + } await this.connection .get() .writeTextFile({ sessionId: this.ctx.sessionId, path, content: data }); } + appendText(path: string, data: string): Promise { + return this.inner.appendText(path, data); + } + async *readLines(path: string, options?: ReadTextOptions): AsyncGenerator { const text = await this.readText(path, options); yield* splitLinesKeepingTerminator(text); diff --git a/packages/acp-server/src/convert.ts b/packages/acp-server/src/convert.ts index edba39455f..68bb6ba74e 100644 --- a/packages/acp-server/src/convert.ts +++ b/packages/acp-server/src/convert.ts @@ -206,7 +206,7 @@ export function toolResultToAcpContent(event: ToolResultEvent): ToolCallContent[ try { text = JSON.stringify(out); } catch { - text = typeof out === 'object' && out !== null ? '[object]' : String(out); + text = '[object]'; } if (!text) return []; return [{ type: 'content', content: { type: 'text', text } }]; diff --git a/packages/acp-server/src/server.ts b/packages/acp-server/src/server.ts index 8a37c6516b..eeaef44edc 100644 --- a/packages/acp-server/src/server.ts +++ b/packages/acp-server/src/server.ts @@ -58,10 +58,14 @@ import { } from '@moonshot-ai/agent-core-v2'; import { buildTerminalAuthMethod, TERMINAL_AUTH_METHOD } from './auth-methods'; +import { IAcpConnection } from './acp-fs'; import { log } from './log'; import { isAcpModeId } from './modes'; import { AcpSession } from './session'; +/** The ACP protocol version this server implements. */ +const PROTOCOL_VERSION = 1; + export interface AcpServerOptions { /** Agent identity advertised in `initialize.agentInfo`. */ readonly agentInfo?: Implementation; @@ -118,6 +122,7 @@ export class AcpServer implements Agent { async initialize(params: InitializeRequest): Promise { this.clientCapabilities = params.clientCapabilities; + this.core.accessor.get(IAcpConnection).bindFsCapabilities(params.clientCapabilities?.fs); const agentCapabilities: AgentCapabilities = { loadSession: true, @@ -126,10 +131,6 @@ export class AcpServer implements Agent { audio: false, embeddedContext: true, }, - mcpCapabilities: { - http: true, - sse: true, - }, sessionCapabilities: { list: {}, resume: {}, @@ -138,7 +139,7 @@ export class AcpServer implements Agent { }; return { - protocolVersion: params.protocolVersion, + protocolVersion: PROTOCOL_VERSION, agentCapabilities, authMethods: [ this.terminalAuthEnv !== undefined || this.terminalAuthLegacyCommand !== undefined @@ -168,6 +169,7 @@ export class AcpServer implements Agent { await this.ensureAuthed(); const handle = await this.resumeHandle(params.sessionId); const acpSession = await this.wireSession(handle, params.sessionId); + this.sessions.get(params.sessionId)?.dispose(); this.sessions.set(params.sessionId, acpSession); // Replay the persisted history as an ordered batch of `session/update` // notifications BEFORE settling, so the client re-renders prior turns @@ -182,6 +184,7 @@ export class AcpServer implements Agent { await this.ensureAuthed(); const handle = await this.resumeHandle(params.sessionId); const acpSession = await this.wireSession(handle, params.sessionId); + this.sessions.get(params.sessionId)?.dispose(); this.sessions.set(params.sessionId, acpSession); void acpSession.emitAvailableCommandsUpdate(); return { configOptions: acpSession.configOptions() }; diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 1928370213..0d244f88e7 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -61,7 +61,7 @@ import { detectSlashIntent } from './slash'; /** Minimal handle to the in-flight turn, captured so `cancel` can abort it. */ interface ActiveTurn { - readonly abortController: AbortController; + cancel(reason?: unknown): boolean; } /** The turn handle returned by the prompt / skill-activation drivers. */ @@ -224,7 +224,7 @@ export class AcpSession { origin: { kind: 'user' }, }; return this.driveTurn(() => - this.mainAgent.accessor.get(IAgentPromptService).prompt(message), + this.mainAgent.accessor.get(IAgentPromptService).inject(message), ); } @@ -342,7 +342,7 @@ export class AcpSession { /** Cancel the in-flight turn, if any. Idempotent. */ cancel(): void { if (this.activeTurn !== undefined) { - this.activeTurn.abortController.abort(); + this.activeTurn.cancel(); this.activeTurn = undefined; } } From e5f40ae7905f4ced3ede9052de5910f413cf006a Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 17:02:03 +0800 Subject: [PATCH 4/6] fix(acp): use enqueue for prompt submission, stop advertising unimplemented builtins - replace IAgentPromptService.inject with enqueue so onBeforeSubmitPrompt hooks (prompt-blocking policy) are not bypassed - stop advertising builtin slash commands (/help, /status, etc.) until builtin command execution is implemented - add comment explaining appendText stays local (ACP has no append RPC) - update skills test to match new availableCommands behavior --- packages/acp-server/src/acp-fs/acpFsService.ts | 2 ++ packages/acp-server/src/builtin-commands.ts | 7 ++++--- packages/acp-server/src/session.ts | 14 +++++++++----- packages/acp-server/test/skills.test.ts | 11 ++++++----- 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/packages/acp-server/src/acp-fs/acpFsService.ts b/packages/acp-server/src/acp-fs/acpFsService.ts index 2bd6d8f139..a1f0dd627a 100644 --- a/packages/acp-server/src/acp-fs/acpFsService.ts +++ b/packages/acp-server/src/acp-fs/acpFsService.ts @@ -81,6 +81,8 @@ export class AcpHostFileSystem implements IHostFileSystem { .writeTextFile({ sessionId: this.ctx.sessionId, path, content: data }); } + // ACP protocol has no append RPC. Read-modify-write emulation would break + // O_APPEND atomicity, so appends always go to the local filesystem. appendText(path: string, data: string): Promise { return this.inner.appendText(path, data); } diff --git a/packages/acp-server/src/builtin-commands.ts b/packages/acp-server/src/builtin-commands.ts index 452aa35600..f007596676 100644 --- a/packages/acp-server/src/builtin-commands.ts +++ b/packages/acp-server/src/builtin-commands.ts @@ -1,9 +1,10 @@ import type { AvailableCommand } from '@agentclientprotocol/sdk'; /** - * ACP-owned built-in slash commands. These are advertised in the - * `available_commands_update` and (in a later phase) executed locally by the - * host rather than forwarded to the model. + * ACP-owned built-in slash commands. Recognized by slash detection (see + * `./slash`) but not yet advertised in `available_commands_update` — in a + * later phase they will be advertised and executed locally by the host rather + * than forwarded to the model. */ export const ACP_BUILTIN_SLASH_COMMANDS = [ { diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 0d244f88e7..262c5e60cf 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -34,7 +34,6 @@ import { type SkillCatalog, } from '@moonshot-ai/agent-core-v2'; -import { ACP_BUILTIN_SLASH_COMMANDS } from './builtin-commands'; import { buildSessionConfigOptions } from './config-options'; import { acpBlocksToContentParts } from './convert'; import { @@ -175,14 +174,17 @@ export class AcpSession { return buildSkillCommandMap(catalog); } - /** Build the `available_commands_update` payload (builtins + skills). */ + /** Build the `available_commands_update` payload (skills only). */ availableCommands(): AvailableCommand[] { const catalog = this.sessionHandle.accessor.get(ISessionSkillCatalog).catalog; const skills: AvailableCommand[] = catalog.listInvocableSkills().map((skill) => ({ name: skill.name, description: skill.description, })); - return [...ACP_BUILTIN_SLASH_COMMANDS, ...skills]; + // TODO: re-add ACP_BUILTIN_SLASH_COMMANDS once builtin command execution is + // implemented — advertising them now would route unhandled commands to the + // model as plain prompts. + return skills; } /** Push the current `available_commands_update` to the client. */ @@ -223,8 +225,10 @@ export class AcpSession { toolCalls: [], origin: { kind: 'user' }, }; - return this.driveTurn(() => - this.mainAgent.accessor.get(IAgentPromptService).inject(message), + return this.driveTurn(async () => + // `enqueue` (not `inject`) so the prompt runs through the full submission + // path, including `onBeforeSubmitPrompt` hooks (prompt-blocking policy). + (await this.mainAgent.accessor.get(IAgentPromptService).enqueue({ message })).launched, ); } diff --git a/packages/acp-server/test/skills.test.ts b/packages/acp-server/test/skills.test.ts index fe9ede90c1..c6dbd8b494 100644 --- a/packages/acp-server/test/skills.test.ts +++ b/packages/acp-server/test/skills.test.ts @@ -33,7 +33,7 @@ describe('acp-server skills / available commands', () => { } it( - 'session/new pushes an available_commands_update containing the builtin commands', + 'session/new pushes an available_commands_update without unhandled builtin commands', async () => { const c = await boot(); await c.send('session/new', { cwd: homeDir, mcpServers: [] }); @@ -43,10 +43,11 @@ describe('acp-server skills / available commands', () => { update: { availableCommands: readonly AvailableCommand[] }; }; const names = params.update.availableCommands.map((command) => command.name); - // The ACP-owned builtin commands are always advertised. - expect(names).toContain('compact'); - expect(names).toContain('status'); - expect(names).toContain('help'); + // Builtin commands are not advertised until the host can execute them; + // only invocable skills are listed. + expect(names).not.toContain('compact'); + expect(names).not.toContain('status'); + expect(names).not.toContain('help'); }, 30_000, ); From e574605731fa84c112934f2d6ee50d197b5261f2 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 17:26:57 +0800 Subject: [PATCH 5/6] fix(acp): filter turn events by turnId, surface auth failures as auth_required - track turnId in driveTurn and ignore events from unrelated turns, preventing queued prompts from settling on the running turn - reject prompt requests with auth_required when turn fails with an auth-related error code, enabling ACP client re-auth flow --- packages/acp-server/src/events-map.ts | 19 +++++++++++++++++++ packages/acp-server/src/session.ts | 24 +++++++++++++++++++++--- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/acp-server/src/events-map.ts b/packages/acp-server/src/events-map.ts index 9a02d4db44..bd6cd99257 100644 --- a/packages/acp-server/src/events-map.ts +++ b/packages/acp-server/src/events-map.ts @@ -70,6 +70,25 @@ export function turnEndReasonToStopReason( } } +/** Error codes that indicate an authentication / authorization failure. */ +const AUTH_ERROR_CODES: ReadonlySet = new Set([ + 'provider.auth_error', + 'auth.login_required', + 'auth.token_missing', + 'auth.token_unauthorized', + 'auth.provisioning_required', + 'auth.model_not_resolved', +]); + +/** + * Whether the given error (from a `turn.ended` event) is an auth failure that + * should surface as a JSON-RPC `auth_required` error so the ACP client + * triggers its re-auth flow. + */ +export function isAuthError(error?: { readonly code: string }): boolean { + return error !== undefined && AUTH_ERROR_CODES.has(error.code); +} + /** * Build the ACP `toolCallId` for a wire-level tool call. * diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 262c5e60cf..138c8f4118 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -17,6 +17,7 @@ import type { SessionConfigOption, SessionNotification, } from '@agentclientprotocol/sdk'; +import { RequestError } from '@agentclientprotocol/sdk'; import { type ContextMessage, type DomainEvent, @@ -50,6 +51,7 @@ import { toolProgressToSessionUpdate, toolResultToSessionUpdate, turnEndReasonToStopReason, + isAuthError, } from './events-map'; import { AcpInteractionBridge } from './interaction-bridge'; import { log } from './log'; @@ -242,6 +244,12 @@ export class AcpSession { return new Promise((resolve, reject) => { const eventBus = this.mainAgent.accessor.get(IEventBus); let settled = false; + /** + * Turn id captured once `launch()` resolves with a `Turn`. Events from + * any other turn (e.g. a still-running prior turn whose events arrive + * while our prompt is queued) are ignored until this is set. + */ + let turnId: number | undefined; // Per-tool-call streaming state, reset for every turn. const accumulators = new Map(); @@ -267,6 +275,9 @@ export class AcpSession { }; const handleEvent = (event: DomainEvent): void => { + // Ignore events from any turn other than ours. While the prompt is + // queued `turnId` is undefined, which skips every turn-scoped event. + if ('turnId' in event && event.turnId !== turnId) return; switch (event.type) { case 'assistant.delta': emit(assistantDeltaToSessionUpdate(this.sessionId, event)); @@ -310,9 +321,15 @@ export class AcpSession { emit(toolResultToSessionUpdate(this.sessionId, event)); break; case 'turn.ended': - settle(() => - resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) }), - ); + settle(() => { + // Auth failures must surface as a JSON-RPC `auth_required` error + // so the client triggers its re-auth flow, not a silent `end_turn`. + if (event.reason === 'failed' && isAuthError(event.error)) { + reject(RequestError.authRequired(undefined, event.error?.message)); + return; + } + resolve({ stopReason: turnEndReasonToStopReason(event.reason, event.error) }); + }); break; default: break; @@ -330,6 +347,7 @@ export class AcpSession { return; } this.activeTurn = turn; + turnId = turn.id; // Fallback settlement: `turn.ended` is the primary signal, but if the // turn resolves/rejects without it, settle here so the prompt never // hangs. From 3971041af7509f8993983dcf97bdce8ceda3a408 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Mon, 13 Jul 2026 17:54:23 +0800 Subject: [PATCH 6/6] fix(acp): gate acp-v2 behind experimental flag, filter sessions by cwd - add acp-v2 experimental flag (KIMI_CODE_EXPERIMENTAL_ACP_V2) and gate CLI command registration behind it - filter session/list results by requested cwd instead of returning sessions from all workspaces - detect hook-blocked prompts via PromptHandle.state and add TODO for streaming block messages once the hook context exposes them --- apps/kimi-code/src/cli/commands.ts | 5 +++- apps/kimi-code/src/cli/experimental-v2.ts | 10 ++++++++ apps/kimi-code/test/cli/options.test.ts | 23 ++++++++++++++++- packages/acp-server/src/server.ts | 10 +++++--- packages/acp-server/src/session.ts | 31 +++++++++++++++++++---- packages/agent-core/src/flags/registry.ts | 9 +++++++ 6 files changed, 77 insertions(+), 11 deletions(-) diff --git a/apps/kimi-code/src/cli/commands.ts b/apps/kimi-code/src/cli/commands.ts index 843f34c83f..dfe04207f2 100644 --- a/apps/kimi-code/src/cli/commands.ts +++ b/apps/kimi-code/src/cli/commands.ts @@ -2,6 +2,7 @@ import { CLI_COMMAND_NAME } from '#/constant/app'; import { registerMigrateCommand } from '#/migration/index'; import { Command, Option } from 'commander'; +import { isAcpV2Enabled } from './experimental-v2'; import type { CLIOptions } from './options'; import { registerAcpCommand } from './sub/acp'; import { registerAcpV2Command } from './sub/acp-v2'; @@ -90,7 +91,9 @@ export function createProgram( registerExportCommand(program); registerProviderCommand(program); registerAcpCommand(program); - registerAcpV2Command(program); + if (isAcpV2Enabled()) { + registerAcpV2Command(program); + } registerServerCommand(program); registerLoginCommand(program); registerDoctorCommand(program); diff --git a/apps/kimi-code/src/cli/experimental-v2.ts b/apps/kimi-code/src/cli/experimental-v2.ts index 6d26187bd7..b961b38982 100644 --- a/apps/kimi-code/src/cli/experimental-v2.ts +++ b/apps/kimi-code/src/cli/experimental-v2.ts @@ -8,9 +8,13 @@ * * `kimi -p` (print mode) routes to the native agent-core-v2 runner through the * same master switch. + * + * `kimi acp-v2` is gated behind its own per-feature env var + * `KIMI_CODE_EXPERIMENTAL_ACP_V2`, or the master switch. */ export const KIMI_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_FLAG'; +export const KIMI_ACP_V2_ENV = 'KIMI_CODE_EXPERIMENTAL_ACP_V2'; const TRUTHY_VALUES = new Set(['1', 'true', 'yes', 'on']); @@ -26,3 +30,9 @@ export function isKimiV2Enabled( ): boolean { return isTruthyEnv(KIMI_V2_ENV, env); } + +export function isAcpV2Enabled( + env: Readonly> = process.env, +): boolean { + return isTruthyEnv(KIMI_ACP_V2_ENV, env) || isKimiV2Enabled(env); +} diff --git a/apps/kimi-code/test/cli/options.test.ts b/apps/kimi-code/test/cli/options.test.ts index f5ed0d7e57..8dbba8718e 100644 --- a/apps/kimi-code/test/cli/options.test.ts +++ b/apps/kimi-code/test/cli/options.test.ts @@ -462,7 +462,6 @@ describe('CLI options parsing', () => { 'export', 'provider', 'acp', - 'acp-v2', 'server', 'web', 'login', @@ -472,6 +471,28 @@ describe('CLI options parsing', () => { 'upgrade', ]); }); + + it('registers acp-v2 when the experimental flag is enabled', () => { + const original = process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2']; + process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'] = '1'; + try { + const program = createProgram( + '0.0.0', + () => {}, + () => {}, + ); + const commandNames: string[] = program.commands + .filter((command) => !command.name().startsWith('__')) + .map((command) => command.name()); + expect(commandNames).toContain('acp-v2'); + } finally { + if (original === undefined) { + delete process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2']; + } else { + process.env['KIMI_CODE_EXPERIMENTAL_ACP_V2'] = original; + } + } + }); }); describe('rejected flags', () => { diff --git a/packages/acp-server/src/server.ts b/packages/acp-server/src/server.ts index eeaef44edc..1a581815bf 100644 --- a/packages/acp-server/src/server.ts +++ b/packages/acp-server/src/server.ts @@ -192,11 +192,13 @@ export class AcpServer implements Agent { async listSessions(params: ListSessionsRequest): Promise { const cwd = params.cwd ?? undefined; - // ACP `cwd` ↔ session `cwd`. Filtering by workspaceId is a later phase; - // for now list everything (the client filters by cwd on its side). - void cwd; const page = await this.core.accessor.get(ISessionIndex).list({}); - const sessions: SessionInfo[] = page.items.map(sessionSummaryToSessionInfo); + // Filter by cwd when the client supplies one. SessionSummary.cwd is optional + // (sessions written before cwd was persisted); those are excluded when a + // filter is active. + const items = + cwd !== undefined ? page.items.filter((s) => s.cwd === cwd) : page.items; + const sessions: SessionInfo[] = items.map(sessionSummaryToSessionInfo); return { sessions, nextCursor: page.nextCursor ?? null }; } diff --git a/packages/acp-server/src/session.ts b/packages/acp-server/src/session.ts index 138c8f4118..06f18361f1 100644 --- a/packages/acp-server/src/session.ts +++ b/packages/acp-server/src/session.ts @@ -32,6 +32,7 @@ import { IModelService, ISessionSkillCatalog, type ISessionScopeHandle, + type PromptHandle, type SkillCatalog, } from '@moonshot-ai/agent-core-v2'; @@ -227,10 +228,16 @@ export class AcpSession { toolCalls: [], origin: { kind: 'user' }, }; - return this.driveTurn(async () => - // `enqueue` (not `inject`) so the prompt runs through the full submission - // path, including `onBeforeSubmitPrompt` hooks (prompt-blocking policy). - (await this.mainAgent.accessor.get(IAgentPromptService).enqueue({ message })).launched, + // Capture the PromptHandle so driveTurn can detect hook-blocked prompts. + let promptHandle: PromptHandle | undefined; + return this.driveTurn( + async () => { + // `enqueue` (not `inject`) so the prompt runs through the full submission + // path, including `onBeforeSubmitPrompt` hooks (prompt-blocking policy). + promptHandle = await this.mainAgent.accessor.get(IAgentPromptService).enqueue({ message }); + return promptHandle.launched; + }, + () => promptHandle, ); } @@ -239,8 +246,14 @@ export class AcpSession { * launching (so no events are missed), translate each event into an ACP * `session/update`, and settle on `turn.ended` (falling back to the turn's * `result` promise). Used by both normal prompts and skill activations. + * + * `getPromptHandle` is optional — only the normal-prompt path provides it, so + * hook-blocked prompts can be distinguished from other no-launch causes. */ - private driveTurn(launch: () => Promise): Promise { + private driveTurn( + launch: () => Promise, + getPromptHandle?: () => PromptHandle | undefined, + ): Promise { return new Promise((resolve, reject) => { const eventBus = this.mainAgent.accessor.get(IEventBus); let settled = false; @@ -343,6 +356,14 @@ export class AcpSession { if (turn === undefined) { // busy / not runnable / hook-blocked — no turn will emit // `turn.ended`, so settle gracefully. + const handle = getPromptHandle?.(); + if (handle?.state === 'blocked') { + // The prompt was blocked by an onBeforeSubmitPrompt hook. + // PromptSubmitContext only sets block: true without a message, + // so we cannot stream a blocking reason to the client. + // TODO: stream the hook's blocking message as an + // agent_message_chunk when PromptSubmitContext exposes one. + } settle(() => resolve({ stopReason: 'end_turn' })); return; } diff --git a/packages/agent-core/src/flags/registry.ts b/packages/agent-core/src/flags/registry.ts index dbec75b80b..d61cbaaf52 100644 --- a/packages/agent-core/src/flags/registry.ts +++ b/packages/agent-core/src/flags/registry.ts @@ -32,6 +32,15 @@ export const FLAG_DEFINITIONS = [ default: false, surface: 'core', }, + { + id: 'acp-v2', + title: 'ACP server v2 (agent-core-v2 engine)', + description: + 'Expose the `kimi acp-v2` sub-command that runs the Agent Client Protocol server over the experimental agent-core-v2 engine.', + env: 'KIMI_CODE_EXPERIMENTAL_ACP_V2', + default: false, + surface: 'core', + }, ] as const satisfies readonly FlagDefinitionInput[]; /** Literal union of registered flag ids. */