mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 14:47:17 +00:00
* 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
149 lines
5 KiB
TypeScript
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();
|
|
});
|
|
});
|