mirror of
https://github.com/MoonshotAI/kimi-code.git
synced 2026-08-21 06:35:50 +00:00
* 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.
198 lines
6.7 KiB
TypeScript
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' });
|
|
});
|
|
});
|