kimi-code/packages/acp-server/test/approval.test.ts
Haozhe 1328b32037
feat(acp): add experimental agent-core-v2 ACP server (kimi acp-v2) (#2571)
* 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

* 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

* 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

* 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

* 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

* 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

* refactor(acp-server): rewire ACP server onto the klient facade

- replace direct agent-core-v2 scope/service access (ISessionLifecycleService,
  ISessionIndex, IEventBus, ISessionInteractionService, etc.) with the Klient
  facade: klient.global.sessions / klient.session(id) / agent('main') handles
- drive turns via agent.prompt() + session-level agent event subscriptions
  instead of per-prompt IEventBus wiring; settle on turn.ended
- route approval/question bridging through session.interactions events
- hide the thinking config option and skill catalog behind KLIENT-GAP markers
  until klient exposes those surfaces
- acp-fs: pass realpath through to the local inner backend
- klient: session.restore() rejects both null and undefined handles

* feat(agent-core-v2): add session delete and ephemeral per-session MCP servers

- add ISessionLifecycleService.delete: close a live session first, then
  remove its persisted data, evict the index read-model entry, and append
  a deleted tombstone to session_index.jsonl; unknown ids raise
  session.not_found
- add CreateSessionOptions/ResumeSessionOptions.mcpServers: session-owned
  MCP overlay merged over the workspace manager via
  MergedMcpConnectionView (an ephemeral name shadows a workspace server),
  never persisted, released when the session scope tears down
- return PromptLaunchResult from activateSkill so callers get the
  launched turn id and activation failures (unknown skill, busy) surface
- add ISessionSkillCatalog.list() as a wire-friendly catalog snapshot
- add ISessionIndex.remove for read-model eviction on delete

* feat(klient): expose session delete, per-session MCP, skills, and stream events

- session lifecycle contract: delete, resume/restore options, and
  CreateSessionOptions.mcpServers (ephemeral per-session MCP servers)
- add the session skills contract and facade accessors for the
  wire-friendly skill catalog snapshot
- register tool.call.delta, tool.progress, and compaction.* agent stream
  events so consumers can subscribe with typed payloads

* feat(acp-server): align ACP v2 server with acp-adapter capabilities

- complete the klient-facade rewire: ACP client connection holder and
  the terminal/* reverse-RPC runner routed through the Agent scope
- negotiate the protocol version on initialize instead of pinning v1
- compress oversized prompt images at the ACP ingestion point with a
  format gate, caption, and persisted originals; a cancel arriving
  mid-compression settles the prompt as cancelled without a turn
- stream tool call args via tool.call.delta (lazy pending create,
  cumulative replace, started upgrade) and refresh titles via
  tool.progress status updates
- report compaction progress and results after /compact via the
  compaction.* events
- answer unknown slash commands locally instead of sending them to the
  model
- accept legacy "<id>,thinking" model ids and legacy approve /
  approve_for_session approval option ids
- keep sessions without cwd metadata in cwd-filtered session/list
- sanitize wire errors: auth codes map to auth_required, turn.agent_busy
  to invalid_request, everything else to a fixed internal-error message
- bump @agentclientprotocol/sdk to ^1.3.0

* fix(cli): drop stale registerServerCommand call and sherif ACP SDK split

- commands.ts called registerServerCommand, which no longer exists on
  current main (the deprecated `kimi server` shim is registered via
  registerWebCommand), breaking typecheck, build, and every CLI test
  that builds the program
- sherif rejects the @agentclientprotocol/sdk major split between
  acp-adapter (^0.23.0, production kimi acp) and acp-server (^1.3.0,
  experimental); the two hosts legitimately target different SDK
  majors, so ignore the dependency in the sherif invocation

* test: update fixtures for acp-v2 flag and domain rename, refresh nix deps hash

- kap-server origin.test: two CORS cases still used foo.com after the
  whitelist moved to foo.example.com, so the origin was no longer
  whitelisted and the expected CORS headers were withheld
- node-sdk config.test: expect the new acp-v2 experimental flag in the
  harness feature metadata
- flake.nix: update the fetchPnpmDeps hash for the
  @agentclientprotocol/sdk 1.3.0 lockfile change

* fix(acp): widen the ACP v2 auth gate beyond OAuth-only providers

The gate consulted only auth.summarize(), which iterates providers
declaring an oauth section — configurations that authenticate with a
plain apiKey or provider env-bag credentials (no OAuth at all) were
rejected with auth_required even though the default model is fully
usable.

- klient: expose authSummaryService.ensureReady on the global auth
  facade (the contract already declared it)
- acp-server: gate on the engine's own readiness probe for the default
  model — config apiKey / env-bag / OAuth token all count, matching how
  the model is actually used — and fall back to "any logged-in OAuth
  provider" (the legacy adapter's first branch)
- test: an apiKey-only config passes the gate with auth enforcement on;
  the OAuth logout regression is unchanged

* fix(acp): reject concurrent prompts instead of displacing the in-flight turn

A second session/prompt while a turn is running overwrote the session's
only TurnDriver: the engine quietly queues plain prompts submitted
during an active turn (the launch resolves undefined, indistinguishable
from a hook-blocked launch), so the first prompt never settled and both
turns' events went unattributed.

Guard both model-bound launch paths (plain prompt and skill activation)
with a synchronous in-flight check and reject with invalid_request
(turn.agent_busy), matching the legacy adapter's busy semantics. Local
slash handling (builtins, unknown-command answers) is unaffected.
2026-08-04 10:20:24 +08:00

198 lines
6.7 KiB
TypeScript

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 the legacy Python kimi-cli optionIds like their canonical counterparts', () => {
// < v0.9.0 clients answer with 'approve' / 'approve_for_session'.
expect(
permissionResponseToApprovalResponse(makeRequest(commandDisplay), selected('approve')),
).toEqual({ decision: 'approved' });
expect(
permissionResponseToApprovalResponse(
makeRequest(commandDisplay),
selected('approve_for_session'),
),
).toEqual({ decision: 'approved', scope: 'session' });
});
it('maps plan_opt_<i> 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' });
});
});