Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/acp-session-close-logout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@moonshot-ai/kimi-code": patch
---

Implement the remaining stable ACP agent-side methods, `session/close` and `logout`, so ACP clients can close sessions and sign out through the protocol.
8 changes: 4 additions & 4 deletions docs/en/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ The table below lists the capabilities declared by the current ACP adapter layer

The spec divides methods into a **stable** surface and an evolving **unstable** surface (handlers mounted with the `unstable_*` prefix in `@agentclientprotocol/sdk@0.23.0`). The two have entirely different stability guarantees — the stable surface covers methods every production ACP client uses, while the unstable surface covers experimental extensions (inline-edit prediction, document buffer sync, provider management, elicitation, etc.) — so they are tracked separately.

**Summary: stable agent-side 10/12 (83%) + client reverse-RPC 4/9 (44%); unstable surface has only `session/set_model` (1/19).** All methods needed for a normal agent flow (initialize → auth → new/load/resume → prompt → cancel + file I/O + tool approval) are implemented.
**Summary: stable agent-side 12/12 (100%) + client reverse-RPC 4/9 (44%); unstable surface has only `session/set_model` (1/19).** All methods needed for a normal agent flow (initialize → auth → new/load/resume → prompt → cancel + file I/O + tool approval) are implemented.

### Stable agent-side — IDE → agent (10 / 12)
### Stable agent-side — IDE → agent (12 / 12)

| Method | Implemented | Description |
| --- | --- | --- |
Expand All @@ -46,8 +46,8 @@ The spec divides methods into a **stable** surface and an evolving **unstable**
| `session/list` | Yes | Enumerates sessions on disk (advertised via `sessionCapabilities.list = {}`) |
| `session/set_mode` | Yes | Compatibility path; dispatches to the same handler as `set_config_option({configId:'mode'})` |
| `session/set_config_option` | Yes | Unified model / thinking / mode picker dispatcher |
| `session/close` | No | |
| `logout` | No | |
| `session/close` | Yes | Closes the specified session and removes it from the server's in-memory map |
| `logout` | Yes | Calls `harness.auth.logout` to clear the current authentication state |

### Stable client-side reverse-RPC — agent → IDE (4 / 9)

Expand Down
8 changes: 4 additions & 4 deletions docs/zh/reference/kimi-acp.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,9 @@ kimi acp

规范把方法分为**稳定**面和仍在演化的**不稳定**面(`@agentclientprotocol/sdk@0.23.0` 中以 `unstable_*` 前缀挂载的 handler)。两部分稳定性保证完全不同——稳定面是任何生产 ACP 客户端都会用到的方法,不稳定面覆盖实验性扩展(inline-edit 预测、document 缓冲区同步、provider 管理、elicitation 等),因此分开追踪。

**概览:稳定面 agent-side 实现 10/12(83%)+ client reverse-RPC 实现 4/9(44%);不稳定面只接入了 `session/set_model`(1/19)。** 任何正常 agent 流程所需的方法(initialize → auth → new/load/resume → prompt → cancel + 文件 I/O + 工具审批)都已实现。
**概览:稳定面 agent-side 实现 12/12(100%)+ client reverse-RPC 实现 4/9(44%);不稳定面只接入了 `session/set_model`(1/19)。** 任何正常 agent 流程所需的方法(initialize → auth → new/load/resume → prompt → cancel + 文件 I/O + 工具审批)都已实现。

### 稳定面 agent-side — IDE → agent(10 / 12)
### 稳定面 agent-side — IDE → agent(12 / 12)

| 方法 | 状态 | 说明 |
| --- | --- | --- |
Expand All @@ -46,8 +46,8 @@ kimi acp
| `session/list` | 是 | 枚举磁盘会话(通过 `sessionCapabilities.list = {}` 公告) |
| `session/set_mode` | 是 | 兼容路径,与 `set_config_option({configId:'mode'})` 走同一 dispatcher |
| `session/set_config_option` | 是 | 统一的 model / thinking / mode picker 分发 |
| `session/close` | 否 | |
| `logout` | 否 | |
| `session/close` | 是 | 关闭指定会话并从服务器内存映射中移除 |
| `logout` | 是 | 调用 `harness.auth.logout` 清除当前认证状态 |

