feat(server-v2): add RPC and WebSocket transport layer

This commit is contained in:
haozhe.yang 2026-06-29 01:14:14 +08:00
parent f9df159c49
commit 6173c6a97a
24 changed files with 3059 additions and 37 deletions

View file

@ -34,6 +34,7 @@ End-to-end procedures that span the stages. Reach for these before reading the s
- [Stage 1 — Orient](orient.md): the DI black box (identity / dependencies / lifetime), the four `LifecycleScope` tiers and visibility, and the file-header comment convention. Read before touching business code.
- [Stage 2 — Design a service](design.md): pick a scope, split a domain across scopes, choose a calling style (direct call vs event vs hook), and direct dependencies. Decide *where things live and who knows whom* before coding.
- Topic: [Persistence layering](persistence.md) — the three-layer `Store → Storage → backend` model, naming Stores by access pattern, and which layer business code should depend on.
- Topic: [Edge exposure — `resource:action` + WS events](edge-exposure.md) — which Services are exposed over `/api/v2` (per-scope action map) and which events stream over WS; what to wrap in a facade.
- [Stage 3 — Implement](implement.md): the standard Service recipe and the DI building blocks — interface + identity, constructor injection, scoped registration, `Disposable`, eager vs delayed, `invokeFunction`, `createInstance`, child scopes, and the cycle-refactor playbook.
- Topic: [Service authoring](service-authoring.md) — file layout, naming, contract vs impl contents, interface style, constructor/field conventions, events, multi-Service domains, comment rules.
- Topic: [Config](config.md) — the section-registry model, Core vs Session split, owning a config section, the TOML format, and the env overlay.

View file

@ -159,6 +159,8 @@ Red lines:
> Capability → orchestrator (e.g. `prompt → turn`) is allowed and present in this repo; the real red line is *inverted reuse* — a foundational / lower Service depending on a specific / upper one.
> When a Service is meant to be reached over the wire (`/api/v2`, WS), see [edge-exposure.md](edge-exposure.md) for the per-scope `resource:action` map, which Services may be exposed directly vs wrapped in a facade, and how events stream.
## 6. New-Service checklist
1. **What does it remember, and what is the state's identity?** → pick the scope (§2).

View file

@ -0,0 +1,180 @@
# Edge exposure — `resource:action` + WS events
How a domain's Services become the wire surface (`/api/v2`) and WebSocket events. This is a **design-time** decision: which Services are exposed, under what public `resource:action` name, and which events stream.
The transport (`/api/v2` over HTTP + WS) lives in the **edge** layer (`gateway`/`rpc`/`transport`). It borrows business Services by interface; business code never imports it.
## 1. The edge model
Three scopes, three URL shapes, one dispatcher:
```text
GET|POST /api/v2/:sa Core
GET|POST /api/v2/session/:session_id/:sa Session
GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa Agent
```
`:sa` is a single path segment of the form `<resource>:<action>` (e.g.
`sessions:list`, `session:read`, `profile:getModel`).
- `:resource` is a **public** name (`sessions`, `session`, `profile`), never an internal domain token (`ISessionMetadata`).
- `:action` is the method. `GET` for reads, `POST` for writes.
- Body = the method's single argument (JSON), omitted for no-arg.
- Response = the project envelope `{ code, msg, data, request_id, details? }`.
- The dispatcher resolves the **scope** from the URL, the **Service** from an `actionMap`, calls the method, wraps the result.
```ts
// actionMap — the allowlist; hides internal domain names.
const actionMap = {
core: { 'sessions:list': { service: ISessionIndex, method: 'list' }, ... },
session: { 'session:read': { service: ISessionMetadata, method: 'read' }, ... },
agent: { 'profile:getModel': { service: IProfileService, method: 'getModel' }, ... },
};
```
The `actionMap` is the single allowlist: only mapped `resource:action` pairs are callable; unknown → `40001`.
## 2. What may be exposed directly
A Service method is directly exposable iff **all** hold:
1. Args are JSON-serializable (no live objects, `AbortSignal`, callbacks, resumer fns).
2. Return is JSON-serializable data or `void` (no `IScopeHandle`, `Turn`, `IProcess`, `AsyncIterable`, `IDisposable`, `Event`).
3. Errors are `KimiError` (coded).
4. It is a command/query, not a factory, stream, byte-store, or sink.
If any fail → wrap in a **facade** (a Service that takes ids, returns data, throws `KimiError`) and expose the facade. The repo already ships a wire-shaped facade in `rpc/core-api.ts` (`CoreAPI` / `SessionAPI` / `AgentAPI`) behind `IAgentRPCService` / `ISessionRPCService` — prefer building the HTTP edge on top of it rather than re-deriving a new one.
## 3. Per-scope `resource:action` map
Read = `GET`, write = `POST`. `sid` = `session_id`, `aid` = `agent_id`.
### Core (`/api/v2/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `sessions` | `list` | ISessionIndex.list | GET |
| `sessions` | `get` | ISessionIndex.get | GET |
| `sessions` | `countActive` | ISessionIndex.countActive | GET |
| `workspaces` | `list` | IWorkspaceRegistry.list | GET |
| `workspaces` | `get` | IWorkspaceRegistry.get | GET |
| `workspaces` | `createOrTouch` | IWorkspaceRegistry.createOrTouch | POST |
| `workspaces` | `delete` | IWorkspaceRegistry.delete | POST |
| `config` | `get` / `getAll` / `inspect` | IConfigService.* | GET |
| `config` | `set` / `replace` / `reload` | IConfigService.* | POST |
| `providers` | `list` / `get` | IProviderService.* | GET |
| `providers` | `set` / `delete` | IProviderService.* | POST |
| `oauth` | `startLogin` / `cancelLogin` / `logout` | IOAuthService.* | POST |
| `oauth` | `getFlow` / `status` | IOAuthService.* | GET |
| `auth` | `summarize` | IAuthSummaryService.summarize | GET |
| `auth` | `ensureReady` | IAuthSummaryService.ensureReady | POST |
| `flags` | `snapshot` / `enabled` / `explain` / `explainAll` | IFlagService.* | GET |
| `fs` | `browse` / `home` | IHostFolderBrowser.* | GET |
| `meta` | `getEnv` / `detect` | IBootstrapService.* | GET |
### Session (`/api/v2/session/:sid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `session` | `read` | ISessionMetadata.read | GET |
| `session` | `update` | ISessionMetadata.update | POST |
| `session` | `setTitle` | ISessionMetadata.setTitle | POST |
| `session` | `setArchived` | ISessionMetadata.setArchived | POST |
| `session` | `status` | ISessionActivity.status | GET |
| `session` | `isIdle` | ISessionActivity.isIdle | GET |
| `session` | `archive` | ISessionService.archive | POST |
| `approvals` | `listPending` | IApprovalService.listPending | GET |
| `approvals` | `decide` | IApprovalService.decide | POST |
| `questions` | `listPending` | IQuestionService.listPending | GET |
| `questions` | `answer` | IQuestionService.answer | POST |
| `interactions` | `listPending` | IInteractionService.listPending | GET |
| `interactions` | `respond` | IInteractionService.respond | POST |
| `workspace` | `setWorkDir` / `addAdditionalDir` / `removeAdditionalDir` / `resolve` | IWorkspaceContext.* | GET/POST |
### Agent (`/api/v2/session/:sid/agent/:aid/:resource:action`)
| resource | action | Service.method | verb |
|---|---|---|---|
| `goal` | `get` | IGoalService.getGoal | GET |
| `goal` | `create` / `pause` / `resume` / `cancel` | IGoalService.* | POST |
| `plan` | `status` | IPlanService.status | GET |
| `plan` | `enter` / `exit` / `cancel` / `clear` | IPlanService.* | POST |
| `tasks` | `list` / `get` / `readOutput` | IBackgroundService.* | GET |
| `tasks` | `stop` / `detach` | IBackgroundService.* | POST |
| `usage` | `status` | IUsageService.status | GET |
| `context` | `status` | IContextSizeService.getStatus | GET |
| `swarm` | `isActive` | ISwarmService.isActive | GET |
| `swarm` | `enter` / `exit` | ISwarmService.* | POST |
| `permission` | `getMode` | IPermissionModeService.mode | GET |
| `permission` | `setMode` | IPermissionModeService.setMode | POST |
| `permissionRules` | `list` | IPermissionRulesService.rules | GET |
| `permissionRules` | `addRules` | IPermissionRulesService.addRules | POST |
| `profile` | `get` / `getModel` / `getSystemPrompt` / `getActiveToolNames` | IProfileService.* | GET |
| `profile` | `setModel` / `setThinking` | IProfileService.* | POST |
| `messages` | `list` | IContextMemory.get | GET |
| `messages` | `splice` | IContextMemory.splice | POST |
| `toolStore` | `get` / `data` | IToolStoreService.* | GET |
| `toolStore` | `set` | IToolStoreService.set | POST |
| `mcp` | `list` | IMcpService.list | GET |
| `mcp` | `reconnect` | IMcpService.reconnect | POST |
| `tools` | `list` | IToolRegistry.list | GET |
## 4. Facade-needed (wrap before exposing)
These fail §2 and must be wrapped in a facade that takes ids and returns data:
| Service | Why not direct | Facade shape |
|---|---|---|
| ISessionLifecycleService | returns `IScopeHandle` | `sessions.create` / `fork` / `close` / `archive` → wire Session |
| IPromptService / ITurnService | returns `Turn` handle | `prompts.submit` / `steer` / `abort` / `undo` |
| ILLMRequester | `AsyncIterable` stream | stream over WS, not RPC |
| ISubagentHost | `SubagentHandle` | `subagents.spawn` / `resume` → info |
| IProcessRunner | `IProcess` streams | terminal (separate WS protocol) |
| Storage (IStorageService / IAppendLogStore / IAtomicDocumentStore) | bytes / streams | not for RPC |
| IAgentFileSystem | `withCwd` handle | `fs.read` / `write` → text/bytes |
| IExternalHooksService | server-side outbound | not exposed |
| IWireRecord | write-ahead log | internal |
## 5. WS events
A single WebSocket endpoint multiplexes RPC `call`s and event `listen`s over a JSON protocol (the lean counterpart of VSCode's `IMessagePassingProtocol`, carrying the same safety features — see §6):
```text
WS /api/v2/ws
```
Client → server: `hello` (auth), `call` (scope + `resource:action` + arg), `cancel`, `listen` (scope + event), `unlisten`, `pong`.
Server → client: `ready`, `result`, `error`, `event`, `ping`.
`call` reuses the same dispatcher as the HTTP routes (scope + `actionMap`). `listen` subscribes to an `Event<T>` source and forwards each emission as an `event` message, keyed by the client-chosen `id`.
The `eventMap` binds a public event name to the scope's `Event` source (analogous to the `actionMap`):
| Scope | event | Source |
|---|---|---|
| Core | `events` | `IEventService.subscribe` (process-wide `DomainEvent` bus) |
| Agent | `events` | `IEventSink.on` (per-agent `AgentEvent` stream) |
Session-level `onDidChange` sources (metadata / interactions) carry no payload today, so they are not exposed until there is a concrete consumer.
Safety / reliability (carried over from `packages/server/src/ws/connection.ts` and VSCode's `ChannelServer`):
- request ids + active-request table — `cancel` / `unlisten` disposes them;
- heartbeat — `ping` every 30s, `pong` timeout 10s → `terminate`;
- schema validation — invalid frames are dropped, not fatal;
- graceful close — dispose listeners, cancel pending, reject in-flight calls;
- no stack traces over the wire;
- non-serializable event payloads are dropped, never fatal.
Cursor / replay / resync for events is a future addition (a separate `call` before `listen`); the raw stream is the foundation.
## 6. Red lines (edge exposure)
- Never expose an internal domain token (`ISessionMetadata`) as a URL segment — use a public `resource` name + `action`.
- Never expose a method that returns a handle / stream / bytes / disposable — wrap in a facade.
- Never expose a method that takes a live object / `AbortSignal` / callback / resumer fn — wrap in a facade.
- Session / Agent Services are reached by `accessor.get` with the id from the URL — never cache the result; finish before the scope disposes.
- The `actionMap` is the allowlist — only mapped `resource:action` pairs are callable; unknown → `40001`.
- Events stream over WS (`listen`), never RPC (`call`).
- Business code never imports the edge (`gateway` / `rpc` / `transport`) — the edge borrows business Services by interface.
- Read = `GET`, write = `POST`; do not overload `POST` for reads when caching / browser-friendliness matters.

View file

@ -10,6 +10,7 @@ export * from './log/index';
export * from './telemetry/index';
export * from './bootstrap/index';
export * from './hostFs/index';
export { IEventService, type DomainEvent } from './event/index';
export * from './kosong/index';
export * from './session-index/index';
@ -41,6 +42,7 @@ export * from './session/index';
export * from './eventSink/index';
import './approval/index';
export { IApprovalService } from './approval/index';
export * from './question/index';
export * from './gateway/index';
@ -79,6 +81,7 @@ export * from './todoList/index';
export * from './tool/index';
export * from './toolExecutor/index';
import './toolRegistry/index';
export { IToolRegistry } from './toolRegistry/index';
export * from './toolStore/index';
export * from './userTool/index';
export * from './wireRecord/index';

View file

@ -24,9 +24,11 @@
"pino": "^9.5.0",
"pino-pretty": "^13.0.0",
"ulid": "^3.0.1",
"ws": "^8.20.0",
"zod": "catalog:"
},
"devDependencies": {
"@types/ws": "^8.18.0",
"tsx": "^4.21.0"
}
}

