kimi-code/packages/acp-adapter/test/question.test.ts
Haozhe 3eafa79f39
feat(acp): implement ACP server with session lifecycle, tool streaming, and IDE integration (#368)
This commit scaffolds the @moonshot-ai/acp-adapter package and introduces
the full ACP (Agent Communication Protocol) server implementation for
Kimi Code CLI, including:

- Scaffold @moonshot-ai/acp-adapter workspace package with build skeleton
- `kimi acp` CLI subcommand and stdout-safe logging
- ACP version negotiation and AgentSideConnection wrapper
- Auth gate for session creation
- Session lifecycle: new, list, load with history replay
- Prompt content conversion (text, image, embedded resources, resource links)
- Assistant streaming with thinking support and end-turn handling
- Tool call streaming (started, delta, progress) with result conversion (text / diff)
- Approval handling with diff/text display blocks mapped to ACP options
- Kaos read/write interface (AcpKaos) for unsaved buffer access
- Session mode (yolo/auto) and model management
- Config options builder with thinking toggle
- MCP server forwarding from ACP to harness
- Agent plan updates and available commands updates
- AskUserQuestion bridged to session/request_permission
- Plan review options surfaced through requestPermission
- Error mapping, ext_method stubs, and graceful shutdown
- IDE integration guide (Zed + JetBrains)
- End-to-end tests against ACP TS SDK client
2026-06-03 21:11:30 +08:00

101 lines
3.2 KiB
TypeScript

import type {
PermissionOption,
RequestPermissionResponse,
} from '@agentclientprotocol/sdk';
import type { QuestionItem } from '@moonshot-ai/kimi-code-sdk';
import { describe, expect, it } from 'vitest';
import { outcomeToQuestionAnswer, questionItemToPermissionOptions } from '../src/question';
const sampleQuestion: QuestionItem = {
question: 'Pick a flavour',
options: [
{ label: 'Vanilla' },
{ label: 'Chocolate' },
{ label: 'Mint chip' },
],
};
describe('questionItemToPermissionOptions', () => {
it('maps each option to allow_once + a trailing Skip reject_once', () => {
const opts = questionItemToPermissionOptions(sampleQuestion, 0);
expect(opts).toHaveLength(4);
expect(opts[0]).toEqual({
optionId: 'q0_opt_0',
name: 'Vanilla',
kind: 'allow_once',
});
expect(opts[1]).toEqual({
optionId: 'q0_opt_1',
name: 'Chocolate',
kind: 'allow_once',
});
expect(opts[2]).toEqual({
optionId: 'q0_opt_2',
name: 'Mint chip',
kind: 'allow_once',
});
expect(opts[3]).toEqual({
optionId: 'q0_skip',
name: 'Skip',
kind: 'reject_once',
});
});
it('does not conflict across different questionIndex values', () => {
const q0 = questionItemToPermissionOptions(sampleQuestion, 0);
const q1 = questionItemToPermissionOptions(sampleQuestion, 1);
const ids0 = q0.map((o: PermissionOption) => o.optionId);
const ids1 = q1.map((o: PermissionOption) => o.optionId);
const overlap = ids0.filter((id) => ids1.includes(id));
expect(overlap).toEqual([]);
expect(ids1).toEqual(['q1_opt_0', 'q1_opt_1', 'q1_opt_2', 'q1_skip']);
});
it('emits only the Skip option for a question with no options', () => {
const empty: QuestionItem = { question: 'Empty?', options: [] };
const opts = questionItemToPermissionOptions(empty, 0);
expect(opts).toHaveLength(1);
expect(opts[0]).toEqual({
optionId: 'q0_skip',
name: 'Skip',
kind: 'reject_once',
});
});
});
describe('outcomeToQuestionAnswer', () => {
function selected(optionId: string): RequestPermissionResponse {
return { outcome: { outcome: 'selected', optionId } };
}
it('maps a selected q0_opt_<i> to { question: options[i].label }', () => {
expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_2'))).toEqual({
'Pick a flavour': 'Mint chip',
});
expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_0'))).toEqual({
'Pick a flavour': 'Vanilla',
});
});
it('maps q0_skip to null', () => {
expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_skip'))).toBeNull();
});
it('maps cancelled to null', () => {
expect(
outcomeToQuestionAnswer(sampleQuestion, { outcome: { outcome: 'cancelled' } }),
).toBeNull();
});
it('maps an unknown optionId to null', () => {
expect(outcomeToQuestionAnswer(sampleQuestion, selected('wat'))).toBeNull();
expect(
outcomeToQuestionAnswer(sampleQuestion, selected('approve_once')),
).toBeNull();
});
it('defensively maps an out-of-bounds index to null', () => {
expect(outcomeToQuestionAnswer(sampleQuestion, selected('q0_opt_99'))).toBeNull();
});
});