### 稳定面 client-side reverse-RPC — agent → IDE(4 / 9)

Expand Down
37 changes: 37 additions & 0 deletions packages/acp-adapter/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,16 @@ import {
type AvailableCommand,
type CancelNotification,
type ClientCapabilities,
type CloseSessionRequest,
type CloseSessionResponse,
type Implementation,
type InitializeRequest,
type InitializeResponse,
type ListSessionsRequest,
type ListSessionsResponse,
type LoadSessionRequest,
type LogoutRequest,
type LogoutResponse,
type LoadSessionResponse,
type McpServer,
type NewSessionRequest,
Expand Down Expand Up @@ -307,6 +311,9 @@ export class AcpServer implements Agent {
this.clientCapabilities = params.clientCapabilities;

const agentCapabilities: AgentCapabilities = {
auth: {
logout: {},
},
loadSession: true,
promptCapabilities: {
image: true,
Expand All @@ -320,6 +327,7 @@ export class AcpServer implements Agent {
sessionCapabilities: {
list: {},
resume: {},
close: {},
},
};

Expand Down Expand Up @@ -665,6 +673,15 @@ export class AcpServer implements Agent {
// void = empty success body (ACP allows AuthenticateResponse | void).
}

/**
* Handle ACP `logout`. Delegates to {@link KimiHarness.auth.logout}.
* Returns `undefined` so the wire payload is the canonical empty
* success body (ACP allows `LogoutResponse | void`).
*/
async logout(_params: LogoutRequest): Promise<LogoutResponse | void> {
await this.harness.auth.logout();
}

async prompt(params: PromptRequest): Promise<PromptResponse> {
const acpSession = this.sessions.get(params.sessionId);
if (!acpSession) {
Expand Down Expand Up @@ -692,6 +709,26 @@ export class AcpServer implements Agent {
}
}

/**
* Handle ACP `session/close`. Looks the session up by id, closes it via
* {@link AcpSession.session}, and removes the wrapper from the server's
* in-memory map. Unknown session ids throw `invalid_params` (-32602).
*
* Returns `undefined` so the wire payload is the canonical empty success
* body (ACP allows `CloseSessionResponse | void`).
*/
async closeSession(params: CloseSessionRequest): Promise<CloseSessionResponse | void> {
const acpSession = this.sessions.get(params.sessionId);
if (!acpSession) {
throw RequestError.invalidParams(undefined, `Unknown sessionId: ${params.sessionId}`);
}
// Abort any prompt still in the pre-turn image-compression phase so it
// does not fall through to a closed session and surface an internal error.
await acpSession.cancel();
await acpSession.session.close();
Comment thread
xy200303 marked this conversation as resolved.
this.sessions.delete(params.sessionId);
}

/**
* Handle ACP `session/set_mode`. Looks the session up by id and
* forwards to {@link AcpSession.setMode}. Unknown session ids throw
Expand Down
65 changes: 65 additions & 0 deletions packages/acp-adapter/test/logout.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { describe, expect, it, vi } from 'vitest';

import {
AgentSideConnection,
ClientSideConnection,
ndJsonStream,
type Client,
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 } from './_helpers/harness-stubs';

class StubClient implements Client {
async requestPermission(_p: RequestPermissionRequest): Promise<RequestPermissionResponse> {
throw new Error('StubClient.requestPermission should not be called in logout test');
}
async sessionUpdate(_n: SessionNotification): Promise<void> {
throw new Error('StubClient.sessionUpdate should not be called in logout test');
}
async writeTextFile(_p: WriteTextFileRequest): Promise<WriteTextFileResponse> {
throw new Error('StubClient.writeTextFile should not be called in logout test');
}
async readTextFile(_p: ReadTextFileRequest): Promise<ReadTextFileResponse> {
throw new Error('StubClient.readTextFile should not be called in logout 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 };
}

describe('AcpServer logout', () => {
it('forwards logout to harness.auth.logout', async () => {
const logout = vi.fn().mockResolvedValue(undefined);
const harness = {
auth: {
status: async () => AUTHED_STATUS,
logout,
},
} 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 client.logout({});

expect(logout).toHaveBeenCalledTimes(1);
});
});
2 changes: 2 additions & 0 deletions packages/acp-adapter/test/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ describe('AcpServer + AgentSideConnection', () => {
expect(response.agentCapabilities?.mcpCapabilities?.sse).toBe(true);
expect(response.agentCapabilities?.sessionCapabilities?.list).toEqual({});
expect(response.agentCapabilities?.sessionCapabilities?.resume).toEqual({});
expect(response.agentCapabilities?.sessionCapabilities?.close).toEqual({});
expect(response.agentCapabilities?.auth?.logout).toEqual({});
});

it('initialize advertises terminal-auth with id, type, args, name', async () => {
Expand Down
105 changes: 105 additions & 0 deletions packages/acp-adapter/test/session-close.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { describe, expect, it } from 'vitest';

import {
AgentSideConnection,
ClientSideConnection,
ndJsonStream,
type Client,
type ReadTextFileRequest,
type ReadTextFileResponse,
type RequestPermissionRequest,
type RequestPermissionResponse,
type SessionNotification,
type WriteTextFileRequest,
type WriteTextFileResponse,
} from '@agentclientprotocol/sdk';
import { type KimiHarness, type Session } from '@moonshot-ai/kimi-code-sdk';

import { AcpServer } from '../src/server';
import { AUTHED_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 session/close test');
}
async sessionUpdate(_n: SessionNotification): Promise<void> {
throw new Error('StubClient.sessionUpdate should not be called in session/close test');
}
async writeTextFile(_p: WriteTextFileRequest): Promise<WriteTextFileResponse> {
throw new Error('StubClient.writeTextFile should not be called in session/close test');
}
async readTextFile(_p: ReadTextFileRequest): Promise<ReadTextFileResponse> {
throw new Error('StubClient.readTextFile should not be called in session/close 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 };
}

describe('AcpServer session/close', () => {
it('closes a known session and removes it from the server map', async () => {
let closeCalls = 0;
let cancelCalls = 0;
const fakeSession = {
id: 'sess-close-known',
prompt: async () => undefined,
cancel: async () => {
cancelCalls += 1;
},
close: async () => {
closeCalls += 1;
},
onEvent: () => () => undefined,
} as unknown as Session;
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => fakeSession,
} as unknown as KimiHarness;

const { agentStream, clientStream } = makeInMemoryStreamPair();
const serverRef = { current: undefined as AcpServer | undefined };
new AgentSideConnection(
(c) => {
const server = new AcpServer(harness, c);
serverRef.current = server;
return server;
},
agentStream,
);
const client = new ClientSideConnection((_a) => new StubClient(), clientStream);

await client.newSession({ cwd: '/tmp/x', mcpServers: [] });
expect(serverRef.current?.getSession('sess-close-known')).toBeDefined();

await client.closeSession({ sessionId: 'sess-close-known' });

expect(cancelCalls).toBe(1);
expect(closeCalls).toBe(1);
expect(serverRef.current?.getSession('sess-close-known')).toBeUndefined();
});

it('throws invalid_params when sessionId is unknown', async () => {
const harness = {
auth: { status: async () => AUTHED_STATUS },
createSession: async () => {
throw new Error('createSession should not be called when no session is created');
},
} 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.closeSession({ sessionId: 'sess-unknown' })).rejects.toThrow(
/Unknown sessionId/,
);
});
});