View file

@ -0,0 +1,86 @@
/**
* `parseActionSuffix` shared helper for the `:action` URL convention
* (REST.md §1.6) introduced because Fastify's path syntax cannot disambiguate
* `:resource_id` from `:resource_id:action` on the same path prefix.
*
* Pattern: routes register a path `/...prefix/:tail` and pass the captured
* `tail` segment to this helper along with the list of allowed actions. The
* helper returns one of:
* - `{kind: 'bare', id: tail}` no action suffix, action defaulted
* - `{kind: 'action', id, action}` `id:action` parse
* - `{kind: 'invalid', reason}` unknown action or empty id
*
* Callers emit a `40001 VALIDATION_FAILED` envelope on `invalid`. The default
* action (when the caller's resource has a bare form) is encoded by the
* `defaultAction` parameter pass `undefined` if the route disallows the
* bare form.
*
* Examples (allowedActions = ['dismiss'], defaultAction = 'resolve'):
* 'q123' {kind:'bare', id:'q123'} resolve
* 'q123:dismiss' {kind:'action', id:'q123', action:'dismiss'}
* 'q123:foo' {kind:'invalid', reason:'unsupported action: q123:foo'}
* ':dismiss' {kind:'invalid', reason:'invalid <resource>_id in path'}
*
* **Why `lastIndexOf(':')`**: resource ids may CONTAIN a colon (e.g. mcp tool
* qualified names like `mcp:lark:search` if ever used in path position). We
* only treat the FINAL `:` as the action separator so internal colons survive.
*
* Ported verbatim from `packages/server/src/routes/action-suffix.ts`.
*/
export type ActionSuffixParse<TAction extends string> =
| { readonly kind: 'bare'; readonly id: string }
| { readonly kind: 'action'; readonly id: string; readonly action: TAction }
| { readonly kind: 'invalid'; readonly reason: string };
export interface ParseActionSuffixOptions<TAction extends string> {
readonly tail: string;
readonly allowedActions: readonly TAction[];
/**
* When set, a bare `<id>` (no action suffix) is accepted and reported as
* `{kind:'bare'}`. When `undefined`, bare ids are rejected with
* `unsupported action: <tail>` appropriate for resources where every
* REST action is an explicit `:verb` (e.g. `/sessions/{sid}/prompts/`).
*/
readonly defaultAction?: TAction;
/**
* Resource label used in the error message for empty-id failures, e.g.
* `'question'` `"invalid question_id in path"`. Defaults to `'resource'`.
*/
readonly resourceLabel?: string;
}
export function parseActionSuffix<TAction extends string>(
opts: ParseActionSuffixOptions<TAction>,
): ActionSuffixParse<TAction> {
const { tail, allowedActions, defaultAction, resourceLabel = 'resource' } = opts;
const idx = tail.lastIndexOf(':');
// No colon → bare id (allowed iff defaultAction is set).
if (idx <= 0) {
if (tail.length === 0) {
return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` };
}
if (defaultAction !== undefined) {
return { kind: 'bare', id: tail };
}
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
}
const id = tail.slice(0, idx);
const suffix = tail.slice(idx + 1);
// Trailing colon with empty suffix.
if (suffix === '') {
if (defaultAction !== undefined) {
return { kind: 'bare', id: tail };
}
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
}
if (id.length === 0) {
return { kind: 'invalid', reason: `invalid ${resourceLabel}_id in path` };
}
// Type narrowing: only allow declared actions through.
const matched = (allowedActions as readonly string[]).find((a) => a === suffix);
if (matched === undefined) {
return { kind: 'invalid', reason: `unsupported action: ${tail}` };
}
return { kind: 'action', id, action: matched as TAction };
}

View file

@ -14,6 +14,7 @@ import { okEnvelope } from '../envelope';
import { registerAuthRoute } from './auth';
import { registerMetaRoute } from './meta';
import { registerOAuthRoutes } from './oauth';
import { registerSessionsRoutes } from './sessions';
import { registerShutdownRoutes } from './shutdown';
interface ApiV1AppHost {
@ -27,10 +28,7 @@ interface ApiV1RouteHost {
get(
path: string,
options: { schema?: Record<string, unknown> },
handler: (
req: { id: string },
reply: { send(payload: unknown): unknown },
) => unknown,
handler: (req: { id: string }, reply: { send(payload: unknown): unknown }) => unknown,
): unknown;
}
@ -45,43 +43,54 @@ export async function registerApiV1Routes(
core: Scope,
opts: RegisterApiV1RoutesOptions,
): Promise<void> {
await app.register(async (apiV1) => {
registerHealthRoute(apiV1);
await app.register(
async (apiV1) => {
registerHealthRoute(apiV1);
registerMetaRoute(apiV1, {
serverVersion: opts.serverVersion,
serverId: ulid(),
startedAt: new Date().toISOString(),
});
registerMetaRoute(apiV1, {
serverVersion: opts.serverVersion,
serverId: ulid(),
startedAt: new Date().toISOString(),
});
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,
});
}, { prefix: '/api/v1' });
registerAuthRoute(apiV1 as unknown as Parameters<typeof registerAuthRoute>[0], core);
registerOAuthRoutes(apiV1 as unknown as Parameters<typeof registerOAuthRoutes>[0], core);
registerSessionsRoutes(
apiV1 as unknown as Parameters<typeof registerSessionsRoutes>[0],
core,
);
registerShutdownRoutes(apiV1 as unknown as Parameters<typeof registerShutdownRoutes>[0], {
onShutdown: opts.onShutdown,
});
},
{ prefix: '/api/v1' },
);
}
function registerHealthRoute(apiV1: ApiV1RouteHost): void {
apiV1.get('/healthz', {
schema: {
description: 'Health check',
response: {
200: {
type: 'object',
properties: {
code: { type: 'number' },
msg: { type: 'string' },
data: {
type: 'object',
properties: { ok: { type: 'boolean' } },
apiV1.get(
'/healthz',
{
schema: {
description: 'Health check',
response: {
200: {
type: 'object',
properties: {
code: { type: 'number' },
msg: { type: 'string' },
data: {
type: 'object',
properties: { ok: { type: 'boolean' } },
},
request_id: { type: 'string' },
},
request_id: { type: 'string' },
},
},
},
},
}, async (req, reply) => {
return reply.send(okEnvelope({ ok: true }, req.id));
});
async (req, reply) => {
return reply.send(okEnvelope({ ok: true }, req.id));
},
);
}

View file

@ -0,0 +1,534 @@
/**
* `/sessions` route handlers server-v2 port.
*
* Implements the v1 `/api/v1/sessions` wire contract on top of
* `agent-core-v2` services. Only the endpoints v2 can back today are
* registered:
* POST /sessions create
* GET /sessions list
* GET /sessions/{session_id} get
* GET /sessions/{session_id}/profile
* POST /sessions/{session_id}/profile update title (partial)
* POST /sessions/{tail} ::archive action
* GET /sessions/{session_id}/status best-effort
*
* The remaining v1 actions (fork / compact / undo / abort / btw / children /
* warnings) are not registered because `agent-core-v2` does not yet expose
* the backing capabilities see the server-v2 sessions gap list (G1G10).
*
* **Wire fidelity**: mirrors v1's `toProtocolSession`
* (`packages/agent-core/src/services/session/session.ts`), which populates
* only the index/metadata fields and returns placeholders for the heavy ones
* (`agent_config:{model:''}`, `usage:zeros`, `permission_rules:[]`,
* `message_count:0`, `last_seq:0`, hardcoded `status:'idle'`). v2 produces the
* same placeholder shape from `ISessionIndex` + `IWorkspaceRegistry`.
*
* **cwd resolution (gap G3)**: v2 does not store the original work dir on the
* session; we recover `metadata.cwd` from `IWorkspaceRegistry`
* (`workspaceId → root`). Sessions whose workspace is not registered cannot be
* represented and are filtered from list / 404 on get.
*/
import {
ISessionActivity,
ISessionContext,
ISessionIndex,
ISessionLifecycleService,
ISessionMetadata,
IWorkspaceRegistry,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import {
ErrorCode,
archiveSessionResponseSchema,
createSessionRequestSchema,
emptySessionUsage,
pageResponseSchema,
sessionSchema,
sessionStatusResponseSchema,
sessionStatusSchema,
updateSessionProfileRequestSchema,
} from '@moonshot-ai/protocol';
import type { Session } from '@moonshot-ai/protocol';
import { ulid } from 'ulid';
import { z } from 'zod';
import { errEnvelope, okEnvelope } from '../envelope';
import { defineRoute } from '../middleware/defineRoute';
import { parseActionSuffix } from './action-suffix';
interface SessionRouteHost {
post(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> },
handler: (
req: { id: string; body: unknown; params: unknown; headers: Record<string, unknown> },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
get(
path: string,
options: { preHandler: unknown[]; schema?: Record<string, unknown> } | undefined,
handler: (
req: { id: string; query: unknown; params: unknown },
reply: { send(payload: unknown): unknown },
) => Promise<void> | void,
): unknown;
}
const booleanQueryParam = z.preprocess((value) => {
if (value === 'true' || value === '1' || value === 1 || value === true) return true;
if (value === 'false' || value === '0' || value === 0 || value === false) return false;
return value;
}, z.boolean().optional());
// NOTE: `status` filtering and the `before_id`/`after_id` id-cursors are
// accepted for wire compatibility but not applied — `ISessionIndex` does not
// support them (gap G5). `page_size` maps to `limit`; `include_archive` maps
// to `includeArchived`; `workspace_id` maps to `workspaceId`.
const sessionsListQueryCoercion = z
.object({
before_id: z.string().min(1).optional(),
after_id: z.string().min(1).optional(),
page_size: z.coerce.number().int().min(1).max(100).optional(),
status: sessionStatusSchema.optional(),
include_archive: booleanQueryParam,
workspace_id: z.string().min(1).optional(),
})
.superRefine((value, ctx) => {
if (value.before_id !== undefined && value.after_id !== undefined) {
ctx.addIssue({
code: 'custom',
message: 'before_id and after_id are mutually exclusive',
path: ['before_id'],
params: { code: ErrorCode.VALIDATION_FAILED },
});
}
});
const sessionIdParamSchema = z.object({
session_id: z.string().min(1),
});
const sessionActionTailParamSchema = z.object({
tail: z.string().min(1),
});
const detailsSchema = z.array(z.object({ path: z.string(), message: z.string() }));
export function registerSessionsRoutes(app: SessionRouteHost, core: Scope): void {
const createRoute = defineRoute(
{
method: 'POST',
path: '/sessions',
body: createSessionRequestSchema,
success: { data: sessionSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.WORKSPACE_NOT_FOUND]: {},
},
description: 'Create a new session',
tags: ['sessions'],
},
async (req, reply) => {
const body = req.body;
const callerCwd = typeof body.metadata?.cwd === 'string' ? body.metadata.cwd : undefined;
const workspaceId = body.workspace_id;
if (workspaceId === undefined && callerCwd === undefined) {
reply.send(
buildValidationEnvelope(
[{ path: 'metadata.cwd', message: 'either workspace_id or metadata.cwd is required' }],
req.id,
),
);
return;
}
const registry = core.accessor.get(IWorkspaceRegistry);
let workDir: string;
if (workspaceId !== undefined) {
const workspace = await registry.get(workspaceId);
if (workspace === undefined) {
reply.send(
errEnvelope(
ErrorCode.WORKSPACE_NOT_FOUND,
`workspace ${workspaceId} does not exist`,
req.id,
),
);
return;
}
if (callerCwd !== undefined && callerCwd !== workspace.root) {
reply.send(
buildValidationEnvelope(
[
{
path: 'metadata.cwd',
message: `metadata.cwd (${callerCwd}) must equal workspace root (${workspace.root})`,
},
],
req.id,
),
);
return;
}
workDir = workspace.root;
} else {
workDir = callerCwd as string;
}
// Ensure the workspace is registered so `metadata.cwd` is resolvable on
// read (gap G3 — v2 does not store workDir on the session).
const touched = await registry.createOrTouch(workDir);
const handle = await core.accessor.get(ISessionLifecycleService).create({
sessionId: ulid(),
workDir,
});
if (typeof body.title === 'string') {
await handle.accessor.get(ISessionMetadata).setTitle(body.title);
}
const meta = await handle.accessor.get(ISessionMetadata).read();
reply.send(
okEnvelope(toWireSession({ ...meta, workspaceId: touched.id }, touched.root), req.id),
);
},
);
app.post(
createRoute.path,
createRoute.options,
createRoute.handler as Parameters<SessionRouteHost['post']>[2],
);
const listRoute = defineRoute(
{
method: 'GET',
path: '/sessions',
querystring: sessionsListQueryCoercion,
success: { data: pageResponseSchema(sessionSchema) },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
},
description: 'List sessions',
tags: ['sessions'],
},
async (req, reply) => {
const raw = req.query;
const limit = raw.page_size;
const page = await core.accessor.get(ISessionIndex).list({
workspaceId: raw.workspace_id,
includeArchived: raw.include_archive,
// Fetch one extra to detect `has_more` (FileSessionIndex does not
// expose a cursor today).
limit: limit !== undefined ? limit + 1 : undefined,
});
let hasMore = false;
let summaries = page.items;
if (limit !== undefined && summaries.length > limit) {
summaries = summaries.slice(0, limit);
hasMore = true;
}
const workspaces = await core.accessor.get(IWorkspaceRegistry).list();
const roots = new Map(workspaces.map((w) => [w.id, w.root]));
const items: Session[] = [];
for (const summary of summaries) {
const cwd = roots.get(summary.workspaceId);
if (cwd === undefined) continue; // gap G3: cannot represent cwd
items.push(toWireSession(summary, cwd));
}
reply.send(okEnvelope({ items, has_more: hasMore }, req.id));
},
);
app.get(
listRoute.path,
listRoute.options,
listRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
const getRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}',
params: sessionIdParamSchema,
success: { data: sessionSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Get a session by ID',
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
const summary = await core.accessor.get(ISessionIndex).get(session_id);
if (summary === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
const workspace = await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId);
if (workspace === undefined) {
// gap G3: persisted session whose workspace is not registered → cwd
// cannot be recovered.
reply.send(
errEnvelope(
ErrorCode.SESSION_NOT_FOUND,
`session ${session_id} workspace missing`,
req.id,
),
);
return;
}
reply.send(okEnvelope(toWireSession(summary, workspace.root), req.id));
},
);
app.get(
getRoute.path,
getRoute.options,
getRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
const getProfileRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/profile',
params: sessionIdParamSchema,
success: { data: sessionSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Get session profile',
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
const summary = await core.accessor.get(ISessionIndex).get(session_id);
if (summary === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
const workspace = await core.accessor.get(IWorkspaceRegistry).get(summary.workspaceId);
if (workspace === undefined) {
reply.send(
errEnvelope(
ErrorCode.SESSION_NOT_FOUND,
`session ${session_id} workspace missing`,
req.id,
),
);
return;
}
reply.send(okEnvelope(toWireSession(summary, workspace.root), req.id));
},
);
app.get(
getProfileRoute.path,
getProfileRoute.options,
getProfileRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
const updateProfileRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{session_id}/profile',
params: sessionIdParamSchema,
body: updateSessionProfileRequestSchema,
success: { data: sessionSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Update session profile (title only in this slice)',
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
const title = req.body.title;
if (typeof title === 'string') {
await handle.accessor.get(ISessionMetadata).setTitle(title);
}
const meta = await handle.accessor.get(ISessionMetadata).read();
const workspaceId = handle.accessor.get(ISessionContext).workspaceId;
const workspace = await core.accessor.get(IWorkspaceRegistry).get(workspaceId);
if (workspace === undefined) {
reply.send(
errEnvelope(
ErrorCode.SESSION_NOT_FOUND,
`session ${session_id} workspace missing`,
req.id,
),
);
return;
}
reply.send(okEnvelope(toWireSession({ ...meta, workspaceId }, workspace.root), req.id));
},
);
app.post(
updateProfileRoute.path,
updateProfileRoute.options,
updateProfileRoute.handler as Parameters<SessionRouteHost['post']>[2],
);
const sessionActionRoute = defineRoute(
{
method: 'POST',
path: '/sessions/{tail}',
params: sessionActionTailParamSchema,
success: { data: archiveSessionResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Run a session action (only ::archive is supported in this slice)',
tags: ['sessions'],
},
async (req, reply) => {
const { tail } = req.params;
const parsed = parseActionSuffix({
tail,
allowedActions: ['archive'] as const,
resourceLabel: 'session',
});
if (parsed.kind !== 'action') {
const message = parsed.kind === 'invalid' ? parsed.reason : `unsupported action: ${tail}`;
reply.send(buildValidationEnvelope([{ path: 'session_id', message }], req.id));
return;
}
const handle = core.accessor.get(ISessionLifecycleService).get(parsed.id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${parsed.id} does not exist`, req.id),
);
return;
}
await core.accessor.get(ISessionLifecycleService).archive(parsed.id);
reply.send(okEnvelope({ archived: true as const }, req.id));
},
);
app.post(
sessionActionRoute.path,
sessionActionRoute.options,
sessionActionRoute.handler as Parameters<SessionRouteHost['post']>[2],
);
const statusRoute = defineRoute(
{
method: 'GET',
path: '/sessions/{session_id}/status',
params: sessionIdParamSchema,
success: { data: sessionStatusResponseSchema },
errors: {
[ErrorCode.VALIDATION_FAILED]: { detailsSchema },
[ErrorCode.SESSION_NOT_FOUND]: {},
},
description: 'Get realtime session status (best-effort in this slice)',
tags: ['sessions'],
},
async (req, reply) => {
const { session_id } = req.params;
const handle = core.accessor.get(ISessionLifecycleService).get(session_id);
if (handle === undefined) {
reply.send(
errEnvelope(ErrorCode.SESSION_NOT_FOUND, `session ${session_id} does not exist`, req.id),
);
return;
}
const status = handle.accessor.get(ISessionActivity).status();
// Rich fields (model / thinking_level / permission / plan_mode /
// swarm_mode / context_*) require the main agent's scope; not wired yet
// (gap G10). Return safe defaults for the first slice.
reply.send(
okEnvelope(
{
status,
thinking_level: '',
permission: '',
plan_mode: false,
swarm_mode: false,
context_tokens: 0,
max_context_tokens: 0,
context_usage: 0,
},
req.id,
),
);
},
);
app.get(
statusRoute.path,
statusRoute.options,
statusRoute.handler as Parameters<SessionRouteHost['get']>[2],
);
}
// ---------------------------------------------------------------------------
// API body wrapper — pure field projection from a service return value to the
// wire `Session` shape. No service calls, no control flow: handlers pull data
// through `ServiceAccessor.get` and pass it straight here.
// ---------------------------------------------------------------------------
interface SessionWireFields {
readonly id: string;
readonly workspaceId: string;
readonly title?: string;
readonly createdAt: number;
readonly updatedAt: number;
readonly archived: boolean;
}
function toWireSession(fields: SessionWireFields, cwd: string): Session {
return {
id: fields.id,
workspace_id: fields.workspaceId,
title: fields.title ?? '',
created_at: new Date(fields.createdAt).toISOString(),
updated_at: new Date(fields.updatedAt).toISOString(),
status: 'idle',
archived: fields.archived,
metadata: { cwd },
agent_config: { model: '' },
usage: emptySessionUsage(),
permission_rules: [],
message_count: 0,
last_seq: 0,
};
}
function buildValidationEnvelope(
details: { path: string; message: string }[],
requestId: string,
): {
code: number;
msg: string;
data: null;
request_id: string;
details: { path: string; message: string }[];
} {
const first = details[0];
const msg =
first === undefined
? 'validation failed'
: first.path === ''
? first.message
: `${first.path}: ${first.message}`;
return {
code: ErrorCode.VALIDATION_FAILED,
msg,
data: null,
request_id: requestId,
details,
};
}

