kimi-code/packages/acp-server/test/interaction-bridge.test.ts
Haozhe f412e105b3
feat(acp-server): bridge questions via elicitation and support host slash commands (#2583)
* fix(acp): preserve cancels that arrive before the turn id is known

A session/cancel landing between prompt submission and the launch
round-trip found driver.turnId undefined and was dropped entirely; the
turn then ran to completion and the prompt resolved end_turn despite
the client's cancel.

The engine's cancel payload makes turnId optional (an empty call
cancels the active turn — the same contract kap-server's cancel route
relies on), so cancel() now issues an unaddressed cancel in that window
and flags the driver; the launch handler re-issues a precisely
addressed cancel once the id lands, and a no-launch outcome settles
cancelled instead of end_turn.

* fix(agent-core-v2): shut session MCP overlays down on service teardown

The ephemeral per-session MCP overlay was only shut down by the
session handle's dispose wrapper, but the DI container disposes session
scopes directly on workspace/app teardown, bypassing the wrapper — so
overlays of sessions still live at shutdown leaked their MCP
connections and stdio child processes.

Track live overlays in the lifecycle service: the handle wrapper
deletes-then-shuts-down (atomic, so close and service disposal can
never double-shutdown), and the service's own dispose shuts down
whatever is still tracked.

* feat(acp-server): bridge questions via elicitation and support host slash commands

- route AskUserQuestion through `elicitation/create` for form-capable
  clients (native multi-question + multi-select), falling back to the
  `request_permission` bridge on RPC failure
- add a `slashCommands` resolver option so hosts can merge their own
  command palette and skill aliases into `available_commands_update`;
  `/help` now lists the merged palette
- bridge `appendText`/`writeBytes` through client text capabilities
  (read-modify-write append, UTF-8-checked byte writes) with local
  filesystem fallbacks
- defer `available_commands_update` until after the lifecycle response
  settles so clients like Zed do not drop the notification
- propagate plan-toggle errors from `setMode` instead of silently
  reporting the new mode; make server `close()` idempotent

* style(acp-server): satisfy oxlint eqeqeq and await-thenable rules

* test(node-sdk): assert v1-v2 tokenCount parity for imports after eager counting
2026-08-04 10:44:56 +08:00

368 lines
12 KiB
TypeScript

