kimi-code/packages/acp-adapter/test/auth-gate.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

134 lines
4.8 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import {
AgentSideConnection,
ClientSideConnection,
ndJsonStream,
type Client,
type NewSessionRequest,
type ReadTextFileRequest,
type ReadTextFileResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import type { KimiHarness } from '@moonshot-ai/kimi-code-sdk';
import { AcpServer } from '../src/server';
import { AUTHED_STATUS, UNAUTHED_STATUS } from './_helpers/harness-stubs';
class StubClient implements Client {
async requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
throw new Error('StubClient.requestPermission should not be called in auth-gate test');
}
async sessionUpdate(_n: SessionNotification): Promise<void> {
throw new Error('StubClient.sessionUpdate should not be called in auth-gate test');
}
async writeTextFile(_p: WriteTextFileRequest): Promise<WriteTextFileResponse> {
throw new Error('StubClient.writeTextFile should not be called in auth-gate test');
}
async readTextFile(_p: ReadTextFileRequest): Promise<ReadTextFileResponse> {
throw new Error('StubClient.readTextFile should not be called in auth-gate test');
}
}
function makeInMemoryStreamPair(): {
agentStream: ReturnType<typeof ndJsonStream>;
clientStream: ReturnType<typeof ndJsonStream>;
} {
const clientToAgent = new TransformStream<Uint8Array, Uint8Array>();
const agentToClient = new TransformStream<Uint8Array, Uint8Array>();
const agentStream = ndJsonStream(agentToClient.writable, clientToAgent.readable);
const clientStream = ndJsonStream(clientToAgent.writable, agentToClient.readable);
return { agentStream, clientStream };
}
function makeHarnessWithToken(hasToken: boolean): KimiHarness {
return {
auth: {
status: async () => (hasToken ? AUTHED_STATUS : UNAUTHED_STATUS),
},
} as unknown as KimiHarness;
}
describe('AcpServer auth gate', () => {
it('rejects session/new with auth_required (-32000) when no token', async () => {
const harness = makeHarnessWithToken(false);
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
const request: NewSessionRequest = {
cwd: '/tmp/x',
mcpServers: [],
};
await expect(client.newSession(request)).rejects.toMatchObject({
code: -32000,
});
});
it('does not call createSession when the auth gate fails', async () => {
let createCalled = false;
const harness = {
auth: {
status: async () => UNAUTHED_STATUS,
},
createSession: async (_opts: unknown) => {
createCalled = true;
return { id: 'should-not-be-reached' };
},
} as unknown as KimiHarness;
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
await expect(
client.newSession({ cwd: '/tmp/x', mcpServers: [] }),
).rejects.toMatchObject({ code: -32000 });
expect(createCalled).toBe(false);
});
});
describe('AcpServer.authenticate', () => {
it('rejects unknown methodId with invalidParams (-32602)', async () => {
const harness = makeHarnessWithToken(true);
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
await expect(client.authenticate({ methodId: 'unknown' })).rejects.toMatchObject({
code: -32602,
});
});
it('returns void on valid token', async () => {
const harness = makeHarnessWithToken(true);
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
const result = await client.authenticate({ methodId: 'login' });
// ACP allows `AuthenticateResponse | void`; either `null`/`undefined`
// or an empty body `{}` is considered a successful ack.
expect(result ?? {}).toEqual({});
});
it('throws authRequired (-32000) when harness has no token', async () => {
const harness = makeHarnessWithToken(false);
const { agentStream, clientStream } = makeInMemoryStreamPair();
new AgentSideConnection((c) => new AcpServer(harness, c), agentStream);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);
await expect(client.authenticate({ methodId: 'login' })).rejects.toMatchObject({
code: -32000,
});
});
});