View file

@ -7,17 +7,31 @@
* Core-scoped services through `core.accessor.get(IXxx)`.
*/
import { bootstrap, resolveConfigPath, resolveKimiHome, type Scope } from '@moonshot-ai/agent-core-v2';
import {
bootstrap,
FileStorageService,
IAppendLogStorage,
IAtomicDocumentStorage,
logSeed,
resolveConfigPath,
resolveKimiHome,
resolveLoggingConfig,
type Scope,
type ScopeSeed,
type ServiceIdentifier,
} from '@moonshot-ai/agent-core-v2';
import Fastify, { type FastifyInstance } from 'fastify';
import { installErrorHandler } from './error-handler';
import { registerApiV1Routes } from './routes/registerApiV1Routes';
import { resolveRequestId } from './request-id';
import { registerApiV1Routes } from './routes/registerApiV1Routes';
import {
createServerLogger,
type ServerLogger,
type ServerLogLevel,
} from './services/pinoLoggerService';
import { registerRpcRoutes } from './transport/registerRpcRoutes';
import { registerWs } from './transport/ws/registerWs';
import { getServerVersion } from './version';
export interface ServerStartOptions {
@ -28,6 +42,8 @@ export interface ServerStartOptions {
readonly logLevel?: ServerLogLevel;
readonly logger?: ServerLogger;
readonly debugEndpoints?: boolean;
/** When set, require `Authorization: Bearer <rpcToken>` on `/api/v2`. */
readonly rpcToken?: string;
}
export interface RunningServer {
@ -41,10 +57,27 @@ export interface RunningServer {
const DEFAULT_HOST = '127.0.0.1';
const DEFAULT_PORT = 58627;
function durableStorageSeeds(homeDir: string): ScopeSeed {
return [
[IAtomicDocumentStorage as ServiceIdentifier<unknown>, new FileStorageService(homeDir)],
[IAppendLogStorage as ServiceIdentifier<unknown>, new FileStorageService(homeDir)],
];
}
export async function startServer(opts: ServerStartOptions = {}): Promise<RunningServer> {
const homeDir = resolveKimiHome(opts.homeDir);
const configPath = resolveConfigPath({ homeDir, configPath: opts.configPath });
const { core } = bootstrap({ homeDir, configPath });
// `ILogOptions` (logSeed) is required by the Session-scoped log writer; any
// route that creates a session (e.g. POST /sessions) would otherwise fail to
// instantiate the Session scope. Resolve it from env + homeDir like the CLI.
const logging = resolveLoggingConfig({ homeDir, env: process.env });
// `IAtomicDocumentStorage` / `IAppendLogStorage` default to in-memory; seed
// file-backed stores rooted at homeDir so session metadata (and later wire
// records) persist to disk where `FileSessionIndex` reads them.
const { core } = bootstrap({ homeDir, configPath }, [
...logSeed(logging),
...durableStorageSeeds(homeDir),
]);
const logger = opts.logger ?? createServerLogger({ level: opts.logLevel ?? 'info' });
@ -72,6 +105,9 @@ export async function startServer(opts: ServerStartOptions = {}): Promise<Runnin
},
});
registerRpcRoutes(app, core, { token: opts.rpcToken });
registerWs(app, core, { token: opts.rpcToken });
const host = opts.host ?? DEFAULT_HOST;
const port = opts.port ?? DEFAULT_PORT;
await app.listen({ host, port });

View file

@ -0,0 +1,210 @@
/**
* `/api/v2` action map the single allowlist of exposed Services.
*
* This is the server-side map from a public `resource:action` segment to the
* internal `ServiceIdentifier` + method. Domain names (`ISessionMetadata`,
* `IProfileService` ) never appear in the URL; this table is the only place
* that binds a public action to an internal Service.
*
* It also doubles as the **cheatsheet** of every directly-exposed endpoint.
* `readonly: true` means the action is exposed on `GET` as well as `POST`.
*
* URL shapes:
* /api/v2/:sa Core
* /api/v2/session/:session_id/:sa Session
* /api/v2/session/:session_id/agent/:agent_id/:sa Agent
* where `:sa` = `<resource>:<action>`.
*
* Only Services that are data/command (JSON in/out, `KimiError`) are listed
* here. Handle/stream/byte-store/sink Services are intentionally omitted
* they need a wrapper (see `edge-exposure.md` §4) and are not part of this
* direct-exposure surface.
*/
import {
IApprovalService,
IAuthSummaryService,
IBackgroundService,
IBootstrapService,
IConfigService,
IContextMemory,
IContextSizeService,
IFlagService,
IGoalService,
IHostFolderBrowser,
IInteractionService,
IMcpService,
IOAuthService,
IPermissionModeService,
IPermissionRulesService,
IPlanService,
IProfileService,
IProviderService,
IQuestionService,
ISessionActivity,
ISessionIndex,
ISessionMetadata,
ISessionService,
ISwarmService,
IToolRegistry,
IToolStoreService,
IUsageService,
IWorkspaceContext,
IWorkspaceRegistry,
} from '@moonshot-ai/agent-core-v2';
import type { ActionTarget, ScopeKind } from './channel';
export const actionMap: Record<ScopeKind, Record<string, ActionTarget>> = {
// -------------------------------------------------------------------------
// Core (/api/v2/:resource:action)
// -------------------------------------------------------------------------
core: {
'sessions:list': { service: ISessionIndex, method: 'list', readonly: true },
'sessions:get': { service: ISessionIndex, method: 'get', readonly: true },
'sessions:countActive': { service: ISessionIndex, method: 'countActive', readonly: true },
'workspaces:list': { service: IWorkspaceRegistry, method: 'list', readonly: true },
'workspaces:get': { service: IWorkspaceRegistry, method: 'get', readonly: true },
'workspaces:createOrTouch': { service: IWorkspaceRegistry, method: 'createOrTouch' },
'workspaces:delete': { service: IWorkspaceRegistry, method: 'delete' },
'config:get': { service: IConfigService, method: 'get', readonly: true },
'config:getAll': { service: IConfigService, method: 'getAll', readonly: true },
'config:inspect': { service: IConfigService, method: 'inspect', readonly: true },
'config:diagnostics': { service: IConfigService, method: 'diagnostics', readonly: true },
'config:set': { service: IConfigService, method: 'set' },
'config:replace': { service: IConfigService, method: 'replace' },
'config:reload': { service: IConfigService, method: 'reload' },
'providers:list': { service: IProviderService, method: 'list', readonly: true },
'providers:get': { service: IProviderService, method: 'get', readonly: true },
'providers:set': { service: IProviderService, method: 'set' },
'providers:delete': { service: IProviderService, method: 'delete' },
'oauth:startLogin': { service: IOAuthService, method: 'startLogin' },
'oauth:getFlow': { service: IOAuthService, method: 'getFlow', readonly: true },
'oauth:cancelLogin': { service: IOAuthService, method: 'cancelLogin' },
'oauth:logout': { service: IOAuthService, method: 'logout' },
'oauth:status': { service: IOAuthService, method: 'status', readonly: true },
'auth:summarize': { service: IAuthSummaryService, method: 'summarize', readonly: true },
'auth:ensureReady': { service: IAuthSummaryService, method: 'ensureReady' },
'flags:snapshot': { service: IFlagService, method: 'snapshot', readonly: true },
'flags:enabled': { service: IFlagService, method: 'enabled', readonly: true },
'flags:enabledIds': { service: IFlagService, method: 'enabledIds', readonly: true },
'flags:explain': { service: IFlagService, method: 'explain', readonly: true },
'flags:explainAll': { service: IFlagService, method: 'explainAll', readonly: true },
'fs:browse': { service: IHostFolderBrowser, method: 'browse', readonly: true },
'fs:home': { service: IHostFolderBrowser, method: 'home', readonly: true },
'meta:getEnv': { service: IBootstrapService, method: 'getEnv', readonly: true },
'meta:detect': { service: IBootstrapService, method: 'detect', readonly: true },
},
// -------------------------------------------------------------------------
// Session (/api/v2/session/:session_id/:resource:action)
// -------------------------------------------------------------------------
session: {
'session:read': { service: ISessionMetadata, method: 'read', readonly: true },
'session:update': { service: ISessionMetadata, method: 'update' },
'session:setTitle': { service: ISessionMetadata, method: 'setTitle' },
'session:setArchived': { service: ISessionMetadata, method: 'setArchived' },
'session:status': { service: ISessionActivity, method: 'status', readonly: true },
'session:isIdle': { service: ISessionActivity, method: 'isIdle', readonly: true },
'session:archive': { service: ISessionService, method: 'archive' },
'approvals:listPending': { service: IApprovalService, method: 'listPending', readonly: true },
'approvals:decide': { service: IApprovalService, method: 'decide' },
'questions:listPending': { service: IQuestionService, method: 'listPending', readonly: true },
'questions:answer': { service: IQuestionService, method: 'answer' },
'interactions:listPending': {
service: IInteractionService,
method: 'listPending',
readonly: true,
},
'interactions:respond': { service: IInteractionService, method: 'respond' },
'workspace:resolve': { service: IWorkspaceContext, method: 'resolve', readonly: true },
'workspace:isWithin': { service: IWorkspaceContext, method: 'isWithin', readonly: true },
'workspace:setWorkDir': { service: IWorkspaceContext, method: 'setWorkDir' },
'workspace:addAdditionalDir': { service: IWorkspaceContext, method: 'addAdditionalDir' },
'workspace:removeAdditionalDir': { service: IWorkspaceContext, method: 'removeAdditionalDir' },
},
// -------------------------------------------------------------------------
// Agent (/api/v2/session/:session_id/agent/:agent_id/:resource:action)
// -------------------------------------------------------------------------
agent: {
'goal:get': { service: IGoalService, method: 'getGoal', readonly: true },
'goal:create': { service: IGoalService, method: 'createGoal' },
'goal:pause': { service: IGoalService, method: 'pauseGoal' },
'goal:resume': { service: IGoalService, method: 'resumeGoal' },
'goal:cancel': { service: IGoalService, method: 'cancelGoal' },
'plan:status': { service: IPlanService, method: 'status', readonly: true },
'plan:enter': { service: IPlanService, method: 'enter' },
'plan:exit': { service: IPlanService, method: 'exit' },
'plan:cancel': { service: IPlanService, method: 'cancel' },
'plan:clear': { service: IPlanService, method: 'clear' },
'tasks:list': { service: IBackgroundService, method: 'list', readonly: true },
'tasks:get': { service: IBackgroundService, method: 'getTask', readonly: true },
'tasks:readOutput': { service: IBackgroundService, method: 'readOutput', readonly: true },
'tasks:stop': { service: IBackgroundService, method: 'stop' },
'tasks:detach': { service: IBackgroundService, method: 'detach' },
'usage:status': { service: IUsageService, method: 'status', readonly: true },
'context:status': { service: IContextSizeService, method: 'getStatus', readonly: true },
'swarm:isActive': { service: ISwarmService, method: 'isActive', readonly: true },
'swarm:enter': { service: ISwarmService, method: 'enter' },
'swarm:exit': { service: ISwarmService, method: 'exit' },
'permission:getMode': { service: IPermissionModeService, method: 'mode', readonly: true },
'permission:setMode': { service: IPermissionModeService, method: 'setMode' },
'permissionRules:list': { service: IPermissionRulesService, method: 'rules', readonly: true },
'permissionRules:addRules': { service: IPermissionRulesService, method: 'addRules' },
'profile:get': { service: IProfileService, method: 'data', readonly: true },
'profile:getModel': { service: IProfileService, method: 'getModel', readonly: true },
'profile:getSystemPrompt': {
service: IProfileService,
method: 'getSystemPrompt',
readonly: true,
},
'profile:getActiveToolNames': {
service: IProfileService,
method: 'getActiveToolNames',
readonly: true,
},
'profile:setModel': { service: IProfileService, method: 'setModel' },
'profile:setThinking': { service: IProfileService, method: 'setThinking' },
'messages:list': { service: IContextMemory, method: 'get', readonly: true },
'messages:splice': { service: IContextMemory, method: 'splice' },
'toolStore:get': { service: IToolStoreService, method: 'get', readonly: true },
'toolStore:data': { service: IToolStoreService, method: 'data', readonly: true },
'toolStore:set': { service: IToolStoreService, method: 'set' },
'mcp:list': { service: IMcpService, method: 'list', readonly: true },
'mcp:reconnect': { service: IMcpService, method: 'reconnect' },
'tools:list': { service: IToolRegistry, method: 'list', readonly: true },
},
};
/** Look up an action target within a scope. */
export function resolveAction(
scopeKind: ScopeKind,
sa: { resource: string; action: string },
): ActionTarget | undefined {
return actionMap[scopeKind][`${sa.resource}:${sa.action}`];
}

View file

@ -0,0 +1,57 @@
/**
* `/api/v2` channel transport shared primitives.
*
* The transport exposes `agent-core-v2` Services directly (no facade): a URL
* carries the scope path + a `resource:action` segment, the dispatcher resolves
* the scope + Service + method, and the result is wrapped in the project
* envelope. This file holds the small, transport-agnostic pieces shared by the
* server dispatcher and the client.
*/
import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2';
/** Which scope a route resolves before dispatching. */
export type ScopeKind = 'core' | 'session' | 'agent';
/**
* One entry in the action map: the Service to resolve and the method to call.
* `method` is a string so the map can point at any method without a typed
* reference; the dispatcher checks it is a function before calling.
*/
export interface ActionTarget {
readonly service: ServiceIdentifier<unknown>;
readonly method: string;
/** Read-only methods are exposed on `GET` as well as `POST`. */
readonly readonly?: boolean;
}
/** The client-facing channel contract (request/response + future events). */
export interface IChannel {
call<T>(command: string, arg?: unknown): Promise<T>;
listen(event: string, arg?: unknown): unknown;
}
/**
* Parsed `resource:action` segment. `resource` is the public resource name,
* `action` the method name. Service names never contain a colon, so splitting
* at the last colon is unambiguous.
*/
export interface ServiceAction {
readonly resource: string;
readonly action: string;
}
/**
* Parse a `resource:action` segment. Returns `undefined` when the segment has
* no colon, an empty resource, or an empty action.
*/
export function parseServiceAction(sa: string): ServiceAction | undefined {
const idx = sa.lastIndexOf(':');
if (idx <= 0 || idx === sa.length - 1) return undefined;
return { resource: sa.slice(0, idx), action: sa.slice(idx + 1) };
}
/** Build a `resource:action` segment (inverse of {@link parseServiceAction}). */
export function formatServiceAction(resource: string, action: string): string {
return `${resource}:${action}`;
}

View file

@ -0,0 +1,98 @@
/**
* `/api/v2` dispatcher resolves the scope + Service + method from a request
* and calls it. No facade: Services are reached directly through the scope
* tree, and the `actionMap` is the only thing binding a public action to an
* internal Service.
*/
import {
ErrorCodes,
IAgentLifecycleService,
ISessionLifecycleService,
KimiError,
type IScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import { resolveAction } from './actionMap';
import type { ActionTarget, ScopeKind, ServiceAction } from './channel';
import { assertSerializable } from './errors';
/**
* Resolve the scope a request targets. Returns `undefined` when the referenced
* session / agent does not exist (caller maps to `40401`).
*/
export function resolveScope(
core: Scope,
scopeKind: ScopeKind,
params: Record<string, string>,
): Scope | IScopeHandle | undefined {
switch (scopeKind) {
case 'core':
return core;
case 'session': {
const sessionId = params['session_id'] ?? '';
return core.accessor.get(ISessionLifecycleService).get(sessionId);
}
case 'agent': {
const sessionId = params['session_id'] ?? '';
const agentId = params['agent_id'] ?? '';
const session = core.accessor.get(ISessionLifecycleService).get(sessionId);
if (session === undefined) return undefined;
return session.accessor.get(IAgentLifecycleService).getHandle(agentId);
}
}
}
/**
* Dispatch one call. Throws `KimiError` for expected failures (unknown action,
* scope not found, service not in scope, method missing); the route maps them
* to the envelope. Unexpected errors propagate and become `50001`.
*/
export async function dispatch(
core: Scope,
scopeKind: ScopeKind,
params: Record<string, string>,
sa: ServiceAction,
arg: unknown,
): Promise<unknown> {
const scope = resolveScope(core, scopeKind, params);
if (scope === undefined) {
throw new KimiError(
ErrorCodes.SESSION_NOT_FOUND,
`session ${params['session_id'] ?? ''} not found`,
);
}
const target: ActionTarget | undefined = resolveAction(scopeKind, sa);
if (target === undefined) {
throw new KimiError(ErrorCodes.REQUEST_INVALID, `unknown action: ${sa.resource}:${sa.action}`);
}
let service: unknown;
try {
service = scope.accessor.get(target.service);
} catch {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
`service not available in ${scopeKind} scope: ${sa.resource}`,
);
}
const member = (service as Record<string, unknown>)[target.method];
if (member === undefined) {
throw new KimiError(
ErrorCodes.REQUEST_INVALID,
`method not found: ${sa.resource}:${sa.action}`,
);
}
// Property read (e.g. `mode`, `rules`, `isActive`) — return as-is.
if (typeof member !== 'function') {
return assertSerializable(member);
}
const args = Array.isArray(arg) ? arg : arg === undefined ? [] : [arg];
const result = await (member as (...a: unknown[]) => unknown).apply(service, args);
return assertSerializable(result);
}

View file

@ -0,0 +1,115 @@
/**
* `/api/v2` transport error handling map internal errors onto the project
* envelope, guard serialization, time-box calls, and gate access.
*/
import { timingSafeEqual } from 'node:crypto';
import { ErrorCodes, KimiError } from '@moonshot-ai/agent-core-v2';
import { ErrorCode, errEnvelope } from '@moonshot-ai/protocol';
/** Thrown by {@link withTimeout} when a call exceeds its deadline. */
export class TimeoutError extends Error {
constructor(readonly ms: number) {
super(`call timed out after ${ms}ms`);
this.name = 'TimeoutError';
}
}
/** Race a promise against a deadline. */
export function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {
if (ms <= 0) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => reject(new TimeoutError(ms)), ms);
timer.unref?.();
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
}) as Promise<T>;
}
const KIMI_TO_PROTOCOL: Record<string, ErrorCode> = {
[ErrorCodes.SESSION_NOT_FOUND]: ErrorCode.SESSION_NOT_FOUND,
[ErrorCodes.REQUEST_INVALID]: ErrorCode.VALIDATION_FAILED,
[ErrorCodes.NOT_IMPLEMENTED]: ErrorCode.INTERNAL_ERROR,
};
/**
* Map an internal error to the project envelope. `KimiError` keeps its coded
* mapping; everything else becomes `50001`. Stack traces are intentionally not
* surfaced.
*/
export function mapError(err: unknown, requestId: string): ReturnType<typeof errEnvelope> {
if (err instanceof KimiError) {
const code = KIMI_TO_PROTOCOL[err.code] ?? ErrorCode.INTERNAL_ERROR;
return errEnvelope(code, err.message, requestId);
}
if (err instanceof TimeoutError) {
return errEnvelope(ErrorCode.INTERNAL_ERROR, err.message, requestId);
}
return errEnvelope(
ErrorCode.INTERNAL_ERROR,
err instanceof Error ? err.message : String(err),
requestId,
);
}
/** Build a `40001` envelope with structured details. */
export function validationEnvelope(
details: { path: string; message: string }[],
requestId: string,
): {
code: number;
msg: string;
data: null;
request_id: string;
details: { path: string; message: string }[];
} {
const first = details[0];
const msg =
first === undefined
? 'validation failed'
: first.path === ''
? first.message
: `${first.path}: ${first.message}`;
return {
code: ErrorCode.VALIDATION_FAILED,
msg,
data: null,
request_id: requestId,
details,
};
}
/**
* Ensure a value survives a JSON round-trip (catches circular refs, `BigInt`,
* functions). Returns the value unchanged; throws `KimiError` on failure so the
* caller maps it to `50001` with a clear message.
*/
export function assertSerializable(value: unknown): unknown {
if (value === undefined) return null;
try {
JSON.stringify(value);
} catch (error) {
throw new KimiError(
ErrorCodes.INTERNAL,
`result not serializable: ${error instanceof Error ? error.message : String(error)}`,
);
}
return value;
}
/**
* Timing-safe bearer-token check against the `Authorization` header. Query
* `?tkn=` is intentionally not supported on the wire to avoid leaking tokens
* into logs; use the header.
*/
export function isAuthorized(headers: Record<string, unknown>, token: string): boolean {
const auth = headers['authorization'];
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false;
const presented = auth.slice('Bearer '.length);
const a = Buffer.from(presented);
const b = Buffer.from(token);
return a.length === b.length && timingSafeEqual(a, b);
}