import type { RequestPermissionResponse } from '@agentclientprotocol/sdk';
import type { Interaction } from '@moonshot-ai/agent-core-v2';
import type { SessionHandle } from '@moonshot-ai/klient';
import type { ToolInputDisplay } from '@moonshot-ai/protocol';
import { describe, expect, it } from 'vitest';
import type { AcpClient } from '../src/acp-client';
import { AcpInteractionBridge } from '../src/interaction-bridge';
const SESSION_ID = 'session_test';
const commandDisplay: ToolInputDisplay = {
kind: 'command',
command: 'echo hi',
} as unknown as ToolInputDisplay;
interface FakeSession {
readonly handle: SessionHandle;
readonly responses: Array<{ id: string; response: unknown }>;
setPending(pending: readonly Interaction[]): void;
fire(): void;
}
/**
* Fake the klient session surface the bridge consumes:
* `events.on('interactions.changed')` + `interactions.list()` / `respond()`.
*/
function makeFakeSession(): FakeSession {
let listener: ((pending: readonly Interaction[]) => void) | undefined;
let pending: readonly Interaction[] = [];
const responses: Array<{ id: string; response: unknown }> = [];
const handle = {
events: {
on: (_event: string, l: (payload: readonly Interaction[]) => void) => {
listener = l;
return {
dispose: () => {
listener = undefined;
},
};
},
onError: () => ({ dispose: () => {} }),
},
interactions: {
list: () => Promise.resolve(pending),
respond: (id: string, response: unknown) => {
responses.push({ id, response });
return Promise.resolve();
},
},
} as unknown as SessionHandle;
return {
handle,
responses,
setPending: (p) => {
pending = p;
},
fire: () => listener?.(pending),
};
}
interface FakeConn {
readonly conn: AcpClient;
readonly calls: Array<Record<string, unknown>>;
}
function makeFakeConn(
handler: (params: Record<string, unknown>) => RequestPermissionResponse,
): FakeConn {
const calls: Array<Record<string, unknown>> = [];
const conn = {
requestPermission: async (params: Record<string, unknown>) => {
calls.push(params);
return handler(params);
},
} as unknown as AcpClient;
return { conn, calls };
}
async function flush(): Promise<void> {
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 session = makeFakeSession();
const { conn, calls } = makeFakeConn(() => ({
outcome: { outcome: 'selected', optionId: 'approve_once' },
}));
session.setPending([approvalInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(calls).toHaveLength(1);
expect(calls[0]).toMatchObject({
sessionId: SESSION_ID,
toolCall: { toolCallId: '3:call_1', title: 'Bash' },
});
expect(session.responses).toEqual([
{ id: 'approval-1', response: { decision: 'approved', selectedLabel: 'Approve once' } },
]);
bridge.dispose();
});
it('maps approve_always to a session-scoped approval', async () => {
const session = makeFakeSession();
const { conn } = makeFakeConn(() => ({
outcome: { outcome: 'selected', optionId: 'approve_always' },
}));
session.setPending([approvalInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(session.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 session = makeFakeSession();
const conn = {
requestPermission: async () => {
throw new Error('transport dropped');
},
} as unknown as AcpClient;
session.setPending([approvalInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(session.responses).toEqual([{ id: 'approval-1', response: { decision: 'rejected' } }]);
bridge.dispose();
});
it('bridges a plan review interaction and preserves the selected plan label', async () => {
const session = makeFakeSession();
const { conn, calls } = makeFakeConn(() => ({
outcome: { outcome: 'selected', optionId: 'plan_opt_1' },
}));
const planInteraction: Interaction = {
id: 'plan-1',
kind: 'approval',
payload: {
toolName: 'ExitPlanMode',
action: 'review the plan',
toolCallId: 'plan-call',
turnId: 7,
display: {
kind: 'plan_review',
plan: 'step one',
options: [{ label: 'Fast path' }, { label: 'Safe path' }],
},
},
origin: { turnId: 7 },
createdAt: 0,
};
session.setPending([planInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(calls[0]?.['options']).toEqual([
expect.objectContaining({ optionId: 'plan_opt_0', name: 'Fast path' }),
expect.objectContaining({ optionId: 'plan_opt_1', name: 'Safe path' }),
expect.objectContaining({ optionId: 'plan_revise' }),
expect.objectContaining({ optionId: 'plan_reject_and_exit' }),
]);
expect(session.responses).toEqual([
{ id: 'plan-1', response: { decision: 'approved', selectedLabel: 'Safe path' } },
]);
bridge.dispose();
});
it('forwards a question request and responds with the answer', async () => {
const session = makeFakeSession();
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,
};
session.setPending([questionInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(calls[0]).toMatchObject({
toolCall: { toolCallId: '5:tc_q', title: 'AskUserQuestion' },
});
expect(session.responses).toEqual([{ id: 'question-1', response: { 'Pick one': 'A' } }]);
bridge.dispose();
});
it('ignores non-approval/question interactions', async () => {
const session = makeFakeSession();
const { conn, calls } = makeFakeConn(() => ({ outcome: { outcome: 'cancelled' } }));
const userToolInteraction: Interaction = {
id: 'ut-1',
kind: 'user_tool',
payload: {},
origin: {},
createdAt: 0,
};
session.setPending([userToolInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
expect(calls).toHaveLength(0);
expect(session.responses).toEqual([]);
bridge.dispose();
});
it('does not double-handle the same pending id across change events', async () => {
const session = makeFakeSession();
const { conn, calls } = makeFakeConn(() => ({
outcome: { outcome: 'selected', optionId: 'approve_once' },
}));
session.setPending([approvalInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
session.fire();
session.fire();
await flush();
expect(calls).toHaveLength(1);
bridge.dispose();
});
it('settles an approval exactly once when the client answers after cancellation', async () => {
const session = makeFakeSession();
let resolvePermission!: (response: RequestPermissionResponse) => void;
const calls: unknown[] = [];
const conn = {
requestPermission: (params: unknown) => {
calls.push(params);
return new Promise<RequestPermissionResponse>((resolve) => {
resolvePermission = resolve;
});
},
} as unknown as AcpClient;
session.setPending([approvalInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID);
await flush();
session.setPending([]);
session.fire();
resolvePermission({ outcome: { outcome: 'selected', optionId: 'approve_once' } });
await flush();
expect(calls).toHaveLength(1);
expect(session.responses).toEqual([
{ id: 'approval-1', response: { decision: 'approved', selectedLabel: 'Approve once' } },
]);
bridge.dispose();
});
const questionInteraction: Interaction = {
id: 'question-el-1',
kind: 'question',
payload: {
toolCallId: 'tc_q',
turnId: 5,
questions: [
{ question: 'Pick one', header: 'One', options: [{ label: 'A' }, { label: 'B' }] },
{
question: 'Pick many',
options: [{ label: 'X' }, { label: 'Y' }, { label: 'Z' }],
multiSelect: true,
},
],
},
origin: { turnId: 5 },
createdAt: 0,
};
it('routes questions through elicitation/create when the client supports form mode', async () => {
const session = makeFakeSession();
const elicitationCalls: Array<Record<string, unknown>> = [];
const { conn, calls: permissionCalls } = makeFakeConn(() => ({
outcome: { outcome: 'cancelled' },
}));
(conn as { createElicitation?: unknown }).createElicitation = async (
params: Record<string, unknown>,
) => {
elicitationCalls.push(params);
return { action: 'accept', content: { q0: 'B', q1: ['Z', 'X'] } };
};
session.setPending([questionInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID, true);
await flush();
expect(permissionCalls).toHaveLength(0);
expect(elicitationCalls).toHaveLength(1);
expect(elicitationCalls[0]).toMatchObject({
sessionId: SESSION_ID,
toolCallId: '5:tc_q',
mode: 'form',
requestedSchema: {
required: ['q0', 'q1'],
properties: {
q0: { type: 'string', title: 'One' },
q1: { type: 'array', minItems: 1 },
},
},
});
// Answers key by question text; multi-select joins in declared option order.
expect(session.responses).toEqual([
{ id: 'question-el-1', response: { 'Pick one': 'B', 'Pick many': 'X, Z' } },
]);
bridge.dispose();
});
it('responds null (dismissed) when the elicitation is declined', async () => {
const session = makeFakeSession();
const { conn } = makeFakeConn(() => ({ outcome: { outcome: 'cancelled' } }));
(conn as { createElicitation?: unknown }).createElicitation = async () => ({
action: 'decline',
});
session.setPending([questionInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID, true);
await flush();
expect(session.responses).toEqual([{ id: 'question-el-1', response: null }]);
bridge.dispose();
});
it('falls back to request_permission when elicitation/create fails', async () => {
const session = makeFakeSession();
const { conn, calls: permissionCalls } = makeFakeConn(() => ({
outcome: { outcome: 'selected', optionId: 'q0_opt_1' },
}));
(conn as { createElicitation?: unknown }).createElicitation = async () => {
throw new Error('method not found');
};
session.setPending([questionInteraction]);
const bridge = new AcpInteractionBridge(conn, session.handle, SESSION_ID, true);
await flush();
// The permission bridge degrades to the first question, single-select.
expect(permissionCalls).toHaveLength(1);
expect(permissionCalls[0]).toMatchObject({
toolCall: { toolCallId: '5:tc_q', title: 'AskUserQuestion' },
});
expect(session.responses).toEqual([{ id: 'question-el-1', response: { 'Pick one': 'B' } }]);
bridge.dispose();
});
});