kimi-code/packages/acp-server/test/question.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

149 lines
5 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
elicitationResponseToQuestionAnswers,
outcomeToQuestionAnswer,
questionItemToPermissionOptions,
questionRequestToElicitationParams,
} from '../src/question';
import type { CreateElicitationResponse, 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();
});
});
const multiQuestion: QuestionItem = {
question: 'Pick features',
header: 'Features',
options: [
{ label: 'Auth', description: 'Login + signup' },
{ label: 'Email' },
{ label: 'Uploads' },
],
multiSelect: true,
};
describe('questionRequestToElicitationParams', () => {
it('maps a single-select question to a string oneOf property', () => {
const params = questionRequestToElicitationParams([sampleQuestion], 'session_1', '3:tc_1');
expect(params).toMatchObject({
sessionId: 'session_1',
toolCallId: '3:tc_1',
mode: 'form',
message: 'Pick a color',
});
const schema = params.requestedSchema;
expect(schema.required).toEqual(['q0']);
expect(schema.properties?.['q0']).toMatchObject({
type: 'string',
title: 'Pick a color',
oneOf: [
{ const: 'Red', title: 'Red' },
{ const: 'Green', title: 'Green' },
{ const: 'Blue', title: 'Blue' },
],
});
});
it('maps every question (multi-select as array anyOf with minItems), titled by header', () => {
const params = questionRequestToElicitationParams(
[sampleQuestion, multiQuestion],
'session_1',
);
expect(params.message).toBe('Pick a color\nPick features');
const schema = params.requestedSchema;
expect(schema.required).toEqual(['q0', 'q1']);
expect(schema.properties?.['q1']).toMatchObject({
type: 'array',
title: 'Features',
minItems: 1,
items: {
anyOf: [
{ const: 'Auth', title: 'Auth', description: 'Login + signup' },
{ const: 'Email', title: 'Email' },
{ const: 'Uploads', title: 'Uploads' },
],
},
});
});
});
function elicitation(content: Record<string, unknown>): CreateElicitationResponse {
return { action: 'accept', content } as CreateElicitationResponse;
}
describe('elicitationResponseToQuestionAnswers', () => {
it('maps an accepted single-select answer keyed by the question text', () => {
expect(elicitationResponseToQuestionAnswers([sampleQuestion], elicitation({ q0: 'Green' })))
.toEqual({ 'Pick a color': 'Green' });
});
it('joins multi-select values in declared option order', () => {
expect(
elicitationResponseToQuestionAnswers([multiQuestion], elicitation({ q0: ['Uploads', 'Auth'] })),
).toEqual({ 'Pick features': 'Auth, Uploads' });
});
it('drops values outside the declared options', () => {
expect(
elicitationResponseToQuestionAnswers([sampleQuestion], elicitation({ q0: 'Purple' })),
).toBeNull();
expect(
elicitationResponseToQuestionAnswers([multiQuestion], elicitation({ q0: ['Auth', 'Hack'] })),
).toEqual({ 'Pick features': 'Auth' });
});
it('returns null on decline / cancel / content-less accept', () => {
expect(
elicitationResponseToQuestionAnswers([sampleQuestion], { action: 'decline' }),
).toBeNull();
expect(elicitationResponseToQuestionAnswers([sampleQuestion], { action: 'cancel' })).toBeNull();
expect(
elicitationResponseToQuestionAnswers([sampleQuestion], { action: 'accept' }),
).toBeNull();
});
});