View file

@ -0,0 +1,137 @@
/**
* `/api/v2` route registration mounts the channel dispatcher on Fastify.
*
* Three routes mirror the scope tree; all share one handler. `:sa` is the
* `resource:action` segment. Reads use `GET`, writes use `POST`.
*
* GET|POST /api/v2/:sa
* GET|POST /api/v2/session/:session_id/:sa
* GET|POST /api/v2/session/:session_id/agent/:agent_id/:sa
*
* Body (POST) or `?arg=<json>` (GET) is the method's single argument.
* Responses are always the project envelope (HTTP 200; business outcome in
* `code`). Body size, connection timeout, and graceful close are Fastify's.
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
import { ErrorCode, errEnvelope, okEnvelope } from '@moonshot-ai/protocol';
import { actionMap } from './actionMap';
import type { ScopeKind } from './channel';
import { parseServiceAction } from './channel';
import { dispatch } from './dispatcher';
import { isAuthorized, mapError, validationEnvelope, withTimeout } from './errors';
interface RpcRequest {
readonly id: string;
readonly method: string;
readonly body: unknown;
readonly query: unknown;
readonly params: unknown;
readonly headers: Record<string, unknown>;
}
interface RpcReply {
status(code: number): { send(payload: unknown): unknown };
send(payload: unknown): unknown;
}
interface RouteHost {
get(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
post(path: string, handler: (req: RpcRequest, reply: RpcReply) => Promise<unknown>): unknown;
}
export interface RegisterRpcRoutesOptions {
/** When set, require `Authorization: Bearer <token>` on every call. */
readonly token?: string;
/** Per-call deadline in ms. Default 30s. */
readonly callTimeoutMs?: number;
}
const SCOPE_ROUTES: { path: string; scopeKind: ScopeKind }[] = [
{ path: '/api/v2/:sa', scopeKind: 'core' },
{ path: '/api/v2/session/:session_id/:sa', scopeKind: 'session' },
{ path: '/api/v2/session/:session_id/agent/:agent_id/:sa', scopeKind: 'agent' },
];
export function registerRpcRoutes(
app: RouteHost,
core: Scope,
opts: RegisterRpcRoutesOptions = {},
): void {
for (const { path, scopeKind } of SCOPE_ROUTES) {
const handler = makeHandler(core, scopeKind, opts);
app.get(path, handler);
app.post(path, handler);
}
}
function makeHandler(
core: Scope,
scopeKind: ScopeKind,
opts: RegisterRpcRoutesOptions,
): (req: RpcRequest, reply: RpcReply) => Promise<unknown> {
return async (req, reply) => {
const requestId = req.id;
// Auth.
if (opts.token !== undefined && !isAuthorized(req.headers, opts.token)) {
return reply
.status(401)
.send(errEnvelope(ErrorCode.AUTH_TOKEN_UNAUTHORIZED, 'unauthorized', requestId));
}
// Parse `resource:action`.
const { sa } = req.params as { sa: string };
const parsed = parseServiceAction(sa);
if (parsed === undefined) {
return reply.send(
validationEnvelope(
[{ path: 'action', message: `expected <resource>:<action>, got '${sa}'` }],
requestId,
),
);
}
// Read vs write gate.
const target = actionMap[scopeKind][`${parsed.resource}:${parsed.action}`];
const isGet = req.method.toUpperCase() === 'GET';
if (isGet && target !== undefined && target.readonly !== true) {
return reply.send(
validationEnvelope(
[{ path: 'action', message: `'${sa}' is not a read action` }],
requestId,
),
);
}
// Parse argument.
let arg: unknown;
try {
arg = isGet ? parseArgFromQuery(req.query) : req.body;
} catch {
return reply.send(
validationEnvelope([{ path: 'arg', message: 'invalid JSON in ?arg=' }], requestId),
);
}
// Dispatch + timeout + envelope.
try {
const result = await withTimeout(
dispatch(core, scopeKind, req.params as Record<string, string>, parsed, arg),
opts.callTimeoutMs ?? 30_000,
);
return reply.send(okEnvelope(result, requestId));
} catch (error) {
return reply.send(mapError(error, requestId));
}
};
}
function parseArgFromQuery(query: unknown): unknown {
const q = query as Record<string, unknown> | undefined;
const raw = q?.['arg'];
if (raw === undefined) return undefined;
if (typeof raw !== 'string') return undefined;
return JSON.parse(raw) as unknown;
}

View file

@ -0,0 +1,154 @@
/**
* `/api/v2` typed client `fetch`-based channel + scoped typed proxies.
*
* const client = new RpcClient({ url: 'http://127.0.0.1:58627' });
* await client.core<ISessionIndex>('sessions').list(query);
* await client.session('abc').service<ISessionMetadata>('session').read();
* await client.session('abc').agent('xyz').service<IProfileService>('profile').getModel();
*
* The client always `POST`s (the server accepts `GET` too, but the typed client
* does not need it). The envelope is unwrapped here: a non-zero `code` throws
* {@link RpcError}; otherwise `data` is returned.
*/
import type { IChannel } from './channel';
import { formatServiceAction } from './channel';
interface Envelope<T> {
readonly code: number;
readonly msg: string;
readonly data: T;
readonly request_id: string;
readonly details?: unknown;
}
export class RpcError extends Error {
constructor(
readonly code: number,
message: string,
readonly details?: unknown,
) {
super(message);
this.name = 'RpcError';
}
}
interface HttpChannelOptions {
readonly baseUrl: string;
readonly token?: string;
}
/** A single `resource` channel bound to a scope URL. */
class HttpChannel implements IChannel {
private readonly baseUrl: string;
private readonly token?: string;
constructor(opts: HttpChannelOptions) {
this.baseUrl = opts.baseUrl.replace(/\/$/, '');
this.token = opts.token;
}
async call<T>(command: string, arg?: unknown): Promise<T> {
const headers: Record<string, string> = {};
let body: string | undefined;
if (arg !== undefined) {
headers['content-type'] = 'application/json';
body = JSON.stringify(arg);
}
if (this.token !== undefined) {
headers['authorization'] = `Bearer ${this.token}`;
}
const res = await fetch(`${this.baseUrl}/${command}`, {
method: 'POST',
headers,
body,
});
const envelope = (await res.json()) as Envelope<T>;
if (envelope.code !== 0) {
throw new RpcError(envelope.code, envelope.msg, envelope.details);
}
return envelope.data;
}
listen<T>(_event: string, _arg?: unknown): T {
throw new Error('events not supported over HTTP; use the WS transport');
}
}
function makeProxy<T extends object>(channel: IChannel, resource: string): T {
return new Proxy({} as T, {
get(_target, prop) {
if (typeof prop !== 'string') return undefined;
return (arg?: unknown) => channel.call(formatServiceAction(resource, prop), arg);
},
});
}
export interface RpcClientOptions {
/** Base URL of the server, e.g. `http://127.0.0.1:58627`. */
readonly url: string;
/** Optional bearer token. */
readonly token?: string;
}
export class RpcClient {
private readonly url: string;
private readonly token?: string;
constructor(opts: RpcClientOptions) {
this.url = opts.url.replace(/\/$/, '');
this.token = opts.token;
}
/** Core-scoped resource, e.g. `client.core<ISessionIndex>('sessions')`. */
core<T extends object>(resource: string): T {
return makeProxy<T>(
new HttpChannel({ baseUrl: `${this.url}/api/v2`, token: this.token }),
resource,
);
}
/** Session scope entry point. */
session(sessionId: string): SessionRpcClient {
return new SessionRpcClient(this.url, this.token, sessionId);
}
}
export class SessionRpcClient {
private readonly baseUrl: string;
constructor(
url: string,
private readonly token: string | undefined,
sessionId: string,
) {
this.baseUrl = `${url.replace(/\/$/, '')}/api/v2/session/${encodeURIComponent(sessionId)}`;
}
/** Session-scoped resource, e.g. `.service<ISessionMetadata>('session')`. */
service<T extends object>(resource: string): T {
return makeProxy<T>(new HttpChannel({ baseUrl: this.baseUrl, token: this.token }), resource);
}
/** Agent scope entry point. */
agent(agentId: string): AgentRpcClient {
return new AgentRpcClient(this.baseUrl, this.token, agentId);
}
}
export class AgentRpcClient {
private readonly baseUrl: string;
constructor(
sessionBaseUrl: string,
private readonly token: string | undefined,
agentId: string,
) {
this.baseUrl = `${sessionBaseUrl}/agent/${encodeURIComponent(agentId)}`;
}
/** Agent-scoped resource, e.g. `.service<IProfileService>('profile')`. */
service<T extends object>(resource: string): T {
return makeProxy<T>(new HttpChannel({ baseUrl: this.baseUrl, token: this.token }), resource);
}
}

View file

@ -0,0 +1,49 @@
/**
* `/api/v2` WS event map binds a public event name to the scope's `Event`
* source. Mirrors the `actionMap` (which binds `resource:action` method); this
* binds `event` an `Event` subscription that yields an `IDisposable`.
*
* Only the high-value streams are exposed for now:
* Core `events` process-wide `DomainEvent` bus (`IEventService`)
* Agent `events` per-agent `AgentEvent` stream (`IEventSink`)
*/
import {
IEventService,
IEventSink,
type DomainEvent,
type IDisposable,
type IScopeHandle,
type Scope,
} from '@moonshot-ai/agent-core-v2';
import type { ScopeKind } from '../channel';
type Accessor = Scope | IScopeHandle;
export interface EventSource {
subscribe(scope: Accessor, listener: (data: unknown) => void): IDisposable;
}
export const eventMap: Record<ScopeKind, Record<string, EventSource>> = {
core: {
events: {
subscribe: (scope, listener) =>
scope.accessor.get(IEventService).subscribe(listener as (event: DomainEvent) => void),
},
},
session: {
// Future: `metadata` (ISessionMetadata.onDidChange), `interactions`
// (IInteractionService.onDidChange). These carry no payload today, so they
// are not exposed until there is a concrete consumer.
},
agent: {
events: {
subscribe: (scope, listener) => scope.accessor.get(IEventSink).on(listener),
},
},
};
export function resolveEventSource(scopeKind: ScopeKind, event: string): EventSource | undefined {
return eventMap[scopeKind][event];
}

View file

@ -0,0 +1,59 @@
/**
* `/api/v2/ws` mounts the WebSocket endpoint on Fastify's underlying HTTP
* server. Uses `ws` in `noServer` mode and handles the `upgrade` event for the
* `/api/v2/ws` path only; other upgrade requests are destroyed.
*
* Lifecycle / cleanup:
* - each connection is a {@link WsConnection}, tracked in a set;
* - on `app.close()` every connection is closed and the WS server is shut down;
* - per-connection heartbeat / cleanup lives in {@link WsConnection}.
*/
import type { Scope } from '@moonshot-ai/agent-core-v2';
import type { FastifyInstance } from 'fastify';
import { WebSocketServer } from 'ws';
import { WsConnection } from './wsConnection';
export interface RegisterWsOptions {
readonly token?: string;
readonly pingIntervalMs?: number;
readonly pongTimeoutMs?: number;
readonly callTimeoutMs?: number;
}
const WS_PATH = '/api/v2/ws';
export function registerWs(app: FastifyInstance, core: Scope, opts: RegisterWsOptions = {}): void {
const wss = new WebSocketServer({ noServer: true });
const connections = new Set<WsConnection>();
app.server.on('upgrade', (req, socket, head) => {
const url = req.url ?? '';
if (url === WS_PATH || url.startsWith(`${WS_PATH}?`)) {
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit('connection', ws, req);
});
} else {
socket.destroy();
}
});
wss.on('connection', (socket) => {
const conn = new WsConnection({
socket,
core,
token: opts.token,
pingIntervalMs: opts.pingIntervalMs,
pongTimeoutMs: opts.pongTimeoutMs,
callTimeoutMs: opts.callTimeoutMs,
});
connections.add(conn);
socket.on('close', () => connections.delete(conn));
});
app.addHook('onClose', async () => {
for (const conn of connections) conn.close();
wss.close();
});
}

View file

@ -0,0 +1,177 @@
/**
* `/api/v2` WebSocket client test/consumer-side counterpart of
* {@link WsConnection}. Speaks the same JSON protocol:
* - `call(scope, sa, arg)` Promise<data> (rejects on `error`)
* - `listen(scope, event)` AsyncIterable<data> + `cancel()`
* - answers `ping` with `pong` (heartbeat)
*/
import { ulid } from 'ulid';
import { WebSocket } from 'ws';
import type { ScopeKind } from '../channel';
interface PendingCall {
readonly resolve: (data: unknown) => void;
readonly reject: (err: Error) => void;
}
export interface WsClientOptions {
readonly url: string;
readonly token?: string;
}
interface ServerMsg {
readonly type: string;
readonly id?: string;
readonly data?: unknown;
readonly code?: number;
readonly msg?: string;
}
export class RpcWsError extends Error {
constructor(
readonly code: number,
message: string,
) {
super(message);
this.name = 'RpcWsError';
}
}
export class WsClient {
private readonly ws: WebSocket;
private readonly ready: Promise<void>;
private resolveReady!: () => void;
private rejectReady!: (err: Error) => void;
private readonly pending = new Map<string, PendingCall>();
private readonly listeners = new Map<string, (data: unknown) => void>();
constructor(opts: WsClientOptions) {
this.ws = new WebSocket(opts.url);
this.ready = new Promise<void>((resolve, reject) => {
this.resolveReady = resolve;
this.rejectReady = reject;
});
this.ws.on('open', () => {
this.ws.send(JSON.stringify({ type: 'hello', token: opts.token }));
});
this.ws.on('message', (data) => this.onMessage(data.toString()));
this.ws.on('error', (err) => {
this.rejectReady(err);
for (const p of this.pending.values()) p.reject(err);
this.pending.clear();
});
this.ws.on('close', () => {
const err = new Error('ws closed');
for (const p of this.pending.values()) p.reject(err);
this.pending.clear();
});
}
async call<T>(
scope: ScopeKind,
sa: string,
arg?: unknown,
scopeIds?: { sessionId?: string; agentId?: string },
): Promise<T> {
await this.ready;
const id = ulid();
this.ws.send(JSON.stringify({ type: 'call', id, scope, sa, arg, ...scopeIds }));
return new Promise<T>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (d: unknown) => void, reject });
});
}
listen<T>(
scope: ScopeKind,
event: string,
scopeIds?: { sessionId?: string; agentId?: string },
): { iterator: AsyncIterable<T>; cancel: () => void } {
const id = ulid();
const queue: T[] = [];
let wake: (() => void) | undefined;
let done = false;
this.listeners.set(id, (data) => {
queue.push(data as T);
wake?.();
});
void this.ready.then(() => {
if (this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify({ type: 'listen', id, scope, event, ...scopeIds }));
}
});
const iterator: AsyncIterableIterator<T> = {
[Symbol.asyncIterator]() {
return this;
},
next: async (): Promise<IteratorResult<T>> => {
if (queue.length > 0) return { value: queue.shift() as T, done: false };
if (done) return { value: undefined as unknown as T, done: true };
await new Promise<void>((resolve) => {
wake = resolve;
});
if (queue.length > 0) return { value: queue.shift() as T, done: false };
return { value: undefined as unknown as T, done: true };
},
};
const cancel = (): void => {
if (done) return;
done = true;
wake?.();
this.listeners.delete(id);
if (this.ws.readyState === this.ws.OPEN) {
this.ws.send(JSON.stringify({ type: 'unlisten', id }));
}
};
return { iterator, cancel };
}
close(): void {
this.ws.close();
}
private onMessage(raw: string): void {
let msg: ServerMsg;
try {
msg = JSON.parse(raw) as ServerMsg;
} catch {
return;
}
switch (msg.type) {
case 'ready':
this.resolveReady();
return;
case 'ping':
this.ws.send(JSON.stringify({ type: 'pong' }));
return;
case 'result': {
const p = this.pending.get(msg.id ?? '');
if (p !== undefined) {
this.pending.delete(msg.id ?? '');
p.resolve(msg.data);
}
return;
}
case 'error': {
const p = this.pending.get(msg.id ?? '');
if (p !== undefined) {
this.pending.delete(msg.id ?? '');
p.reject(new RpcWsError(msg.code ?? 0, msg.msg ?? 'error'));
}
return;
}
case 'event': {
const listener = this.listeners.get(msg.id ?? '');
if (listener !== undefined) listener(msg.data);
return;
}
}
}
}

View file

@ -0,0 +1,337 @@
/**
* `/api/v2` WebSocket connection per-connection lifecycle.
*
* Multiplexes RPC `call`s and event `listen`s over one socket, carrying the
* safety features from v1's `WsConnection` and VSCode's `ChannelServer`:
* - request ids + active-request table (cancel / unlisten disposes them)
* - heartbeat (ping / pong timeout terminate)
* - schema validation (invalid frames are dropped, not fatal)
* - graceful cleanup on close (dispose listeners, cancel pending)
* - no stack traces over the wire
*/
import { timingSafeEqual } from 'node:crypto';
import { ErrorCodes, KimiError, type IDisposable, type Scope } from '@moonshot-ai/agent-core-v2';
import { ulid } from 'ulid';
import type { RawData, WebSocket } from 'ws';
import type { ScopeKind } from '../channel';
import { parseServiceAction } from '../channel';
import { dispatch, resolveScope } from '../dispatcher';
import { assertSerializable, mapError } from '../errors';
import { resolveEventSource } from './eventMap';
import type { CallMessage, ListenMessage, ServerMessage } from './wsProtocol';
import { clientMessageSchema } from './wsProtocol';
const DEFAULT_PING_INTERVAL_MS = 30_000;
const DEFAULT_PONG_TIMEOUT_MS = 10_000;
const DEFAULT_CALL_TIMEOUT_MS = 30_000;
interface PendingEntry {
/** Dispose the listener / drop the call result. */
readonly cancel: () => void;
}
export interface WsConnectionOptions {
readonly socket: WebSocket;
readonly core: Scope;
readonly token?: string;
readonly pingIntervalMs?: number;
readonly pongTimeoutMs?: number;
readonly callTimeoutMs?: number;
}
export class WsConnection {
readonly id: string;
private readonly socket: WebSocket;
private readonly core: Scope;
private readonly token?: string;
private readonly pingIntervalMs: number;
private readonly pongTimeoutMs: number;
private readonly callTimeoutMs: number;
private closed = false;
private gotHello = false;
private readonly pending = new Map<string, PendingEntry>();
private pingTimer?: ReturnType<typeof setInterval>;
private pongTimer?: ReturnType<typeof setTimeout>;
constructor(opts: WsConnectionOptions) {
this.id = `conn_${ulid()}`;
this.socket = opts.socket;
this.core = opts.core;
this.token = opts.token;
this.pingIntervalMs = opts.pingIntervalMs ?? DEFAULT_PING_INTERVAL_MS;
this.pongTimeoutMs = opts.pongTimeoutMs ?? DEFAULT_PONG_TIMEOUT_MS;
this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS;
this.socket.on('message', (data: RawData) => this.onMessage(data));
this.socket.on('close', () => this.onClose());
this.socket.on('error', () => this.onClose());
this.startHeartbeat();
this.send({ type: 'ready', heartbeatMs: this.pingIntervalMs });
}
// -------------------------------------------------------------------------
// Inbound
// -------------------------------------------------------------------------
private onMessage(data: RawData): void {
if (this.closed) return;
let parsed: unknown;
try {
parsed = JSON.parse(rawDataToString(data));
} catch {
return; // non-JSON frame — drop
}
const result = clientMessageSchema.safeParse(parsed);
if (!result.success) return; // invalid frame — drop, don't tear down
const msg = result.data;
switch (msg.type) {
case 'hello':
this.onHello(msg.token);
return;
case 'pong':
this.onPong();
return;
case 'call':
void this.onCall(msg);
return;
case 'cancel':
this.cancel(msg.id);
return;
case 'listen':
this.onListen(msg);
return;
case 'unlisten':
this.cancel(msg.id);
return;
}
}
private onHello(token: string | undefined): void {
if (this.token !== undefined) {
const ok =
token !== undefined &&
Buffer.from(token).length === Buffer.from(this.token).length &&
timingSafeEqual(Buffer.from(token), Buffer.from(this.token));
if (!ok) {
this.send({ type: 'error', id: '', code: 40112, msg: 'unauthorized' });
this.close();
return;
}
}
this.gotHello = true;
}
private async onCall(msg: CallMessage): Promise<void> {
if (!this.gotHello) {
this.send({ type: 'error', id: msg.id, code: 40112, msg: 'hello required' });
return;
}
const parsed = parseServiceAction(msg.sa);
if (parsed === undefined) {
this.send({
type: 'error',
id: msg.id,
code: 40001,
msg: `expected <resource>:<action>, got '${msg.sa}'`,
});
return;
}
// Track so `cancel` can drop the result.
let settled = false;
const entry: PendingEntry = {
cancel: () => {
settled = true;
this.pending.delete(msg.id);
},
};
this.pending.set(msg.id, entry);
try {
const data = await withTimeoutWs(
dispatch(this.core, msg.scope as ScopeKind, scopeParams(msg), parsed, msg.arg),
this.callTimeoutMs,
);
if (settled || this.closed) return;
this.pending.delete(msg.id);
this.send({ type: 'result', id: msg.id, data });
} catch (error) {
if (settled || this.closed) return;
this.pending.delete(msg.id);
const env = mapError(error, msg.id);
this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg });
}
}
private onListen(msg: ListenMessage): void {
if (!this.gotHello) {
this.send({ type: 'error', id: msg.id, code: 40112, msg: 'hello required' });
return;
}
if (this.pending.has(msg.id)) {
this.send({ type: 'error', id: msg.id, code: 40001, msg: `id already in use: ${msg.id}` });
return;
}
const source = resolveEventSource(msg.scope as ScopeKind, msg.event);
if (source === undefined) {
this.send({ type: 'error', id: msg.id, code: 40001, msg: `unknown event: ${msg.event}` });
return;
}
let scope;
try {
scope = resolveScope(this.core, msg.scope as ScopeKind, scopeParams(msg));
} catch {
scope = undefined;
}
if (scope === undefined) {
this.send({
type: 'error',
id: msg.id,
code: 40401,
msg: `session ${msg.sessionId ?? ''} not found`,
});
return;
}
let disposable: IDisposable;
try {
disposable = source.subscribe(scope, (data) => this.sendEvent(msg.id, data));
} catch (error) {
const env = mapError(error, msg.id);
this.send({ type: 'error', id: msg.id, code: env.code, msg: env.msg });
return;
}
this.pending.set(msg.id, {
cancel: () => {
disposable.dispose();
this.pending.delete(msg.id);
},
});
}
private cancel(id: string): void {
const entry = this.pending.get(id);
if (entry !== undefined) entry.cancel();
}
// -------------------------------------------------------------------------
// Outbound
// -------------------------------------------------------------------------
private sendEvent(id: string, data: unknown): void {
if (this.closed) return;
try {
const wire = assertSerializable(data);
this.send({ type: 'event', id, data: wire });
} catch {
// Non-serializable event payload — drop, don't tear down the connection.
}
}
private send(msg: ServerMessage): void {
if (this.closed || this.socket.readyState !== this.socket.OPEN) return;
try {
this.socket.send(JSON.stringify(msg));
} catch {
// best-effort
}
}
// -------------------------------------------------------------------------
// Heartbeat
// -------------------------------------------------------------------------
private startHeartbeat(): void {
this.pingTimer = setInterval(() => {
if (this.closed) return;
this.send({ type: 'ping' });
if (this.pongTimer !== undefined) clearTimeout(this.pongTimer);
this.pongTimer = setTimeout(() => {
if (this.closed) return;
try {
this.socket.terminate();
} catch {
// ignore
}
}, this.pongTimeoutMs);
this.pongTimer.unref?.();
}, this.pingIntervalMs);
this.pingTimer.unref?.();
}
private onPong(): void {
if (this.pongTimer !== undefined) {
clearTimeout(this.pongTimer);
this.pongTimer = undefined;
}
}
// -------------------------------------------------------------------------
// Close
// -------------------------------------------------------------------------
close(): void {
if (this.closed) return;
try {
this.socket.close(1000);
} catch {
// ignore
}
}
private onClose(): void {
if (this.closed) return;
this.closed = true;
if (this.pingTimer !== undefined) clearInterval(this.pingTimer);
if (this.pongTimer !== undefined) clearTimeout(this.pongTimer);
for (const entry of this.pending.values()) {
try {
entry.cancel();
} catch {
// ignore cleanup errors
}
}
this.pending.clear();
}
}
function scopeParams(msg: { sessionId?: string; agentId?: string }): Record<string, string> {
const params: Record<string, string> = {};
if (msg.sessionId !== undefined) params['session_id'] = msg.sessionId;
if (msg.agentId !== undefined) params['agent_id'] = msg.agentId;
return params;
}
function rawDataToString(data: RawData): string {
if (typeof data === 'string') return data;
if (Buffer.isBuffer(data)) return data.toString('utf8');
if (Array.isArray(data)) return Buffer.concat(data).toString('utf8');
return Buffer.from(data as ArrayBuffer).toString('utf8');
}
async function withTimeoutWs<T>(promise: Promise<T>, ms: number): Promise<T> {
if (ms <= 0) return promise;
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_resolve, reject) => {
timer = setTimeout(
() => reject(new KimiError(ErrorCodes.INTERNAL, `call timed out after ${ms}ms`)),
ms,
);
timer.unref?.();
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
}) as Promise<T>;
}

View file

@ -0,0 +1,101 @@
/**
* `/api/v2` WebSocket protocol message shapes and validators.
*
* A single WS connection multiplexes RPC `call`s and event `listen`s over a
* JSON message protocol. Every request carries a client-chosen `id`; the server
* correlates responses / events by that id. This is the lean counterpart of
* VSCode's framed `IMessagePassingProtocol`, carrying the same safety features
* (request ids, cancellation, heartbeats, schema validation, cleanup).
*/
import { z } from 'zod';
const scopeKindSchema = z.enum(['core', 'session', 'agent']);
const helloMessageSchema = z.object({
type: z.literal('hello'),
token: z.string().optional(),
});
const callMessageSchema = z.object({
type: z.literal('call'),
id: z.string().min(1),
scope: scopeKindSchema,
sessionId: z.string().min(1).optional(),
agentId: z.string().min(1).optional(),
sa: z.string().min(1),
arg: z.unknown().optional(),
});
const cancelMessageSchema = z.object({
type: z.literal('cancel'),
id: z.string().min(1),
});
const listenMessageSchema = z.object({
type: z.literal('listen'),
id: z.string().min(1),
scope: scopeKindSchema,
sessionId: z.string().min(1).optional(),
agentId: z.string().min(1).optional(),
event: z.string().min(1),
});
const unlistenMessageSchema = z.object({
type: z.literal('unlisten'),
id: z.string().min(1),
});
const pongMessageSchema = z.object({
type: z.literal('pong'),
});
export const clientMessageSchema = z.discriminatedUnion('type', [
helloMessageSchema,
callMessageSchema,
cancelMessageSchema,
listenMessageSchema,
unlistenMessageSchema,
pongMessageSchema,
]);
export type ClientMessage = z.infer<typeof clientMessageSchema>;
export type CallMessage = z.infer<typeof callMessageSchema>;
export type ListenMessage = z.infer<typeof listenMessageSchema>;
// Server → client messages (built directly; not validated).
export interface ReadyMessage {
readonly type: 'ready';
readonly heartbeatMs: number;
}
export interface ResultMessage {
readonly type: 'result';
readonly id: string;
readonly data: unknown;
}
export interface ErrorMessage {
readonly type: 'error';
readonly id: string;
readonly code: number;
readonly msg: string;
}
export interface EventMessage {
readonly type: 'event';
readonly id: string;
readonly data: unknown;
}
export interface PingMessage {
readonly type: 'ping';
}
export type ServerMessage =
| ReadyMessage
| ResultMessage
| ErrorMessage
| EventMessage
| PingMessage;

View file

@ -0,0 +1,285 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ISessionIndex, ISessionMetadata } from '@moonshot-ai/agent-core-v2';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
import { RpcClient, RpcError } from '../src/transport/rpcClient';
interface Envelope<T> {
code: number;
msg: string;
data: T;
request_id: string;
details?: { path: string; message: string }[];
}
interface SessionMetaWire {
id: string;
title?: string;
createdAt: number;
updatedAt: number;
archived: boolean;
}
describe('server-v2 /api/v2 RPC', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-'));
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function call<T>(
method: 'GET' | 'POST',
path: string,
arg?: unknown,
token?: string,
): Promise<{ status: number; body: Envelope<T> }> {
const headers: Record<string, string> = {};
let url = `${base}${path}`;
const init: { method: string; headers: Record<string, string>; body?: string } = {
method,
headers,
};
if (method === 'GET') {
if (arg !== undefined) url += `?arg=${encodeURIComponent(JSON.stringify(arg))}`;
} else if (arg !== undefined) {
headers['content-type'] = 'application/json';
init.body = JSON.stringify(arg);
}
if (token !== undefined) headers['authorization'] = `Bearer ${token}`;
const res = await fetch(url, init);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function createSession(cwd: string): Promise<string> {
const res = await fetch(`${base}/api/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd } }),
});
const body = (await res.json()) as Envelope<{ id: string }>;
expect(body.code).toBe(0);
return body.data.id;
}
// --- Core scope -----------------------------------------------------------
it('lists sessions via GET (readonly)', async () => {
const { body } = await call<{ items: unknown[]; has_more: boolean }>(
'GET',
'/api/v2/sessions:list',
{},
);
expect(body.code).toBe(0);
expect(Array.isArray(body.data.items)).toBe(true);
});
it('creates a workspace and reads it back', async () => {
const cwd = home as string;
const created = await call<{ id: string; root: string }>(
'POST',
'/api/v2/workspaces:createOrTouch',
cwd,
);
expect(created.body.code).toBe(0);
expect(created.body.data.root).toBe(cwd);
const got = await call<{ id: string; root: string }>(
'GET',
'/api/v2/workspaces:get',
created.body.data.id,
);
expect(got.body.code).toBe(0);
expect(got.body.data.root).toBe(cwd);
});
it('counts active sessions', async () => {
const cwd = home as string;
const created = await call<{ id: string }>('POST', '/api/v2/workspaces:createOrTouch', cwd);
await createSession(cwd);
const { body } = await call<number>(
'POST',
'/api/v2/sessions:countActive',
created.body.data.id,
);
expect(body.code).toBe(0);
expect(body.data).toBeGreaterThanOrEqual(1);
});
// --- Session scope --------------------------------------------------------
it('reads and updates session metadata', async () => {
const id = await createSession(home as string);
const read = await call<SessionMetaWire>('POST', `/api/v2/session/${id}/session:read`);
expect(read.body.code).toBe(0);
expect(read.body.data.id).toBe(id);
const set = await call<null>('POST', `/api/v2/session/${id}/session:setTitle`, 'renamed');
expect(set.body.code).toBe(0);
const read2 = await call<SessionMetaWire>('POST', `/api/v2/session/${id}/session:read`);
expect(read2.body.data.title).toBe('renamed');
});
it('returns session status', async () => {
const id = await createSession(home as string);
const { body } = await call<string>('POST', `/api/v2/session/${id}/session:status`);
expect(body.code).toBe(0);
expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(body.data);
});
it('archives a session', async () => {
const id = await createSession(home as string);
const { body } = await call<null>('POST', `/api/v2/session/${id}/session:archive`);
expect(body.code).toBe(0);
});
// --- typed client ---------------------------------------------------------
it('works through the typed RpcClient', async () => {
const cwd = home as string;
await createSession(cwd);
const client = new RpcClient({ url: base });
const sessions = client.core<ISessionIndex>('sessions');
const page = await sessions.list({});
expect(page.items.length).toBeGreaterThanOrEqual(1);
const id = page.items[0]!.id;
const meta = client.session(id).service<ISessionMetadata>('session');
const read = await meta.read();
expect(read.id).toBe(id);
});
it('throws RpcError on unknown action', async () => {
const client = new RpcClient({ url: base });
const sessions = client.core<ISessionIndex>('sessions');
// @ts-expect-error — intentionally calling a non-existent method
await expect(sessions.nope()).rejects.toBeInstanceOf(RpcError);
});
// --- NFR ------------------------------------------------------------------
it('rejects unknown action (40001)', async () => {
const { body } = await call<null>('POST', '/api/v2/sessions:nope');
expect(body.code).toBe(40001);
});
it('rejects malformed segment without colon (40001)', async () => {
const { body } = await call<null>('POST', '/api/v2/sessions');
expect(body.code).toBe(40001);
});
it('rejects unknown session (40401)', async () => {
const { body } = await call<null>('POST', '/api/v2/session/nope/session:read');
expect(body.code).toBe(40401);
});
it('rejects GET on a write action (40001)', async () => {
const id = await createSession(home as string);
const { body } = await call<null>('GET', `/api/v2/session/${id}/session:archive`);
expect(body.code).toBe(40001);
});
it('rejects oversized body', async () => {
// Fastify's default bodyLimit is 1MB; a larger body is rejected. Fastify's
// body-parser throws a 413 which our global error handler currently wraps
// as `50001` (HTTP 200) — either way the request is rejected, not served.
const huge = 'x'.repeat(2 * 1024 * 1024);
let rejected = false;
let code: number | undefined;
try {
const res = await fetch(`${base}/api/v2/sessions:list`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ big: huge }),
});
const body = (await res.json()) as Envelope<null>;
rejected = res.status === 413 || body.code !== 0;
code = body.code;
} catch {
rejected = true;
}
expect(rejected).toBe(true);
expect(code).not.toBe(0);
});
it('does not leak stack traces on error', async () => {
const { body } = await call<null>('POST', '/api/v2/session/nope/session:read');
expect(JSON.stringify(body)).not.toContain('stack');
});
});
describe('server-v2 /api/v2 RPC auth', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
const token = 'test-secret-token';
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-rpc-auth-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
rpcToken: token,
});
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
it('rejects calls without a token (40112)', async () => {
const res = await fetch(`${base}/api/v2/sessions:list`, { method: 'POST' });
const body = (await res.json()) as Envelope<null>;
expect(body.code).toBe(40112);
});
it('accepts calls with the correct token', async () => {
const res = await fetch(`${base}/api/v2/sessions:list`, {
method: 'POST',
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
body: JSON.stringify({}),
});
const body = (await res.json()) as Envelope<{ items: unknown[] }>;
expect(body.code).toBe(0);
});
it('rejects a wrong token (40112)', async () => {
const res = await fetch(`${base}/api/v2/sessions:list`, {
method: 'POST',
headers: { authorization: 'Bearer wrong' },
});
const body = (await res.json()) as Envelope<null>;
expect(body.code).toBe(40112);
});
});

View file

@ -0,0 +1,216 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
interface Envelope<T> {
code: number;
msg: string;
data: T;
request_id: string;
details?: { path: string; message: string }[];
}
interface SessionWire {
id: string;
workspace_id: string;
title: string;
created_at: string;
updated_at: string;
status: string;
archived?: boolean;
metadata: { cwd: string } & Record<string, unknown>;
agent_config: { model: string };
usage: { input_tokens: number };
permission_rules: unknown[];
message_count: number;
last_seq: number;
}
interface PageWire {
items: SessionWire[];
has_more: boolean;
}
describe('server-v2 /api/v1/sessions', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-sessions-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
});
base = `http://127.0.0.1:${server.port}`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function postJson<T>(
path: string,
body?: unknown,
): Promise<{ status: number; body: Envelope<T> }> {
const hasBody = body !== undefined;
const res = await fetch(`${base}${path}`, {
method: 'POST',
headers: hasBody ? { 'content-type': 'application/json' } : undefined,
body: hasBody ? JSON.stringify(body) : undefined,
});
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
async function getJson<T>(path: string): Promise<{ status: number; body: Envelope<T> }> {
const res = await fetch(`${base}${path}`);
return { status: res.status, body: (await res.json()) as Envelope<T> };
}
it('creates a session from metadata.cwd', async () => {
const cwd = home as string;
const { status, body } = await postJson<SessionWire>('/api/v1/sessions', {
title: 'hello',
metadata: { cwd },
});
expect(status).toBe(200);
expect(body.code).toBe(0);
expect(typeof body.data.id).toBe('string');
expect(typeof body.data.workspace_id).toBe('string');
expect(body.data.title).toBe('hello');
expect(body.data.metadata.cwd).toBe(cwd);
expect(body.data.status).toBe('idle');
expect(body.data.agent_config).toEqual({ model: '' });
expect(body.data.permission_rules).toEqual([]);
expect(body.data.message_count).toBe(0);
expect(body.data.last_seq).toBe(0);
expect(Number.isNaN(Date.parse(body.data.created_at))).toBe(false);
});
it('rejects create without cwd or workspace_id (40001)', async () => {
const { body } = await postJson<null>('/api/v1/sessions', { title: 'no cwd' });
expect(body.code).toBe(40001);
expect(body.details?.[0]?.path).toBe('metadata.cwd');
});
it('rejects create with unknown workspace_id (40410)', async () => {
const { body } = await postJson<null>('/api/v1/sessions', {
workspace_id: 'wd_missing_000000000000',
metadata: { cwd: '/x' },
});
expect(body.code).toBe(40410);
});
it('creates a second session via workspace_id resolved from a prior cwd create', async () => {
const cwd = home as string;
const first = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
expect(first.body.code).toBe(0);
const second = await postJson<SessionWire>('/api/v1/sessions', {
workspace_id: first.body.data.workspace_id,
metadata: { cwd },
});
expect(second.body.code).toBe(0);
expect(second.body.data.workspace_id).toBe(first.body.data.workspace_id);
expect(second.body.data.id).not.toBe(first.body.data.id);
});
it('rejects create when cwd mismatches workspace root (40001)', async () => {
const cwd = home as string;
const first = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await postJson<null>('/api/v1/sessions', {
workspace_id: first.body.data.workspace_id,
metadata: { cwd: '/definitely/elsewhere' },
});
expect(body.code).toBe(40001);
expect(body.details?.[0]?.path).toBe('metadata.cwd');
});
it('lists created sessions', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await getJson<PageWire>('/api/v1/sessions');
expect(body.code).toBe(0);
expect(body.data.items.some((s) => s.id === created.body.data.id)).toBe(true);
expect(typeof body.data.has_more).toBe('boolean');
});
it('gets a session by id and 404s for unknown', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const got = await getJson<SessionWire>(`/api/v1/sessions/${created.body.data.id}`);
expect(got.body.code).toBe(0);
expect(got.body.data.id).toBe(created.body.data.id);
const missing = await getJson<null>('/api/v1/sessions/nope');
expect(missing.body.code).toBe(40401);
});
it('updates the session title via profile', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
const updated = await postJson<SessionWire>(`/api/v1/sessions/${id}/profile`, {
title: 'renamed',
});
expect(updated.body.code).toBe(0);
expect(updated.body.data.title).toBe('renamed');
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
expect(got.body.data.title).toBe('renamed');
});
it('returns best-effort status for a live session', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await getJson<{
status: string;
thinking_level: string;
plan_mode: boolean;
context_tokens: number;
}>(`/api/v1/sessions/${created.body.data.id}/status`);
expect(body.code).toBe(0);
expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(
body.data.status,
);
expect(typeof body.data.thinking_level).toBe('string');
expect(typeof body.data.plan_mode).toBe('boolean');
expect(body.data.context_tokens).toBe(0);
});
it('archives a session via :archive and reflects archived flag on get', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const id = created.body.data.id;
const archived = await postJson<{ archived: boolean }>(`/api/v1/sessions/${id}:archive`);
expect(archived.body.code).toBe(0);
expect(archived.body.data).toEqual({ archived: true });
const got = await getJson<SessionWire>(`/api/v1/sessions/${id}`);
expect(got.body.code).toBe(0);
expect(got.body.data.archived).toBe(true);
});
it('rejects an unsupported action suffix (40001)', async () => {
const cwd = home as string;
const created = await postJson<SessionWire>('/api/v1/sessions', { metadata: { cwd } });
const { body } = await postJson<null>(`/api/v1/sessions/${created.body.data.id}:fork`);
expect(body.code).toBe(40001);
});
});

View file

@ -0,0 +1,168 @@
import { mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { type RunningServer, startServer } from '../src/start';
import { RpcWsError, WsClient } from '../src/transport/ws/wsClient';
interface Envelope<T> {
code: number;
data: T;
}
interface SessionMetaWire {
id: string;
title?: string;
archived: boolean;
}
describe('server-v2 /api/v2/ws', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let base: string;
let wsUrl: string;
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-'));
server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' });
base = `http://127.0.0.1:${server.port}`;
wsUrl = `ws://127.0.0.1:${server.port}/api/v2/ws`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
async function createSession(cwd: string): Promise<string> {
const res = await fetch(`${base}/api/v1/sessions`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ metadata: { cwd } }),
});
const body = (await res.json()) as Envelope<{ id: string }>;
expect(body.code).toBe(0);
return body.data.id;
}
it('performs a core call over WS', async () => {
const client = new WsClient({ url: wsUrl });
const page = await client.call<{ items: unknown[] }>('core', 'sessions:list', {});
expect(Array.isArray(page.items)).toBe(true);
client.close();
});
it('performs a session call over WS', async () => {
const id = await createSession(home as string);
const client = new WsClient({ url: wsUrl });
const meta = await client.call<SessionMetaWire>('session', 'session:read', undefined, {
sessionId: id,
});
expect(meta.id).toBe(id);
client.close();
});
it('returns an error for an unknown action', async () => {
const client = new WsClient({ url: wsUrl });
await expect(client.call('core', 'sessions:nope')).rejects.toMatchObject({
code: 40001,
});
client.close();
});
it('streams core events via listen', async () => {
const client = new WsClient({ url: wsUrl });
const { iterator, cancel } = client.listen<{ type: string; payload: unknown }>(
'core',
'events',
);
// Ensure the subscription is registered before publishing.
await new Promise((r) => setTimeout(r, 50));
const id = await createSession(home as string);
await fetch(`${base}/api/v1/sessions/${id}:archive`, { method: 'POST' });
const iter = iterator[Symbol.asyncIterator]();
const next = await Promise.race([
iter.next(),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('no event')), 2000)),
]);
expect(next.done).toBe(false);
expect(next.value).toMatchObject({
type: 'event.session.archived',
payload: { sessionId: id },
});
cancel();
client.close();
});
it('cancels a subscription', async () => {
const client = new WsClient({ url: wsUrl });
const { iterator, cancel } = client.listen('core', 'events');
await new Promise((r) => setTimeout(r, 50));
cancel();
const iter = iterator[Symbol.asyncIterator]();
const next = await iter.next();
expect(next.done).toBe(true);
client.close();
});
it('rejects pending calls on close', async () => {
const client = new WsClient({ url: wsUrl });
const pending = client.call('core', 'sessions:list', {});
client.close();
await expect(pending).rejects.toThrow();
});
});
describe('server-v2 /api/v2/ws auth', () => {
let server: RunningServer | undefined;
let home: string | undefined;
let wsUrl: string;
const token = 'ws-secret';
beforeEach(async () => {
home = await mkdtemp(join(tmpdir(), 'kimi-server-v2-ws-auth-'));
server = await startServer({
host: '127.0.0.1',
port: 0,
homeDir: home,
logLevel: 'silent',
rpcToken: token,
});
wsUrl = `ws://127.0.0.1:${server.port}/api/v2/ws`;
});
afterEach(async () => {
if (server !== undefined) {
await server.close();
server = undefined;
}
if (home !== undefined) {
await rm(home, { recursive: true, force: true });
home = undefined;
}
});
it('accepts a call with the correct token', async () => {
const client = new WsClient({ url: wsUrl, token });
const page = await client.call<{ items: unknown[] }>('core', 'sessions:list', {});
expect(Array.isArray(page.items)).toBe(true);
client.close();
});
it('rejects a wrong token', async () => {
const client = new WsClient({ url: wsUrl, token: 'wrong' });
await expect(client.call('core', 'sessions:list', {})).rejects.toThrow();
client.close();
});
});

8
pnpm-lock.yaml generated
View file

@ -678,10 +678,16 @@ importers:
ulid:
specifier: ^3.0.1
version: 3.0.2
ws:
specifier: ^8.20.0
version: 8.20.0
zod:
specifier: 'catalog:'
version: 4.3.6
devDependencies:
'@types/ws':
specifier: ^8.18.0
version: 8.18.1
tsx:
specifier: ^4.21.0
version: 4.21.0
@ -8369,7 +8375,7 @@ snapshots:
obug: 2.1.1
std-env: 4.0.0
tinyrainbow: 3.1.0
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@6.4.2(@types/node@22.19.17)(jiti@2.6.1)(lightningcss@1.32.0)(tsx@4.21.0)(yaml@2.8.3))
vitest: 4.1.4(@types/node@22.19.17)(@vitest/coverage-v8@4.1.4)(jsdom@25.0.1)(vite@8.0.8(@types/node@22.19.17)(esbuild@0.27.7)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))
'@vitest/expect@4.1.4':
dependencies: