diff --git a/.agents/skills/agent-core-dev/server-align.md b/.agents/skills/agent-core-dev/server-align.md index a489cf37e4..82b9188f1d 100644 --- a/.agents/skills/agent-core-dev/server-align.md +++ b/.agents/skills/agent-core-dev/server-align.md @@ -205,7 +205,7 @@ Where the route mirrors v1, the test is the regression guard for the schema-fide - `pnpm -C packages/kap-server test` — server routes green (incl. any wire-schema guards). - `pnpm -C packages/agent-core-v2 test` — native + Legacy Service tests green. - `pnpm -C packages/agent-core-v2 run lint:domain` — a LegacyService is still inside the domain layers (edge adapter, L7); it must not pull business code into the edge or invert scope direction. -- `pnpm -C packages/server-e2e ...` when a v1 parity scenario exists. +- `pnpm -C packages/klient test` (optionally with `KIMI_SERVER_URL` for the live legacy suites) when a v1 parity scenario exists. ## Worked example — porting v1 `/sessions/:sid/prompts` diff --git a/.changeset/README.md b/.changeset/README.md index d20552ded7..3afeb3bdfb 100644 --- a/.changeset/README.md +++ b/.changeset/README.md @@ -24,7 +24,6 @@ All other workspace packages are private internal packages, are not published to - `@moonshot-ai/kosong` - `@moonshot-ai/migration-legacy` - `@moonshot-ai/protocol` -- `@moonshot-ai/server-e2e` - `@moonshot-ai/vis` - `@moonshot-ai/vis-server` - `@moonshot-ai/vis-web` diff --git a/.changeset/config.json b/.changeset/config.json index 2320f45e31..0f9da8d75c 100644 --- a/.changeset/config.json +++ b/.changeset/config.json @@ -7,7 +7,6 @@ "baseBranch": "main", "updateInternalDependencies": "patch", "ignore": [ - "@moonshot-ai/server-e2e", "@moonshot-ai/vis", "@moonshot-ai/vis-server", "@moonshot-ai/vis-web" diff --git a/.changeset/kap-server-v2-model-channels.md b/.changeset/kap-server-v2-model-channels.md new file mode 100644 index 0000000000..4e5645077c --- /dev/null +++ b/.changeset/kap-server-v2-model-channels.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/kap-server": minor +"@moonshot-ai/kimi-code": patch +--- + +Expose the model registry and model catalog services on the server's v2 RPC surface so remote clients can manage model configuration and read the provider/model catalog. diff --git a/.changeset/klient-global-facade-catalog.md b/.changeset/klient-global-facade-catalog.md new file mode 100644 index 0000000000..2783469e26 --- /dev/null +++ b/.changeset/klient-global-facade-catalog.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/klient": minor +--- + +Add `models`, `catalog`, and `hostFs` sections to the global facade for model configuration, the provider/model catalog, and host folder browsing, plus a `models.changed` event, `flags.enabledIds()`, and caching of the `env()` snapshot. `auth.refreshProviderModels()` is deprecated in favor of `catalog.refresh({ scope: 'oauth' })`. diff --git a/AGENTS.md b/AGENTS.md index 6891b73fd1..df1b117c71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ This is a TypeScript monorepo built for agent-assisted development. Keep the roo - `packages/oauth`: Kimi OAuth and managed auth utilities. - `packages/telemetry`: shared client-side telemetry infrastructure. - `packages/kap-server`: the Kimi Code server, backed by the DI × Scope agent engine (`@moonshot-ai/agent-core-v2`). Exposes sessions over REST + WebSocket (`/api/v1` and the native `/api/v2` RPC surface); bootstrapped from `src/start.ts` and consumed by `apps/kimi-code`. -- `packages/server-e2e`: live e2e tests and scenarios against a running server (`KIMI_SERVER_URL`, default `http://127.0.0.1:58627`). See `packages/server-e2e/AGENTS.md`. +- `packages/klient`: the client SDK — a contract-driven facade over agent-core-v2 with aggregated `global.*` / `session(id).*` / `agent(id).*` methods, zod validation on every call, and klient-level typed event forwarding. Transport is chosen once at creation via subpath entry (`@moonshot-ai/klient/http|ipc|memory`); all three return the same `Klient`. The package also hosts the e2e suites: dual-backend session/agent suites (`test/e2e/dual/`, in-memory + in-process server), `/api/v2` wire tests (`test/e2e/v2/`), the legacy `/api/v1` live suites (`test/e2e/legacy/`), and the docker e2e runner (`pnpm --filter @moonshot-ai/klient docker:e2e`). See `packages/klient/AGENTS.md`. ## Environment Requirements diff --git a/flake.nix b/flake.nix index 82edde273d..cb8c7a3692 100644 --- a/flake.nix +++ b/flake.nix @@ -66,7 +66,6 @@ ./packages/agent-core ./packages/agent-core-v2 ./packages/kap-server - ./packages/server-e2e ./packages/kaos ./packages/klient ./packages/kosong @@ -91,7 +90,6 @@ "@moonshot-ai/agent-core" "@moonshot-ai/agent-core-v2" "@moonshot-ai/kap-server" - "@moonshot-ai/server-e2e" "@moonshot-ai/kaos" "@moonshot-ai/kosong" "@moonshot-ai/migration-legacy" diff --git a/packages/kap-server/src/transport/channelRegistry.ts b/packages/kap-server/src/transport/channelRegistry.ts index 9c5f46531c..bd43439d25 100644 --- a/packages/kap-server/src/transport/channelRegistry.ts +++ b/packages/kap-server/src/transport/channelRegistry.ts @@ -36,6 +36,8 @@ import { IFaultInjectionService, IFlagService, IHostFolderBrowser, + IModelCatalogService, + IModelService, IOAuthService, IPluginService, IProviderService, @@ -204,6 +206,8 @@ const EXPOSED_SERVICES: readonly ServiceIdentifier[] = [ IWorkspaceRegistry, IConfigService, IProviderService, + IModelService, + IModelCatalogService, IOAuthService, IAuthSummaryService, IFlagService, diff --git a/packages/klient/AGENTS.md b/packages/klient/AGENTS.md new file mode 100644 index 0000000000..3dff543f44 --- /dev/null +++ b/packages/klient/AGENTS.md @@ -0,0 +1,83 @@ +# klient Agent Guide + +Package-local rules for `packages/klient`. + +## Architecture + +The package is layered; keep the layers strict when changing code: + +- **Facade** (`src/core/facade/`, `src/core/klient.ts`) — the only public API: + aggregated `global.*` / `session(id).*` / `session(id).agent(id).*` methods + and their `events.*` hubs. No engine service tokens, no `onDid*`/`onWill*` + names, and **no escape hatch to raw services** — do not reintroduce a + service locator (`core()`/`service()`/`makeProxy`). +- **Contract** (`src/contract/`) — zod input/output schemas for every wire + method plus event payload schemas. Schemas are hand-mirrored from + agent-core-v2 types and pinned by the compile-time parity assertions in + `test/contract-parity.ts`; when the engine types change, tsc fails here + first. `maybe()`/`noResult()` in `src/contract/helpers.ts` encode the HTTP + wire's `null`-vs-`undefined` semantics — use them for every + `X | undefined` / `void` result. +- **Transports** (`src/transports/{http,ipc,memory}`) — each implements the + `KlientChannel` SPI (`src/core/channel.ts`) and nothing else. http carries + events over a lazily opened WS; ipc reuses the WS frame shapes over a unix + socket and shares the in-process dispatcher with memory; memory JSON + round-trips every value so all three transports return byte-identical data. + +The facade only covers services kap-server exposes over `/api/v2` **and** that +behave identically on all three transports (the in-process dispatcher mirrors +the server's scope resolution, including `main`-agent materialization via +`ensureMainAgent`). onWill/hook-style interception is not wire-exposable +(engine hooks are in-process `OrderedHookSlot`s); file upload and the +terminal surface are v1-only and live in the legacy suites. + +## Testing + +- One shared conformance suite (`test/helpers/conformance.ts`) runs unchanged + against every transport — one test file per transport under `test/`; the + http leg boots an in-process kap-server. Add new **global** facade coverage + there, not per-transport. +- Session/agent coverage lives in `test/e2e/dual/` (`test/helpers/dual.ts`): + every suite runs the exact same body against an in-memory engine AND an + in-process kap-server. Model-requiring suites declare + `{ requiresModel: true }` and skip unless `KIMI_E2E_MODEL` + + `KIMI_E2E_API_KEY` (optional `KIMI_E2E_BASE_URL` / `KIMI_E2E_PROTOCOL`) + are set; the model is seeded into each backend's temp home via + `klient.global.models.set` and agents bind it with + `agent.setModel(DUAL_MODEL_ID)`. +- `test/e2e/v2/` — `/api/v2` wire tests booting kap-server in-process. +- `test/e2e/legacy/` + `test/e2e/harness/` — the legacy `/api/v1` live + suites (moved from server-e2e). They skip unless `KIMI_SERVER_URL` points + at a running server and **must keep running unchanged**; the v1 surface + has no in-memory equivalent, so these stay http-only — do not try to + dual-run them. +- The retired `scenarios/` scripts were rewritten as suites: prompt / + approval / workspace / catalog / children / pending flows live in + `test/e2e/dual/`; image-upload and terminal (v1-only surfaces) live in + `test/e2e/legacy/`; refresh-replay was dropped as redundant with the + legacy test of the same name. + +## Observability (inherited from server-e2e) + +- Keep observability inside each e2e case; every live case prints structured, + case-scoped details (requests, envelopes, WS handshakes, terminal frames, + error envelopes) through the shared logger in `test/e2e/legacy/log.ts`, + not ad hoc `console.log`. +- Logs must stay visible for passing Vitest cases — write through stdout. +- When adding or changing an e2e case, update its observability at the same + time; do not add a scenario solely to print data an existing case should + already expose. + +## Command reference + +- `pnpm --filter @moonshot-ai/klient test` — all Vitest suites (unit + + conformance + e2e; live and model cases skip without their env). +- `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/klient test` + — include the live legacy/v2 cases against a running server. +- `KIMI_E2E_MODEL=... KIMI_E2E_API_KEY=... [KIMI_E2E_BASE_URL=...] pnpm --filter @moonshot-ai/klient exec vitest run test/e2e/dual` + — run the model-requiring dual suites against both backends. +- `pnpm --filter @moonshot-ai/klient docker:e2e` — docker e2e; the run + derives its runner name/namespace from the current workspace to avoid + cross-workspace conflicts. +- `pnpm --filter @moonshot-ai/klient typecheck` / `pnpm smoke` (real-server + smoke; see `examples/smoke.ts`). diff --git a/packages/server-e2e/Dockerfile b/packages/klient/Dockerfile similarity index 91% rename from packages/server-e2e/Dockerfile rename to packages/klient/Dockerfile index 36d24375d3..98473034c4 100644 --- a/packages/server-e2e/Dockerfile +++ b/packages/klient/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 # -# server-e2e docker-run image. +# klient e2e docker-run image. # # This image layers server-e2e defaults on top of the repository server dev # image. Source code and node_modules are still provided by bind mounts from @@ -22,6 +22,6 @@ ENV KIMI_CODE_HOME=/data/docker-e2e/kimi-code-home \ npm_config_store_dir=/workspace/kimi-code/node_modules/.pnpm-store \ npm_config_package_import_method=copy -WORKDIR /workspace/kimi-code/packages/server-e2e +WORKDIR /workspace/kimi-code/packages/klient CMD ["bash"] diff --git a/packages/klient/README.md b/packages/klient/README.md index afc60452a5..620b2d7654 100644 --- a/packages/klient/README.md +++ b/packages/klient/README.md @@ -1,89 +1,112 @@ # @moonshot-ai/klient -Client SDK that reuses `agent-core-v2` service interfaces and fulfills them over -the `/api/v2` HTTP channel. It follows the VS Code model: a channel is bound to -**one Service** (the URL carries the scope + the Service's decorator id) and -method calls are forwarded **verbatim** to the server's reflection dispatcher — -no per-method allowlist, no `resource:action`, no renaming. The shared interface -is the whole contract. +Contract-driven client SDK for the agent-core-v2 engine. One facade, three +transports — you pick the transport **once** at creation; everything after +that is byte-identical: ```ts -import { Klient, SessionIndexClient, HttpChannel } from '@moonshot-ai/klient'; -import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; - -const client = new Klient({ url: 'http://127.0.0.1:58627' }); - -// Generic typed proxy: the v2 service token carries both the type and the -// channel name (`String(ISessionIndex)` === 'sessionIndex'). -const sessions = await client.core(ISessionIndex).list({}); -const meta = await client.session('s1').service(ISessionMetadata).read(); - -// Explicit, fully-typed implementation of a single interface. The channel is -// bound to the Service's scope URL. -const index: ISessionIndex = new SessionIndexClient( - new HttpChannel({ baseUrl: 'http://127.0.0.1:58627/api/v2/sessionIndex' }), -); -const page = await index.list({ workspaceId: 'w1' }); -``` +import { createKlient } from '@moonshot-ai/klient/http'; // or '/ipc', '/memory' -Service interfaces and tokens are imported directly from `agent-core-v2` leaf -subpaths; the channel and proxy live in this package. +const klient = createKlient({ url: 'http://127.0.0.1:58627', token }); -## WebSocket transport (calls + events) +const env = await klient.global.env(); +const sessions = await klient.global.sessions.list({ limit: 20 }); -`Klient#ws()` returns a lazily-created `WsKlient` over the persistent -`/api/v2/ws` socket: the same scope entries and typed proxies (one socket -multiplexes every `call`), plus `listen(event, handler)` on each scope for the -server's event streams — core `events`, session `interactions` / -`interactions:resolved`, agent `events`: +const session = await klient.global.sessions.create({ workDir: process.cwd() }); +const agent = klient.session(session.id).agent('main'); +agent.events.on('assistant.delta', (e) => process.stdout.write(e.delta)); +agent.events.on('prompt.completed', () => console.log('\ndone')); +await agent.prompt({ input: [{ type: 'text', text: 'Say OK.' }] }); -```ts -const ws = client.ws(); -const sub = ws.session('s1').agent('main').listen('events', (event) => { - console.log('agent event', event); -}); -const pending = await ws.session('s1').service(ISessionApprovalService).listPending(); -sub.dispose(); -ws.close(); +await klient.close(); ``` -The socket answers heartbeats, applies per-call timeouts, and reconnects -automatically after an unexpected close (active `listen`s are re-subscribed; -in-flight calls reject). The bearer token rides the -`kimi-code.bearer.` subprotocol, so the transport works unchanged in -browsers. - -## Real-server smoke checks +## Architecture -Run the transport smoke against a real server (the model phase is opt-in). It -creates and archives a fixture session, and therefore touches the selected -workspace's persisted metadata: - -```sh -KIMI_SERVER_URL=http://127.0.0.1:58627 \ -KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \ -pnpm smoke - -KIMI_SMOKE_MODEL=YOUR_MODEL pnpm smoke +``` +facade (klient.global.*, klient.session(id).*, session.agent(id).*, *.events.*) + ↓ single-object params, zod-validated +contract (procedure schemas, shared by all transports) + ↓ +KlientChannel { call, listen } ← the only transport SPI + ↓ +http │ ipc │ memory ``` -The history smoke checks persisted sessions before warming one, including the -cold-session regression where an indexed session is unavailable through the v2 -session scope. It sends no explicit mutation request. When `KIMI_SMOKE_MARKER` -is set, the v1 message read resumes the session and may persist server-side -legacy metadata migrations: +- **Facade** — aggregated methods, no engine service tokens, no + `onDid*`/`onWill*` event names. There is no escape hatch to raw services: + the facade is the public contract. + - `klient.global.*` — `sessions.*` (incl. `create`), `workspaces.*`, + `config.*`, `providers.*`, `models.*`, `catalog.*`, `auth.*`, `flags.*`, + `plugins.*`, `hostFs.*`, `env()`. + - `klient.session(id).*` — `get/setTitle/update/status/close/archive/ + restore/fork/createChild`, `approvals.*`, `questions.*`, + `interactions.*`, `agents()`. + - `session.agent(id).*` — `prompt/steer/cancel/runShellCommand/ + cancelShellCommand/getModel/setModel/setPermission/getUsage/getContext/ + getPlan*/getTasks*/stopTask/getTaskOutput`. +- **Contract** — every method has a zod input tuple + output schema, validated + on the client before send / after receive (default on; `validate: false` to + disable). Validation is sub-µs for typical payloads — cheaper than the JSON + serialization the wire already pays. +- **Events** — `klient.events.on(...)` for the global bus + (`config.changed`, `models.changed`, `session.archived`, …), + `session(id).events.on('metadata.changed' | 'interactions.changed' | + 'interactions.resolved')`, and `agent(id).events.on('turn.started' | + 'assistant.delta' | 'tool.call.started' | 'prompt.completed' | …)`. + Underlying subscriptions are shared and ref-counted; payloads are + validated; bad payloads drop to `events.onError`. + +## Transports + +| entry | options | events | +|---|---|---| +| `@moonshot-ai/klient/http` | `{ url, token?, fetch?, WebSocketImpl? }` | lazily opened WS, transparent | +| `@moonshot-ai/klient/ipc` | `{ socketPath, token? }` | same socket | +| `@moonshot-ai/klient/memory` | `{ scope }` (a bootstrapped engine app scope) | direct emitter/bus subscription | + +`ipc` and `memory` share one in-process dispatcher, so they behave identically +by construction; `memory` additionally JSON round-trips every value so results +match the networked transports byte-for-byte. The IPC host ships with the +transport: `serveKlientIpc({ scope, socketPath })`. + +The same conformance suite runs against all three transports in this +package's tests (`test/helpers/conformance.ts` — one test file per transport; +the http leg boots an in-process kap-server). + +This package also hosts the e2e suites (the retired `server-e2e` package was +folded in here): + +- `test/e2e/dual/` — session/agent suites that run the **exact same body** + against an in-memory engine and an in-process kap-server + (`test/helpers/dual.ts`). Model-requiring suites skip unless + `KIMI_E2E_MODEL` + `KIMI_E2E_API_KEY` (optional `KIMI_E2E_BASE_URL`, + `KIMI_E2E_PROTOCOL`) are set; the model is seeded into each backend's temp + home through the facade itself. +- `test/e2e/v2/` — `/api/v2` wire tests booting kap-server in-process. +- `test/e2e/legacy/` + `test/e2e/harness/` — the legacy `/api/v1` live suites + and their client harness (skip unless `KIMI_SERVER_URL` is set; the v1 + surface has no in-memory equivalent, so these stay http-only). + +The docker e2e runner (`pnpm docker:e2e`) runs this whole vitest suite inside +a container against a container-local server. See `AGENTS.md` for the testing +rules. + +## Scope + +The facade covers the global (app), session, and agent surfaces shown above. +What it deliberately leaves out (for now): onWill/hook-style interception +(engine hooks are in-process `OrderedHookSlot`s and not wire-exposable), file +upload (v1 multipart REST only), and the terminal surface (v1 REST + WS +only). + +## Real-server smoke check ```sh KIMI_SERVER_URL=http://127.0.0.1:58627 \ KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \ -KIMI_SMOKE_EXPECT_SESSION_ID=YOUR_SESSION_ID \ -KIMI_SMOKE_MARKER=YOUR_MARKER \ -KIMI_SMOKE_REQUIRE_HISTORY=1 \ -pnpm smoke:history +pnpm -C packages/klient smoke ``` -`KIMI_SMOKE_EXPECT_CWD` can select a session by working directory instead of -`KIMI_SMOKE_EXPECT_SESSION_ID`. The transport smoke creates its fixture in the -first registered workspace; set `KIMI_SMOKE_CWD` when a different server-local -folder is required. Omit `KIMI_SERVER_TOKEN` only for a server started with -authentication bypassed. +Omit `KIMI_SERVER_TOKEN` only for a server started with authentication +bypassed. `examples/basic.ts` is a shorter narrated tour. diff --git a/packages/klient/examples/basic.ts b/packages/klient/examples/basic.ts index bacb45c491..8919c1e0e0 100644 --- a/packages/klient/examples/basic.ts +++ b/packages/klient/examples/basic.ts @@ -1,63 +1,56 @@ /** - * Minimal end-to-end example driving a running `kap-server` over `/api/v2` with - * klient — core + session scopes, both the generic typed proxy and the - * explicit `SessionIndexClient`, plus the error path. + * Minimal end-to-end example driving a running `kap-server` with klient's + * `global` facade over the HTTP transport (events ride a lazily opened WS — + * same facade either way). * * Run against a local dev server (auth bypassed for dev): * pnpm dev:server --dangerous-bypass-auth * pnpm -C packages/klient exec tsx examples/basic.ts */ -import { HttpChannel, Klient, SessionIndexClient } from '@moonshot-ai/klient'; -import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import { createKlient } from '@moonshot-ai/klient/http'; const BASE = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -async function createSessionViaV1(cwd: string): Promise { - const res = await fetch(`${BASE}/api/v1/sessions`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ metadata: { cwd } }), - }); - const env = (await res.json()) as { code: number; data: { id: string } }; - if (env.code !== 0) throw new Error(`create session failed: ${JSON.stringify(env)}`); - return env.data.id; -} - async function main(): Promise { - const client = new Klient({ url: BASE }); - - // 1) Generic typed proxy — core scope. The token carries type + channel name. - const page = await client.core(ISessionIndex).list({}); - console.log('[core] sessionIndex.list ->', page.items.length, 'sessions'); - - // 2) Explicit impl — same interface, hand-written class on a bound channel. - const index: ISessionIndex = new SessionIndexClient( - new HttpChannel({ baseUrl: `${BASE}/api/v2/sessionIndex` }), - ); - const page2 = await index.list({}); - console.log('[core] SessionIndexClient.list ->', page2.items.length, 'sessions (explicit)'); - - // 3) Session scope — create a session via /api/v1, then read metadata via v2. - const id = await createSessionViaV1('/tmp/klient-example'); - console.log('[v1] created session ->', id); - const meta = await client.session(id).service(ISessionMetadata).read(); - console.log('[session] sessionMetadata.read ->', { - id: meta.id, - cwd: meta.cwd, - archived: meta.archived, + const klient = createKlient({ url: BASE }); + + // 1) Aggregated host snapshot. + const env = await klient.global.env(); + console.log('[env] platform/homeDir ->', env.platform, env.homeDir); + + // 2) Read models. + const sessions = await klient.global.sessions.list({}); + console.log('[sessions] list ->', sessions.items.length, 'sessions'); + const workspaces = await klient.global.workspaces.list(); + console.log('[workspaces] list ->', workspaces.length, 'workspaces'); + const providers = await klient.global.providers.list(); + console.log('[providers] list ->', Object.keys(providers).length, 'providers'); + + // 3) Events — klient-level forwarding (no onDid*/onWill* in sight). + const sub = klient.events.on('providers.changed', (event) => { + console.log('[event] providers.changed -> +%s -%s ~%s', event.added, event.removed, event.changed); }); + await klient.global.providers.set({ + name: '__klient_example__', + config: { apiKey: 'example-key' }, + }); + await klient.global.providers.delete('__klient_example__'); + sub.dispose(); - // 4) Error path — unknown method -> RPCError(40001). + // 4) Error path — a missing plugin surfaces as RPCError with the server's code. try { - await (client.core(ISessionIndex) as unknown as { nope(): Promise }).nope(); - } catch (err) { - const e = err as { name: string; code: number }; - console.log('[error] unknown method ->', e.name, e.code); + await klient.global.plugins.info('__definitely_missing__'); + } catch (error) { + const e = error as { name: string; code?: number }; + console.log('[error] plugins.info ->', e.name, e.code); } + + await klient.close(); } -main().catch((err) => { - console.error(err); +try { + await main(); +} catch (error) { + console.error(error); process.exit(1); -}); +} diff --git a/packages/klient/examples/init.ts b/packages/klient/examples/init.ts deleted file mode 100644 index d85669593f..0000000000 --- a/packages/klient/examples/init.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Example: drive the `/init` command end-to-end against a running server-v2 - * (kap-server) over the `/api/v2` channel, using `@moonshot-ai/klient`. - * - * Flow: - * 1. read base url / token / default model from the local Kimi home - * 2. create a session (v1 REST — klient is v2-only) pointed at a temp cwd - * 3. bind the default model on the (auto-created) main agent - * 4. call `ISessionInitService.generateAgentsMd()` and print the outcome - * - * No secrets are hard-coded: the token is read from `/server.token` - * (or `KIMI_SERVER_TOKEN`), the model from `default_model` in `/config.toml` - * (or `KIMI_INIT_MODEL`), and the base url from `KIMI_SERVER_URL`. - * - * Run: `pnpm exec tsx examples/init.ts` (cwd = this package). - */ - -import { mkdtemp, readFile, writeFile } from 'node:fs/promises'; -import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { Klient, RPCError } from '@moonshot-ai/klient'; -import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; -import { ISessionInitService } from '@moonshot-ai/agent-core-v2/session/sessionInit/sessionInit'; - -interface Envelope { - readonly code: number; - readonly msg: string; - readonly data: T; -} - -const home = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); -const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, ''); - -async function readToken(): Promise { - const fromEnv = process.env['KIMI_SERVER_TOKEN']; - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const token = (await readFile(join(home, 'server.token'), 'utf8')).trim(); - if (token.length === 0) throw new Error(`empty token at ${join(home, 'server.token')}`); - return token; -} - -async function readDefaultModel(): Promise { - const fromEnv = process.env['KIMI_INIT_MODEL']; - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const config = await readFile(join(home, 'config.toml'), 'utf8'); - const match = /^default_model\s*=\s*"([^"]+)"/m.exec(config); - if (match === null) throw new Error('default_model not found in config.toml; set KIMI_INIT_MODEL'); - return match[1]!; -} - -async function postV1(token: string, path: string, body: unknown): Promise { - const res = await fetch(`${baseUrl}${path}`, { - method: 'POST', - headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, - body: JSON.stringify(body), - }); - const envelope = (await res.json()) as Envelope; - if (envelope.code !== 0) { - throw new Error(`v1 ${path} failed: code=${envelope.code} msg=${envelope.msg}`); - } - return envelope.data; -} - -async function main(): Promise { - const token = await readToken(); - const model = await readDefaultModel(); - const cwd = await mkdtemp(join(tmpdir(), 'klient-init-')); - await writeFile( - join(cwd, 'package.json'), - JSON.stringify({ name: 'init-demo', scripts: { test: 'echo ok', build: 'echo ok' } }, null, 2), - ); - await writeFile(join(cwd, 'README.md'), '# init demo\n\nTiny fixture for the /init example.\n'); - - console.log(`baseUrl = ${baseUrl}`); - console.log(`model = ${model}`); - console.log(`cwd = ${cwd}`); - - const client = new Klient({ url: baseUrl, token }); - - console.log('\n[1/3] creating session (v1) ...'); - const created = await postV1<{ readonly id: string }>(token, '/api/v1/sessions', { - metadata: { cwd }, - }); - const sid = created.id; - console.log(` session id = ${sid}`); - - console.log('[2/3] binding model on main agent (setModel) ...'); - const profile = client.session(sid).agent('main').service(IAgentProfileService); - const setModelResult = await profile.setModel(model); - console.log(' setModel ->', setModelResult); - - console.log('[3/3] calling ISessionInitService.generateAgentsMd() ...'); - const init = client.session(sid).service(ISessionInitService); - await init.generateAgentsMd(); - console.log(' /init completed (no error)'); -} - -main().catch((error: unknown) => { - if (error instanceof RPCError) { - console.error(`\nFAILED (rpc): code=${error.code} msg=${error.message}`); - if (error.details !== undefined) console.error('details:', error.details); - } else { - console.error('\nFAILED:', error instanceof Error ? error.message : error); - } - process.exitCode = 1; -}); diff --git a/packages/klient/examples/inspect-init.ts b/packages/klient/examples/inspect-init.ts deleted file mode 100644 index fef9da06f8..0000000000 --- a/packages/klient/examples/inspect-init.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Diagnostic: inspect a session after a `/init` attempt — session agents, and - * for each agent the permission mode / active turn / context tail. Uses only - * Services exposed on the wire. Usage: `pnpm exec tsx examples/inspect-init.ts ` - */ - -import { readFile } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; - -import { Klient } from '@moonshot-ai/klient'; -import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; -import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; - -const home = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); -const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, ''); -const sid = process.argv[2]; -if (sid === undefined) { - console.error('usage: inspect-init.ts '); - process.exit(1); -} - -const token = (await readFile(join(home, 'server.token'), 'utf8')).trim(); -const client = new Klient({ url: baseUrl, token }); - -const meta = await client.session(sid).service(ISessionMetadata).read(); -const agentIds = Object.keys(meta.agents ?? {}); -console.log('agents in metadata:', agentIds); - -for (const agentId of ['main', ...agentIds.filter((id) => id !== 'main')]) { - const agent = client.session(sid).agent(agentId); - try { - // Klient maps property reads to zero-argument RPC calls at runtime. - const mode = await ( - agent.service(IAgentPermissionModeService) as unknown as { mode(): Promise } - ).mode(); - // `get()` is sync in the shared interface but async over the wire. - const context = await Promise.resolve(agent.service(IAgentContextMemoryService).get()); - console.log(`\n== agent ${agentId} == mode=${mode} messages=${context.length}`); - for (const last of context.slice(-2)) { - const text = last.content - .map((part) => (part.type === 'text' ? part.text : `[${part.type}]`)) - .join(' ') - .slice(0, 300); - const calls = (last.toolCalls ?? []).map((c) => c.name).join(','); - console.log( - ` - role=${last.role} origin=${JSON.stringify(last.origin ?? null)}${calls.length > 0 ? ` toolCalls=[${calls}]` : ''} :: ${text}`, - ); - } - } catch (error) { - console.log(`\n== agent ${agentId} == not reachable (${error instanceof Error ? error.message : String(error)})`); - } -} diff --git a/packages/klient/examples/media-recovery.ts b/packages/klient/examples/media-recovery.ts deleted file mode 100644 index aa9578fa17..0000000000 --- a/packages/klient/examples/media-recovery.ts +++ /dev/null @@ -1,457 +0,0 @@ -/** - * Example: end-to-end cases for the image format policy and the requester's - * media recovery projections, driven against a REAL running kap-server over - * `/api/v1` + `/api/v2` with `@moonshot-ai/klient` — no mocks. - * - * What is covered: - * A. REST ingestion gate — AVIF (declared AND sniffed through a lying - * label) and a remote `.avif` URL become text notices; a valid PNG - * passes through; a large WebP is re-encoded (not passed through). - * B. Core backstop — an AVIF data-URL injected straight through the v2 - * prompt service (bypassing the REST gate) lands in the context - * history as a text notice, never as an image part. - * C. HTTP 413 recovery — with the `fault-injection` experimental flag, a - * one-shot `request-too-large` fault is armed; the turn still completes - * against the real provider via the media-degraded resend. - * D. Image-format recovery — same, with the `image-format` fault and the - * media-stripped resend. - * - * Prerequisites: - * - a kap-server started with the fault-injection flag and a real model: - * KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION=1 kimi server run --foreground - * (the master KIMI_CODE_EXPERIMENTAL_FLAG=1 also enables it) - * - the token is read from `/server.token` (or `KIMI_SERVER_TOKEN`); - * omit both when the server runs with auth bypassed - * - the model comes from `KIMI_EXAMPLE_MODEL`, else `default_model` in - * `/config.toml`; cases C/D run one tiny real turn each - * - * Run: `pnpm exec tsx examples/media-recovery.ts` (cwd = this package). - */ - -import { mkdtemp, readFile } from 'node:fs/promises'; -import { createRequire } from 'node:module'; -import { homedir, tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { deflateSync } from 'node:zlib'; - -import { Klient, RPCError } from '@moonshot-ai/klient'; -import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; -import { IFaultInjectionService } from '@moonshot-ai/agent-core-v2/agent/faultInjection/faultInjection'; -import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; -import { IAgentPromptService } from '@moonshot-ai/agent-core-v2/agent/prompt/prompt'; - -interface Envelope { - readonly code: number; - readonly msg: string; - readonly data: T; -} - -interface PromptItemWire { - readonly prompt_id: string; - readonly status: string; - readonly content: unknown; -} - -type WirePart = - | { type: 'text'; text: string } - | { type: 'image'; source: { kind: 'base64'; media_type: string; data: string } | { kind: 'url'; url: string } }; - -const home = process.env['KIMI_CODE_HOME'] ?? join(homedir(), '.kimi-code'); -const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, ''); - -const failures: string[] = []; -function check(name: string, ok: boolean, detail?: string): void { - console.log(` ${ok ? 'PASS' : 'FAIL'} ${name}${ok || detail === undefined ? '' : ` — ${detail}`}`); - if (!ok) failures.push(name); -} - -async function readToken(): Promise { - const fromEnv = process.env['KIMI_SERVER_TOKEN']; - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - try { - const token = (await readFile(join(home, 'server.token'), 'utf8')).trim(); - return token.length > 0 ? token : undefined; - } catch { - return undefined; // auth-bypassed dev server - } -} - -async function readDefaultModel(): Promise { - const fromEnv = process.env['KIMI_EXAMPLE_MODEL']; - if (fromEnv !== undefined && fromEnv.length > 0) return fromEnv; - const config = await readFile(join(home, 'config.toml'), 'utf8'); - const match = /^default_model\s*=\s*"([^"]+)"/m.exec(config); - if (match === null) throw new Error('default_model not found in config.toml; set KIMI_EXAMPLE_MODEL'); - return match[1]!; -} - -function authHeaders(token: string | undefined, extra: Record = {}): Record { - return token === undefined ? extra : { ...extra, authorization: `Bearer ${token}` }; -} - -async function postV1(token: string | undefined, path: string, body: unknown): Promise { - const res = await fetch(`${baseUrl}${path}`, { - method: 'POST', - headers: authHeaders(token, { 'content-type': 'application/json' }), - body: JSON.stringify(body), - }); - const envelope = (await res.json()) as Envelope; - if (envelope.code !== 0) { - throw new Error(`v1 ${path} failed: code=${envelope.code} msg=${envelope.msg}`); - } - return envelope.data; -} - -// ── fixtures ───────────────────────────────────────────────────────── - -const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); - -function crc32(bytes: Buffer): number { - let crc = 0xffffffff; - for (const byte of bytes) { - crc = CRC32_TABLE[(crc ^ byte) & 0xff]! ^ (crc >>> 8); - } - return (crc ^ 0xffffffff) >>> 0; -} -const CRC32_TABLE = ((): Uint32Array => { - const table = new Uint32Array(256); - for (let i = 0; i < 256; i++) { - let c = i; - for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; - table[i] = c >>> 0; - } - return table; -})(); - -function pngChunk(type: string, data: Buffer): Buffer { - const typeBytes = Buffer.from(type, 'ascii'); - const length = Buffer.alloc(4); - length.writeUInt32BE(data.length, 0); - const crc = Buffer.alloc(4); - crc.writeUInt32BE(crc32(Buffer.concat([typeBytes, data])), 0); - return Buffer.concat([length, typeBytes, data, crc]); -} - -function solidPng(width: number, height: number, rgb: [number, number, number]): Buffer { - const ihdr = Buffer.alloc(13); - ihdr.writeUInt32BE(width, 0); - ihdr.writeUInt32BE(height, 4); - ihdr[8] = 8; - ihdr[9] = 2; // RGB - const row = Buffer.alloc(1 + width * 3); - for (let x = 0; x < width; x++) { - row[1 + x * 3] = rgb[0]; - row[2 + x * 3] = rgb[1]; - row[3 + x * 3] = rgb[2]; - } - const raw = Buffer.alloc(row.length * height); - for (let y = 0; y < height; y++) row.copy(raw, y * row.length); - return Buffer.concat([ - PNG_SIGNATURE, - pngChunk('IHDR', ihdr), - pngChunk('IDAT', deflateSync(raw)), - pngChunk('IEND', Buffer.alloc(0)), - ]); -} - -/** Minimal ftyp box sniffed as image/avif (bytes authoritative, no pixels). */ -function avifFtyp(): Buffer { - const buf = Buffer.alloc(24); - buf.writeUInt32BE(24, 0); - buf.write('ftyp', 4, 'latin1'); - buf.write('avif', 8, 'latin1'); - buf.write('avif', 16, 'latin1'); - return buf; -} - -/** Encode a solid 2100x1050 WebP (over the 2000px edge cap) via the wasm - * encoder bundled with agent-core-v2's @jsquash/webp dependency. */ -async function bigWebpBase64(): Promise { - const requireLocal = createRequire(import.meta.url); - const requireFromV2 = createRequire(requireLocal.resolve('@moonshot-ai/agent-core-v2/package.json')); - const { Jimp } = (await import(requireFromV2.resolve('jimp'))) as never as { - Jimp: new (opts: { width: number; height: number; color: number }) => { - bitmap: { data: Buffer; width: number; height: number }; - }; - }; - const encMod = (await import( - requireFromV2.resolve('@jsquash/webp/encode.js') - )) as { init(wasm: object): Promise; default(data: unknown, opts: unknown): Promise }; - const wasmNamespace = ( - globalThis as unknown as { WebAssembly: { compile(bytes: Uint8Array): Promise } } - ).WebAssembly; - const wasm = await wasmNamespace.compile( - await readFile(requireFromV2.resolve('@jsquash/webp/codec/enc/webp_enc.wasm')), - ); - await encMod.init(wasm); - const { bitmap } = new Jimp({ width: 2100, height: 1050, color: 0x3366ccff }); - const encoded = await encMod.default( - { - data: new Uint8ClampedArray(bitmap.data.buffer, bitmap.data.byteOffset, bitmap.data.byteLength), - width: bitmap.width, - height: bitmap.height, - }, - { quality: 90 }, - ); - return Buffer.from(encoded).toString('base64'); -} - -// ── main ───────────────────────────────────────────────────────────── - -async function main(): Promise { - const token = await readToken(); - const model = await readDefaultModel(); - console.log(`baseUrl = ${baseUrl}`); - console.log(`model = ${model}`); - - const client = new Klient(token === undefined ? { url: baseUrl } : { url: baseUrl, token }); - - // Session A: REST ingestion gate (each submission also runs a background - // turn against the real model — harmless). - console.log('\n== A. REST ingestion gate =='); - const sessionA = (await postV1<{ id: string }>(token, '/api/v1/sessions', { - metadata: { cwd: await mkdtemp(join(tmpdir(), 'klient-media-a-')) }, - })).id; - // Bind the model so the background turns can run. - await client.session(sessionA).agent('main').service(IAgentProfileService).setModel(model); - - const submit = (content: unknown): Promise => - postV1(token, `/api/v1/sessions/${sessionA}/prompts`, { content }); - - // A1: inline AVIF, honestly declared. - const a1 = await submit([ - { type: 'image', source: { kind: 'base64', media_type: 'image/avif', data: avifFtyp().toString('base64') } }, - ]); - const a1parts = a1.content as WirePart[]; - check( - 'A1 inline AVIF becomes a text notice', - a1parts.length === 1 && a1parts[0]?.type === 'text' && a1parts[0].text.includes('image/avif'), - JSON.stringify(a1parts).slice(0, 160), - ); - - // A2: AVIF bytes labeled image/png — the sniff wins over the label. - const a2 = await submit([ - { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: avifFtyp().toString('base64') } }, - ]); - const a2parts = a2.content as WirePart[]; - check( - 'A2 mislabeled AVIF bytes gated by sniff', - a2parts.length === 1 && a2parts[0]?.type === 'text' && a2parts[0].text.includes('image/avif'), - JSON.stringify(a2parts).slice(0, 160), - ); - - // A3: remote .avif URL — notice keeps the URL. - const avifUrl = 'https://example.com/pic.avif'; - const a3 = await submit([{ type: 'image', source: { kind: 'url', url: avifUrl } }]); - const a3parts = a3.content as WirePart[]; - check( - 'A3 remote .avif URL becomes a notice keeping the URL', - a3parts.length === 1 && - a3parts[0]?.type === 'text' && - a3parts[0].text.includes('image/avif') && - a3parts[0].text.includes(avifUrl), - JSON.stringify(a3parts).slice(0, 160), - ); - - // A4: large WebP is re-encoded (caption + non-WebP image), not passed through. - const a4 = await submit([ - { type: 'image', source: { kind: 'base64', media_type: 'image/webp', data: await bigWebpBase64() } }, - ]); - const a4parts = a4.content as WirePart[]; - const a4image = a4parts.find((p) => p.type === 'image'); - const a4caption = a4parts.find((p) => p.type === 'text' && p.text.includes('Image compressed')); - check( - 'A4 large WebP re-encoded instead of passed through', - a4image !== undefined && - a4image.type === 'image' && - a4image.source.kind === 'base64' && - a4image.source.media_type !== 'image/webp' && - a4caption !== undefined, - JSON.stringify(a4parts.map((p) => (p.type === 'text' ? p.text.slice(0, 60) : p))).slice(0, 200), - ); - - // A5: small valid PNG passes through untouched. - const a5 = await submit([ - { type: 'image', source: { kind: 'base64', media_type: 'image/png', data: solidPng(32, 32, [0x33, 0x66, 0xcc]).toString('base64') } }, - ]); - const a5parts = a5.content as WirePart[]; - check( - 'A5 valid PNG passes through as an image part', - a5parts.length === 1 && - a5parts[0]?.type === 'image' && - a5parts[0].source.kind === 'base64' && - a5parts[0].source.media_type === 'image/png', - JSON.stringify(a5parts).slice(0, 120), - ); - - // Session B: core backstop + recovery cases. - const sessionB = (await postV1<{ id: string }>(token, '/api/v1/sessions', { - metadata: { cwd: await mkdtemp(join(tmpdir(), 'klient-media-b-')) }, - })).id; - const agentB = client.session(sessionB).agent('main'); - await agentB.service(IAgentProfileService).setModel(model); - const prompt = agentB.service(IAgentPromptService); - const context = agentB.service(IAgentContextMemoryService); - - console.log('\n== B. core backstop (v2 prompt funnel) =='); - const backstopId = 'klient-backstop-avif'; - await prompt.enqueue({ - id: backstopId, - message: { - role: 'user', - content: [ - { - type: 'image_url', - imageUrl: { url: `data:image/avif;base64,${avifFtyp().toString('base64')}` }, - }, - ], - toolCalls: [], - origin: { kind: 'user' }, - }, - }); - // Wait for the message to materialize into the context, then abort the turn. - let landed = false; - for (let i = 0; i < 50; i++) { - const messages = await Promise.resolve(context.get()); - const injected = messages.find((m) => m.role === 'user'); - if (injected !== undefined) { - const hasAvifImage = injected.content.some( - (p) => p.type === 'image_url' && p.imageUrl.url.includes('avif'), - ); - const hasNotice = injected.content.some( - (p) => p.type === 'text' && p.text.includes('image/avif'), - ); - check( - 'B AVIF prompt lands in history as a text notice, never an image', - !hasAvifImage && hasNotice, - JSON.stringify(injected.content).slice(0, 160), - ); - landed = true; - break; - } - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - } - if (!landed) check('B AVIF prompt lands in history', false, 'message never materialized'); - await Promise.resolve(prompt.abort(backstopId)); - - console.log('\n== C/D. recovery resends against the real provider =='); - const fault = agentB.service(IFaultInjectionService); - const profile = agentB.service(IAgentProfileService); - const caps = await Promise.resolve(profile.data()); - const canSeeImages = caps.modelCapabilities.image_in; - - // Seed media into the history when the model can see images, so the - // recovery projections are non-trivial (degraded keeps the recent two, - // stripped removes all). Skip with a note on text-only models. - if (canSeeImages) { - const seedId = 'klient-seed-media'; - await prompt.enqueue({ - id: seedId, - message: { - role: 'user', - content: [ - { type: 'text', text: 'three seed images' }, - ...([0x3366cc, 0xcc6633, 0x66cc33] as const).map((color) => ({ - type: 'image_url' as const, - imageUrl: { - url: `data:image/png;base64,${solidPng(32, 32, [(color >> 16) & 0xff, (color >> 8) & 0xff, color & 0xff]).toString('base64')}`, - }, - })), - ], - toolCalls: [], - origin: { kind: 'user' }, - }, - }); - for (let i = 0; i < 50; i++) { - const messages = await Promise.resolve(context.get()); - if (messages.some((m) => m.content.some((p) => p.type === 'image_url'))) break; - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - } - await Promise.resolve(prompt.abort(seedId)); - console.log(' seeded 3 PNG images into the history'); - } else { - console.log(' note: model reports image_in=false — recovery runs on a text-only history'); - } - - const ws = client.ws(); - const agentEvents = ws.session(sessionB).agent('main'); - function waitCompleted(promptId: string, timeoutMs = 120_000): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - sub.dispose(); - reject(new Error(`timeout waiting for prompt.completed ${promptId}`)); - }, timeoutMs); - const sub = agentEvents.listen('events', (event: unknown) => { - const e = event as { type?: string; promptId?: string; reason?: string }; - if (e.type === 'prompt.completed' && e.promptId === promptId) { - clearTimeout(timer); - sub.dispose(); - resolve(e.reason ?? 'unknown'); - } - }); - }); - } - - const runRecoverablePrompt = async ( - caseName: string, - kind: 'request-too-large' | 'image-format', - promptId: string, - ): Promise => { - await Promise.resolve(fault.clear()); - await Promise.resolve(fault.arm(kind)); - const armed = (await Promise.resolve(fault.status())).armed; - check(`${caseName} fault armed`, armed === kind); - const completed = waitCompleted(promptId); - await prompt.enqueue({ - id: promptId, - message: { - role: 'user', - content: [{ type: 'text', text: 'Reply with exactly: PONG' }], - toolCalls: [], - origin: { kind: 'user' }, - }, - }); - const reason = await completed; - const fired = (await Promise.resolve(fault.status())).fired; - check( - `${caseName} turn completes via recovery resend`, - reason === 'completed' && fired.length === 1 && fired[0] === kind, - `reason=${reason} fired=${JSON.stringify(fired)}`, - ); - }; - - console.log('\n== C. HTTP 413 → media-degraded resend =='); - await runRecoverablePrompt('C', 'request-too-large', 'klient-413'); - - console.log('\n== D. image-format 400 → media-stripped resend =='); - await runRecoverablePrompt('D', 'image-format', 'klient-imgfmt'); - - ws.close(); - - console.log(''); - if (failures.length > 0) { - console.log(`FAILED: ${String(failures.length)} case(s): ${failures.join(', ')}`); - process.exitCode = 1; - } else { - console.log('ALL CASES PASSED'); - } -} - -main().catch((error: unknown) => { - if (error instanceof RPCError) { - console.error(`\nFAILED (rpc): code=${String(error.code)} msg=${error.message}`); - if (error.code === 40001 || /disabled/i.test(error.message)) { - console.error( - 'hint: start the server with KIMI_CODE_EXPERIMENTAL_FAULT_INJECTION=1 ' + - '(or KIMI_CODE_EXPERIMENTAL_FLAG=1) for cases C/D', - ); - } - } else { - console.error('\nFAILED:', error instanceof Error ? error.message : error); - } - process.exitCode = 1; -}); diff --git a/packages/klient/examples/sea-e2e-auto-background.ts b/packages/klient/examples/sea-e2e-auto-background.ts deleted file mode 100644 index dbd17c4d27..0000000000 --- a/packages/klient/examples/sea-e2e-auto-background.ts +++ /dev/null @@ -1,230 +0,0 @@ -/** - * SEA e2e — auto-background on foreground Bash timeout (v2 engine, via klient). - * - * Drives the SEA-built kap-server (`kimi server run`, backend v2) over: - * - /api/v1 REST mirror: create session, submit prompt, read messages - * - /api/v2 typed service proxies (klient): IAgentTaskService assertions - * - * Flow: prompt the agent to run `sleep 5` with a 1s foreground Bash timeout. - * The 1s deadline must move the command to the background (not kill it); the - * task keeps running and settles as completed once `sleep 5` exits. - * - * Run: - * KIMI_CODE_EXPERIMENTAL_FLAG=1 KIMI_CODE_EXPERIMENTAL_MULTI_SERVER=1 \ - * apps/kimi-code/dist-native/bin/darwin-arm64/kimi server run --foreground \ - * --port 58628 --dangerous-bypass-auth - * pnpm -C packages/klient exec tsx examples/sea-e2e-auto-background.ts - */ - -import assert from 'node:assert/strict'; - -import { Klient } from '@moonshot-ai/klient'; -import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; - -const BASE = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58628'; -const client = new Klient({ url: BASE }); - -const SLEEP_SECONDS = 5; -const TIMEOUT_SECONDS = 1; - -interface Envelope { - code: number; - msg: string; - data: T; -} - -async function v1(path: string, init?: RequestInit): Promise { - const headers = new Headers(init?.headers); - headers.set('content-type', 'application/json'); - const res = await fetch(`${BASE}/api/v1${path}`, { - ...init, - headers, - }); - const env = (await res.json()) as Envelope; - if (env.code !== 0) throw new Error(`${path} failed: ${JSON.stringify(env)}`); - return env.data; -} - -async function createSession(cwd: string): Promise { - const data = await v1<{ id: string }>('/sessions', { - method: 'POST', - body: JSON.stringify({ metadata: { cwd } }), - }); - return data.id; -} - -interface PromptHandleView { - prompt_id: string; - status: string; -} - -const MODEL = process.env['KIMI_E2E_MODEL'] ?? 'free-tokens_kimi/coding-model-okapi-0711-vibe'; - -async function submitPrompt(sessionId: string, text: string): Promise { - const data = await v1(`/sessions/${sessionId}/prompts`, { - method: 'POST', - body: JSON.stringify({ - content: [{ type: 'text', text }], - permission_mode: 'yolo', - model: MODEL, - }), - }); - console.log(' submit response:', JSON.stringify(data).slice(0, 200)); - return data as PromptHandleView; -} - -async function pollPrompts(sessionId: string): Promise { - const data = await v1<{ - active?: PromptHandleView | null; - queued?: readonly PromptHandleView[]; - }>(`/sessions/${sessionId}/prompts`); - return [...(data.active ? [data.active] : []), ...(data.queued ?? [])]; -} - -async function sessionStatus(sessionId: string): Promise { - const data = await v1<{ status: string }>(`/sessions/${sessionId}`); - return data.status; -} - -interface MessageItem { - role: string; - content: readonly { type: string; text?: string; output?: unknown }[]; -} - -async function listMessages(sessionId: string): Promise { - const data = await v1<{ items: readonly MessageItem[] }>( - `/sessions/${sessionId}/messages?page_size=100`, - ); - return data.items; -} - -async function waitFor( - label: string, - fn: () => Promise, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - for (;;) { - const value = await fn(); - if (value !== undefined) { - console.log(`▶ ${label}`); - return value; - } - if (Date.now() > deadline) throw new Error(`timed out waiting for: ${label}`); - await new Promise((resolve) => setTimeout(resolve, 500)); - } -} - -async function main(): Promise { - const taskService = (sessionId: string) => - client.session(sessionId).agent('main').service(IAgentTaskService); - - const sessionId = await createSession('/tmp/sea-e2e-auto-background'); - console.log(`▶ session ${sessionId} created (backend: v2, via SEA binary)`); - - try { - const prompt = await submitPrompt( - sessionId, - [ - 'Use the Bash tool exactly once with these arguments:', - `- command: sleep ${String(SLEEP_SECONDS)}`, - `- timeout: ${String(TIMEOUT_SECONDS)}`, - 'Do NOT set run_in_background. After the tool returns, quote its message field verbatim and stop.', - ].join('\n'), - ); - console.log(`▶ prompt ${prompt.prompt_id} submitted (status=${prompt.status})`); - - // 1) The 1s foreground deadline must auto-background the task, not kill it. - const tasks = taskService(sessionId); - const detached = await waitFor( - 'task auto-backgrounded (detached, still running)', - async () => { - const list = tasks.list(false); - return list.find( - (t) => t.kind === 'process' && t.detached === true && t.status === 'running', - ); - }, - 30_000, - ); - console.log(' task:', JSON.stringify({ - taskId: detached.taskId, - status: detached.status, - detached: detached.detached, - description: detached.description, - })); - assert.equal(detached.detached, true, 'task must be detached to the background'); - assert.equal(detached.status, 'running', 'task must still be running after the timeout'); - - // 2) The tool result returned immediately with a running background task - // (the side-channel "timed out and moved to background" message is - // TUI-only; the wire carries the metadata + next_step). - const toolText = await waitFor( - 'tool result returned a running background task', - async () => { - const messages = await listMessages(sessionId); - const text = messages - .filter((m) => m.role === 'tool') - .flatMap((m) => m.content) - .map((part) => - typeof part.output === 'string' ? part.output : (part.text ?? ''), - ) - .join('\n'); - return text.includes('task_id: bash-') && - text.includes('status: running') && - text.includes('The task now runs in the background') - ? text - : undefined; - }, - 30_000, - ); - assert.match(toolText, /task_id: bash-/); - - // 3) The backgrounded command keeps running and settles as completed. - const terminal = await waitFor( - 'backgrounded task completed after sleep exited', - async () => { - const info = tasks.getTask(detached.taskId); - return info?.status === 'completed' ? info : undefined; - }, - 30_000, - ); - assert.equal(terminal.status, 'completed'); - assert.notEqual(terminal.status, 'timed_out'); - - // 4) The agent turn itself finishes (no blocked turn). - await waitFor( - 'session back to idle', - async () => { - const status = await sessionStatus(sessionId); - const prompts = await pollPrompts(sessionId); - return status === 'idle' && prompts.length === 0 ? status : undefined; - }, - 60_000, - ); - - console.log('✓ auto-background on foreground Bash timeout works end-to-end on v2 (SEA)'); - } catch (err) { - // Diagnostics: dump whatever the session produced before giving up. - try { - const messages = await listMessages(sessionId); - console.error(' messages at failure:', JSON.stringify(messages, null, 2).slice(0, 3000)); - const prompts = await pollPrompts(sessionId); - console.error(' prompts at failure:', JSON.stringify(prompts)); - } catch { - // ignore diagnostics failure - } - throw err; - } finally { - try { - await v1(`/sessions/${sessionId}::archive`, { method: 'POST', body: '{}' }); - console.log(`▶ session ${sessionId} archived`); - } catch { - // best effort cleanup - } - } -} - -main().catch((err) => { - console.error('✗ e2e failed:', err); - process.exit(1); -}); diff --git a/packages/klient/examples/session-history-smoke.ts b/packages/klient/examples/session-history-smoke.ts deleted file mode 100644 index 211c451ebf..0000000000 --- a/packages/klient/examples/session-history-smoke.ts +++ /dev/null @@ -1,307 +0,0 @@ -import assert from 'node:assert/strict'; - -import { Klient } from '@moonshot-ai/klient'; -import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; -import { - ISessionIndex, - type SessionSummary, -} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { - IWorkspaceRegistry, - type Workspace, -} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; - -interface Envelope { - readonly code: number; - readonly msg: string; - readonly data: T; -} - -interface V1Workspace { - readonly id: string; - readonly root: string; - readonly session_count: number; -} - -interface V1Session { - readonly id: string; - readonly workspace_id: string; - readonly metadata?: { readonly cwd?: string }; - readonly archived?: boolean; -} - -interface V1Message { - readonly id: string; - readonly role: string; - readonly content: readonly unknown[]; -} - -function optionalEnv(name: string): string | undefined { - const value = process.env[name]; - return value === undefined || value === '' ? undefined : value; -} - -const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, ''); -const token = optionalEnv('KIMI_SERVER_TOKEN'); -const expectedSessionId = optionalEnv('KIMI_SMOKE_EXPECT_SESSION_ID'); -const expectedCwd = optionalEnv('KIMI_SMOKE_EXPECT_CWD'); -const marker = optionalEnv('KIMI_SMOKE_MARKER'); -const requireHistory = /^(1|true|yes)$/i.test(process.env['KIMI_SMOKE_REQUIRE_HISTORY'] ?? 'false'); - -function authHeaders(): Headers { - const result = new Headers(); - if (token !== undefined) result.set('authorization', `Bearer ${token}`); - return result; -} - -async function v1(path: string): Promise { - const response = await fetch(`${baseUrl}/api/v1${path}`, { headers: authHeaders() }); - const envelope = (await response.json()) as Envelope; - assert.equal(response.ok, true, `v1 ${path} returned HTTP ${String(response.status)}`); - assert.equal(envelope.code, 0, `v1 ${path}: ${String(envelope.code)} ${envelope.msg}`); - return envelope.data; -} - -function sortedStrings(values: Iterable): readonly string[] { - return [...values].toSorted((a, b) => a.localeCompare(b)); -} - -function canonicalPath(value: string): string { - const slashed = value.replaceAll('\\', '/').replace(/\/+$/, ''); - const rooted = /^[A-Za-z]:$/.test(slashed) ? `${slashed}/` : slashed; - return /^(?:[A-Za-z]:\/|\/\/)/.test(rooted) ? rooted.toLowerCase() : rooted; -} - -function duplicates(values: readonly string[]): readonly string[] { - const counts = new Map(); - for (const value of values) counts.set(value, (counts.get(value) ?? 0) + 1); - return sortedStrings([...counts].filter(([, count]) => count > 1).map(([value]) => value)); -} - -function diagnoseAliases(values: readonly string[]): readonly string[] { - const groups = new Map>(); - for (const value of values) { - const canonical = canonicalPath(value); - const group = groups.get(canonical) ?? new Set(); - group.add(value); - groups.set(canonical, group); - } - return sortedStrings( - [...groups.values()] - .filter((group) => group.size > 1) - .map((group) => sortedStrings(group).join(' <> ')), - ); -} - -function text(value: unknown): string { - if (typeof value === 'string') return value; - if (Array.isArray(value)) return value.map(text).join('\n'); - if (value !== null && typeof value === 'object') { - const record = value as Record; - if (typeof record['text'] === 'string') return record['text']; - return Object.values(record).map(text).join('\n'); - } - return ''; -} - -async function listAllV1Sessions(): Promise { - const items: V1Session[] = []; - let beforeId: string | undefined; - for (;;) { - const query = new URLSearchParams({ include_archive: 'true', page_size: '100' }); - if (beforeId !== undefined) query.set('before_id', beforeId); - const page = await v1<{ readonly items: readonly V1Session[]; readonly has_more: boolean }>( - `/sessions?${query.toString()}`, - ); - items.push(...page.items); - if (!page.has_more) return items; - const next = page.items.at(-1)?.id; - assert.ok(next !== undefined && next !== beforeId, 'v1 session pagination did not advance'); - beforeId = next; - } -} - -async function listAllV1Messages(sessionId: string): Promise { - const items: V1Message[] = []; - let beforeId: string | undefined; - for (;;) { - const query = new URLSearchParams({ page_size: '100' }); - if (beforeId !== undefined) query.set('before_id', beforeId); - const page = await v1<{ readonly items: readonly V1Message[]; readonly has_more: boolean }>( - `/sessions/${encodeURIComponent(sessionId)}/messages?${query.toString()}`, - ); - items.push(...page.items); - if (!page.has_more) return items; - const next = page.items.at(-1)?.id; - assert.ok(next !== undefined && next !== beforeId, 'v1 message pagination did not advance'); - beforeId = next; - } -} - -function selectColdSession(sessions: readonly SessionSummary[]): SessionSummary | undefined { - if (expectedSessionId !== undefined) { - const selected = sessions.find((session) => session.id === expectedSessionId); - assert.ok(selected, `expected session ${expectedSessionId} is absent from the global index`); - return selected; - } - if (expectedCwd !== undefined) { - const canonicalExpected = canonicalPath(expectedCwd); - const matches = sessions.filter( - (session) => session.cwd !== undefined && canonicalPath(session.cwd) === canonicalExpected, - ); - assert.equal(matches.length, 1, `expected cwd must identify exactly one session; found ${String(matches.length)}`); - return matches[0]; - } - return sessions[0]; -} - -function report(label: string, values: readonly string[]): void { - console.log(`${label}: ${values.length === 0 ? 'none' : values.join(', ')}`); -} - -async function main(): Promise { - console.log(`server: ${baseUrl}`); - const client = new Klient({ url: baseUrl, token }); - const index = client.core(ISessionIndex); - - // This must be the first session operation: capture the durable global index - // before any session-scoped call can materialize a cold session. - const globalPage = await index.list({ includeArchived: true }); - const sessions = globalPage.items; - assert.ok(Array.isArray(sessions), 'global ISessionIndex.list did not return items'); - if (requireHistory || expectedSessionId !== undefined || expectedCwd !== undefined || marker !== undefined) { - assert.ok(sessions.length > 0, 'historical sessions are required but the global index is empty'); - } - console.log(`global index before warm-up: ${String(sessions.length)} sessions`); - const failures: string[] = []; - - // Regression probe: this intentionally fails while the v2 dispatcher only - // looks up live scopes instead of resuming an indexed cold session. - const cold = selectColdSession(sessions); - if (cold !== undefined) { - try { - const metadata = await client.session(cold.id).service(ISessionMetadata).read(); - assert.equal(metadata.id, cold.id, 'cold ISessionMetadata.read returned the wrong session'); - assert.equal(metadata.cwd, cold.cwd, 'cold metadata cwd differs from the index'); - assert.equal(metadata.archived, cold.archived, 'cold metadata archived flag differs from the index'); - console.log(`PASS cold ISessionMetadata.read (${cold.id})`); - } catch (error) { - const message = `cold session ${cold.id} is globally indexed but unavailable through session scope: ${error instanceof Error ? error.message : String(error)}`; - failures.push(message); - console.error(`FAIL ${message}`); - } - } else { - console.log('SKIP cold metadata read (no history found)'); - } - - const registry = client.core(IWorkspaceRegistry); - const workspaces = await registry.list(); - const workspaceById = new Map(workspaces.map((workspace) => [workspace.id, workspace])); - const workspaceIds = new Set([ - ...workspaces.map((workspace) => workspace.id), - ...sessions.map((session) => session.workspaceId), - ]); - - for (const workspaceId of workspaceIds) { - const filtered = await index.list({ workspaceId, includeArchived: true }); - const workspaceSessions: readonly SessionSummary[] = sessions.filter( - (session: SessionSummary) => session.workspaceId === workspaceId, - ); - assert.deepEqual( - filtered.items.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)), - workspaceSessions.map((session: SessionSummary) => session.id).toSorted((a, b) => a.localeCompare(b)), - `workspace-filtered index mismatch for ${workspaceId}`, - ); - const active = workspaceSessions.filter((session: SessionSummary) => !session.archived).length; - assert.equal(await index.countActive(workspaceId), active, `countActive mismatch for ${workspaceId}`); - } - console.log(`PASS workspace-filtered list/countActive (${String(workspaceIds.size)} workspace ids)`); - - const [v1WorkspacePage, v1Sessions] = await Promise.all([ - v1<{ readonly items: readonly V1Workspace[] }>('/workspaces'), - listAllV1Sessions(), - ]); - const v1Workspaces = v1WorkspacePage.items; - assert.deepEqual( - v1Workspaces.map((workspace) => workspace.id).toSorted((a, b) => a.localeCompare(b)), - workspaces.map((workspace) => workspace.id).toSorted((a, b) => a.localeCompare(b)), - 'v1 and IWorkspaceRegistry workspace ids differ', - ); - for (const workspace of v1Workspaces) { - assert.equal(workspace.root, workspaceById.get(workspace.id)?.root, `v1 root mismatch for ${workspace.id}`); - assert.equal( - workspace.session_count, - sessions.filter((session) => session.workspaceId === workspace.id).length, - `v1 session_count mismatch for ${workspace.id}`, - ); - } - assert.deepEqual( - v1Sessions.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)), - sessions.map((session) => session.id).toSorted((a, b) => a.localeCompare(b)), - 'v1 and ISessionIndex session ids differ', - ); - console.log('PASS v1 workspace/session cross-check'); - - const orphanIds = sessions - .filter((session) => !workspaceById.has(session.workspaceId)) - .map((session) => session.id) - .toSorted((a, b) => a.localeCompare(b)); - const allPaths = [ - ...workspaces.map((workspace: Workspace) => workspace.root), - ...sessions.flatMap((session) => (session.cwd === undefined ? [] : [session.cwd])), - ]; - const pathAliases = diagnoseAliases(allPaths); - const duplicateIndexIds = duplicates(sessions.map((session) => session.id)); - const duplicateV1Ids = duplicates(v1Sessions.map((session) => session.id)); - const duplicateWorkspaceIds = duplicates(workspaces.map((workspace) => workspace.id)); - const duplicateWorkspaceRoots = duplicates( - workspaces.map((workspace) => canonicalPath(workspace.root)), - ); - report('orphan sessions', orphanIds); - report('Windows/canonical path aliases', pathAliases); - report('duplicate index session ids', duplicateIndexIds); - report('duplicate v1 session ids', duplicateV1Ids); - report('duplicate workspace ids', duplicateWorkspaceIds); - report('duplicate canonical workspace roots', duplicateWorkspaceRoots); - if (orphanIds.length > 0) failures.push(`orphan sessions: ${orphanIds.join(', ')}`); - if (pathAliases.length > 0) failures.push(`Windows/canonical path aliases: ${pathAliases.join(', ')}`); - if (duplicateIndexIds.length > 0) failures.push(`duplicate index session ids: ${duplicateIndexIds.join(', ')}`); - if (duplicateV1Ids.length > 0) failures.push(`duplicate v1 session ids: ${duplicateV1Ids.join(', ')}`); - if (duplicateWorkspaceIds.length > 0) failures.push(`duplicate workspace ids: ${duplicateWorkspaceIds.join(', ')}`); - if (duplicateWorkspaceRoots.length > 0) { - failures.push(`duplicate canonical workspace roots: ${duplicateWorkspaceRoots.join(', ')}`); - } - - if (marker !== undefined) { - assert.ok(cold, 'KIMI_SMOKE_MARKER requires a selected historical session'); - // The v1 read resumes the session first; only then query the materialized - // agent scope. This avoids racing a cold v2 scope lookup against resume. - const v1Messages = await listAllV1Messages(cold.id); - const context = await Promise.resolve( - client.session(cold.id).agent('main').service(IAgentContextMemoryService).get(), - ); - const v1Matches = v1Messages.filter((message) => text(message.content).includes(marker)); - const contextMatches = context.filter((message) => text(message.content).includes(marker)); - assert.ok(v1Matches.length > 0, `marker ${JSON.stringify(marker)} absent from v1 messages`); - assert.ok(contextMatches.length > 0, `marker ${JSON.stringify(marker)} absent from agent context`); - assert.deepEqual( - sortedStrings(v1Matches.map((message) => message.role)), - sortedStrings(contextMatches.map((message) => message.role)), - 'marker message roles differ between v1 messages and agent context', - ); - console.log(`PASS marker cross-check (${String(v1Matches.length)} matching messages)`); - } - - if (failures.length > 0) { - throw new Error(`history audit found ${String(failures.length)} issue(s):\n- ${failures.join('\n- ')}`); - } - console.log('HISTORY SMOKE PASSED'); -} - -try { - await main(); -} catch (error) { - console.error('HISTORY SMOKE FAILED:', error instanceof Error ? error.stack ?? error.message : error); - process.exitCode = 1; -} diff --git a/packages/klient/examples/smoke.ts b/packages/klient/examples/smoke.ts index 600e5ca5cf..431e0fefb6 100644 --- a/packages/klient/examples/smoke.ts +++ b/packages/klient/examples/smoke.ts @@ -1,285 +1,83 @@ -import assert from 'node:assert/strict'; - -import { Klient, RPCError } from '@moonshot-ai/klient'; -import { IAgentContextMemoryService } from '@moonshot-ai/agent-core-v2/agent/contextMemory/contextMemory'; -import { IAgentContextSizeService } from '@moonshot-ai/agent-core-v2/agent/contextSize/contextSize'; -import { IAgentPermissionModeService } from '@moonshot-ai/agent-core-v2/agent/permissionMode/permissionMode'; -import { IAgentProfileService } from '@moonshot-ai/agent-core-v2/agent/profile/profile'; -import { IAgentTaskService } from '@moonshot-ai/agent-core-v2/agent/task/task'; -import { IAgentToolRegistryService } from '@moonshot-ai/agent-core-v2/agent/toolRegistry/toolRegistry'; -import { IAgentUsageService } from '@moonshot-ai/agent-core-v2/agent/usage/usage'; -import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { IWorkspaceRegistry } from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; -import { ISessionActivity } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; -import { ISessionWorkspaceContext } from '@moonshot-ai/agent-core-v2/session/workspaceContext/workspaceContext'; - -interface Envelope { - readonly code: number; - readonly msg: string; - readonly data: T; - readonly request_id?: string; -} - -interface ChannelDescriptor { - readonly name: string; - readonly scope: 'app' | 'session' | 'agent'; - readonly methods: readonly { readonly name: string; readonly kind: string }[]; -} - -interface WireMessage { - readonly role: string; - readonly content: readonly { readonly type: string; readonly text?: string }[]; -} - -function optionalEnv(name: string): string | undefined { - const value = process.env[name]; - return value === undefined || value === '' ? undefined : value; -} - -const baseUrl = (process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627').replace(/\/$/, ''); -const token = optionalEnv('KIMI_SERVER_TOKEN'); -const model = optionalEnv('KIMI_SMOKE_MODEL'); - -function headers(extra?: RequestInit['headers'], authToken = token): Headers { - const result = new Headers(extra); - if (authToken !== undefined) result.set('authorization', `Bearer ${authToken}`); - return result; +/** + * Assert-based smoke check for klient against a real `kap-server`. Exercises + * the `global` facade end-to-end over the HTTP transport: env snapshot, read + * models, a workspace round-trip, a model set/delete round-trip with the + * `models.changed` event, and the error path. + * + * KIMI_SERVER_URL=http://127.0.0.1:58627 KIMI_SERVER_TOKEN=YOUR_SERVER_TOKEN \ + * pnpm -C packages/klient smoke + */ +import { createKlient } from '@moonshot-ai/klient/http'; + +const BASE = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const TOKEN = process.env['KIMI_SERVER_TOKEN']; + +function assert(cond: boolean, message: string): asserts cond { + if (!cond) throw new Error(`assertion failed: ${message}`); } -async function apiFetch( - version: 'v1' | 'v2', - path: string, - init: RequestInit = {}, - authToken = token, -): Promise<{ readonly response: Response; readonly envelope: Envelope }> { - const response = await fetch(`${baseUrl}/api/${version}${path}`, { - ...init, - headers: headers(init.headers, authToken), +const tick = (ms: number): Promise => + new Promise((resolve) => { + setTimeout(resolve, ms); }); - const envelope = (await response.json()) as Envelope; - return { response, envelope }; -} - -async function v1(path: string, init: RequestInit = {}): Promise { - const { response, envelope } = await apiFetch('v1', path, init); - assert.equal(response.ok, true, `v1 ${path} returned HTTP ${String(response.status)}`); - assert.equal(envelope.code, 0, `v1 ${path}: ${String(envelope.code)} ${envelope.msg}`); - return envelope.data; -} - -async function v2(path: string, init: RequestInit = {}): Promise { - const { response, envelope } = await apiFetch('v2', path, init); - assert.equal(response.ok, true, `v2 ${path} returned HTTP ${String(response.status)}`); - assert.equal(envelope.code, 0, `v2 ${path}: ${String(envelope.code)} ${envelope.msg}`); - return envelope.data; -} - -function textOf(messages: readonly WireMessage[]): string { - return messages.flatMap((message) => message.content.map((part) => part.text ?? '')).join('\n'); -} - -async function waitForModelReply(sessionId: string, marker: string): Promise { - const deadline = Date.now() + 120_000; - while (Date.now() < deadline) { - const page = await v1<{ readonly items: readonly WireMessage[] }>( - `/sessions/${encodeURIComponent(sessionId)}/messages?page_size=100`, - ); - if (page.items.some((message) => message.role === 'assistant' && textOf([message]).includes(marker))) { - return; - } - await new Promise((resolve) => { - setTimeout(resolve, 500); - }); - } - throw new Error(`timed out waiting for model reply containing ${JSON.stringify(marker)}`); -} - -async function expectRpcError(label: string, operation: () => Promise, code: number): Promise { - try { - await operation(); - assert.fail(`${label} unexpectedly succeeded`); - } catch (error) { - assert.ok(error instanceof RPCError, `${label} did not throw RPCError`); - assert.equal(error.code, code, `${label} returned unexpected RPC code`); - } -} async function main(): Promise { - console.log(`server: ${baseUrl}`); - console.log(`auth: ${token === undefined ? 'not configured' : 'bearer token'}`); - console.log(`model: ${model ?? 'disabled (transport-only smoke)'}`); + const klient = createKlient({ url: BASE, token: TOKEN }); - const channels = await v2('/channels'); - for (const expected of ['sessionIndex', 'sessionMetadata', 'agentContextMemoryService']) { - assert.ok(channels.some((channel) => channel.name === expected), `missing v2 channel ${expected}`); - } - assert.ok( - channels.find((channel) => channel.name === 'sessionIndex')?.methods.some((method) => method.name === 'list'), - 'sessionIndex.list is not exposed', - ); - console.log(`PASS /api/v2/channels (${String(channels.length)} channels)`); + const env = await klient.global.env(); + assert(env.platform.length > 0 && env.homeDir.length > 0, 'env snapshot is populated'); + console.log('[ok] env'); - if (token !== undefined) { - const badAuth = await apiFetch('v2', '/channels', {}, `${token}-invalid`); - assert.ok( - !badAuth.response.ok || badAuth.envelope.code !== 0, - 'an invalid bearer token unexpectedly accessed /api/v2/channels', - ); - console.log('PASS invalid bearer token rejected'); - } else { - console.log('SKIP invalid bearer token (KIMI_SERVER_TOKEN is unset)'); - } - - const client = new Klient({ url: baseUrl, token }); - const index = client.core(ISessionIndex); - const initial = await index.list({ includeArchived: true }); - assert.ok(Array.isArray(initial.items), 'HTTP core sessionIndex.list did not return items'); - const workspaces = await client.core(IWorkspaceRegistry).list(); - assert.ok(Array.isArray(workspaces), 'HTTP core workspaceRegistry.list did not return an array'); - console.log( - `PASS Klient HTTP core (${String(initial.items.length)} sessions, ${String(workspaces.length)} workspaces)`, - ); + const page = await klient.global.sessions.list({ limit: 5 }); + assert(Array.isArray(page.items), 'sessions.list returns a page'); + console.log('[ok] sessions.list ->', page.items.length); - await expectRpcError( - 'unknown method', - () => (index as unknown as { missingMethod(): Promise }).missingMethod(), - 40001, - ); - const wrongScope = await apiFetch('v2', '/sessionMetadata/read', { method: 'POST' }); - assert.equal(wrongScope.envelope.code, 40001, 'session service at core scope must be rejected'); - console.log('PASS unknown method and wrong scope rejected'); + const workspaces = await klient.global.workspaces.list(); + assert(Array.isArray(workspaces), 'workspaces.list returns an array'); + console.log('[ok] workspaces.list ->', workspaces.length); - const cwd = process.env['KIMI_SMOKE_CWD'] ?? workspaces[0]?.root; - assert.ok( - cwd !== undefined, - 'no registered workspace; set KIMI_SMOKE_CWD to a directory on the server', - ); - const created = await v1<{ readonly id: string }>('/sessions', { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title: 'Klient smoke session', metadata: { cwd } }), + // Provider round-trip with the klient-level event. + const seen: string[] = []; + const sub = klient.events.on('providers.changed', (event) => { + seen.push(...event.added, ...event.changed, ...event.removed); }); - const sessionId = created.id; - assert.ok(sessionId.length > 0, 'v1 session creation returned an empty id'); - console.log(`created owned session ${sessionId} in ${cwd}`); - + await tick(300); // let the subscription cross the lazy WS + const name = '__klient_smoke__'; + await klient.global.providers.set({ name, config: { apiKey: 'smoke-key' } }); + assert((await klient.global.providers.get(name)) !== undefined, 'providers.get returns the new provider'); + const deadline = Date.now() + 5_000; + while (!seen.includes(name) && Date.now() < deadline) await tick(25); + assert(seen.includes(name), 'providers.changed fired for the new provider'); + await klient.global.providers.delete(name); + sub.dispose(); + console.log('[ok] providers set/get/delete + providers.changed'); + + const config = await klient.global.config.getAll(); + assert(typeof config === 'object' && config !== null, 'config.getAll returns an object'); + console.log('[ok] config.getAll'); + + assert(Array.isArray(await klient.global.flags.list()), 'flags.list returns an array'); + assert(Array.isArray(await klient.global.plugins.list()), 'plugins.list returns an array'); + const auth = await klient.global.auth.status(); + assert(typeof auth.loggedIn === 'boolean', 'auth.status returns a status'); + console.log('[ok] flags / plugins / auth'); + + let rpcError: { name: string; code?: number } | undefined; try { - const session = client.session(sessionId); - const metadata = session.service(ISessionMetadata); - const before = await metadata.read(); - assert.equal(before.id, sessionId); - assert.equal(before.cwd, cwd); - await metadata.setTitle('Klient smoke verified'); - const after = await metadata.read(); - assert.equal(after.title, 'Klient smoke verified'); - - const activity = session.service(ISessionActivity); - assert.equal(await Promise.resolve(activity.status()), 'idle'); - assert.equal(await Promise.resolve(activity.isIdle()), true); - const workspace = session.service(ISessionWorkspaceContext); - const resolvedCwd = await Promise.resolve(workspace.resolve('.')); - assert.equal(await Promise.resolve(workspace.isWithin(resolvedCwd)), true); - console.log('PASS Klient HTTP session metadata/activity/workspace'); - - const agent = session.agent('main'); - const context = await Promise.resolve(agent.service(IAgentContextMemoryService).get()); - const tasks = await Promise.resolve(agent.service(IAgentTaskService).list(false, 20)); - const usage = await Promise.resolve(agent.service(IAgentUsageService).status()); - const contextSize = await Promise.resolve(agent.service(IAgentContextSizeService).get()); - const tools = await Promise.resolve(agent.service(IAgentToolRegistryService).list()); - const profile = await Promise.resolve(agent.service(IAgentProfileService).data()); - const permissionMode = await ( - agent.service(IAgentPermissionModeService) as unknown as { mode(): Promise } - ).mode(); - assert.ok(Array.isArray(context), 'HTTP agent context get did not return an array'); - assert.ok(Array.isArray(tasks), 'HTTP agent task list did not return an array'); - assert.ok(usage !== null && typeof usage === 'object', 'HTTP agent usage status is invalid'); - assert.equal(typeof contextSize.size, 'number'); - assert.ok(Array.isArray(tools), 'HTTP agent tool list did not return an array'); - assert.equal(typeof profile.cwd, 'string'); - assert.ok(['manual', 'auto', 'yolo'].includes(permissionMode)); - console.log('PASS Klient HTTP agent context/task/usage/profile/tools/permission'); - - const ws = client.ws(); - let resolveEvent: (event: unknown) => void = () => {}; - const eventReceived = new Promise((resolve) => { - resolveEvent = resolve; - }); - const listenErrors: unknown[] = []; - const errorSubscription = ws.onDidListenError((error) => listenErrors.push(error)); - const subscription = ws.listen('events', resolveEvent); - try { - const wsIndex = await ws.core(ISessionIndex).get(sessionId); - assert.equal(wsIndex?.id, sessionId); - assert.equal(ws.state, 'open'); - const wsMeta = await ws.session(sessionId).service(ISessionMetadata).read(); - assert.equal(wsMeta.id, sessionId); - const wsContext = await Promise.resolve( - ws.session(sessionId).agent('main').service(IAgentContextMemoryService).get(), - ); - assert.ok(Array.isArray(wsContext), 'WebSocket agent context get did not return an array'); - await new Promise((resolve) => { - setTimeout(resolve, 100); - }); - await v1(`/sessions/${encodeURIComponent(sessionId)}/profile`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ title: 'Klient smoke WebSocket event' }), - }); - await Promise.race([ - eventReceived, - new Promise((_, reject) => { - setTimeout(() => { - reject(new Error('timed out waiting for a WebSocket core event')); - }, 5_000); - }), - ]); - assert.deepEqual(listenErrors, [], 'WebSocket subscription reported an error'); - } finally { - subscription.dispose(); - errorSubscription.dispose(); - ws.close(); - } - console.log('PASS Klient WebSocket core/session/agent calls and event subscription'); - - if (model !== undefined) { - const marker = `KLlENT_SMOKE_${Date.now().toString(36)}`; - await client - .session(sessionId) - .agent('main') - .service(IAgentProfileService) - .setModel(model); - const submitted = await v1<{ readonly prompt_id: string }>( - `/sessions/${encodeURIComponent(sessionId)}/prompts`, - { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ - content: [{ type: 'text', text: `Reply with exactly: ${marker}` }], - model, - }), - }, - ); - assert.ok(submitted.prompt_id.length > 0, 'model prompt returned an empty prompt id'); - await waitForModelReply(sessionId, marker); - console.log('PASS optional minimal model prompt'); - } - } finally { - const archived = await v1<{ readonly archived: boolean }>( - `/sessions/${encodeURIComponent(sessionId)}:archive`, - { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}' }, - ); - assert.equal(archived.archived, true, 'owned smoke session was not archived'); - console.log('PASS owned session archived'); + await klient.global.plugins.info('__definitely_missing__'); + } catch (error) { + rpcError = error as { name: string; code?: number }; } + assert(rpcError !== undefined, 'missing plugin surfaces an error'); + console.log('[ok] error path ->', rpcError.name, rpcError.code); - console.log('SMOKE PASSED'); + await klient.close(); + console.log('smoke: OK'); } try { await main(); } catch (error) { - console.error('SMOKE FAILED:', error instanceof Error ? error.stack ?? error.message : error); - process.exitCode = 1; + console.error(error); + process.exit(1); } diff --git a/packages/klient/package.json b/packages/klient/package.json index f0366164f8..a691b85973 100644 --- a/packages/klient/package.json +++ b/packages/klient/package.json @@ -2,7 +2,7 @@ "name": "@moonshot-ai/klient", "version": "0.0.1", "private": true, - "description": "Kimi client SDK — reuse agent-core-v2 service interfaces over the /api/v2 HTTP channel.", + "description": "Kimi client SDK — contract-driven facade over agent-core-v2, routable over http, ipc, or in-memory transports.", "license": "MIT", "type": "module", "imports": { @@ -13,6 +13,18 @@ "types": "./src/index.ts", "default": "./src/index.ts" }, + "./http": { + "types": "./src/transports/http/index.ts", + "default": "./src/transports/http/index.ts" + }, + "./ipc": { + "types": "./src/transports/ipc/index.ts", + "default": "./src/transports/ipc/index.ts" + }, + "./memory": { + "types": "./src/transports/memory/index.ts", + "default": "./src/transports/memory/index.ts" + }, "./package.json": { "types": "./package.json", "default": "./package.json" @@ -28,10 +40,18 @@ "typecheck:examples": "tsc -p tsconfig.examples.json --noEmit", "test": "vitest run", "smoke": "tsx examples/smoke.ts", - "smoke:history": "tsx examples/session-history-smoke.ts", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "docker:e2e": "bash scripts/run-docker-e2e.sh" }, "dependencies": { - "@moonshot-ai/agent-core-v2": "workspace:^" + "@moonshot-ai/agent-core-v2": "workspace:^", + "zod": "catalog:" + }, + "devDependencies": { + "@moonshot-ai/kap-server": "workspace:^", + "@moonshot-ai/protocol": "workspace:^", + "@types/ws": "^8.18.0", + "ulid": "^3.0.1", + "ws": "^8.18.0" } } diff --git a/packages/server-e2e/scripts/run-docker-e2e.sh b/packages/klient/scripts/run-docker-e2e.sh similarity index 96% rename from packages/server-e2e/scripts/run-docker-e2e.sh rename to packages/klient/scripts/run-docker-e2e.sh index d48068d428..59e72e293f 100755 --- a/packages/server-e2e/scripts/run-docker-e2e.sh +++ b/packages/klient/scripts/run-docker-e2e.sh @@ -54,7 +54,7 @@ workspace_node_modules=( "pkg_acp-adapter:/workspace/kimi-code/packages/acp-adapter/node_modules" "pkg_agent-core:/workspace/kimi-code/packages/agent-core/node_modules" "pkg_kap-server:/workspace/kimi-code/packages/kap-server/node_modules" - "pkg_server-e2e:/workspace/kimi-code/packages/server-e2e/node_modules" + "pkg_server-e2e:/workspace/kimi-code/packages/klient/node_modules" "pkg_kaos:/workspace/kimi-code/packages/kaos/node_modules" "pkg_kosong:/workspace/kimi-code/packages/kosong/node_modules" "pkg_migration-legacy:/workspace/kimi-code/packages/migration-legacy/node_modules" @@ -97,7 +97,7 @@ cd /workspace/kimi-code mkdir -p "${KIMI_CODE_HOME}/server" "${KIMI_SERVER_E2E_REPORT_DIR}" "${TMPDIR}" /data/server-e2e-reports/docker rm -f "${KIMI_CODE_HOME}/server/lock" -if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/server-e2e/node_modules/ws ]]; then +if [[ ! -e /workspace/kimi-code/node_modules/.modules.yaml || ! -e /workspace/kimi-code/packages/klient/node_modules/ws ]]; then echo "[server-e2e:docker] installing pnpm deps" pnpm install --frozen-lockfile else @@ -148,8 +148,8 @@ if [[ "${ready}" != "1" ]]; then exit 1 fi -cd /workspace/kimi-code/packages/server-e2e -pnpm test:scenarios +cd /workspace/kimi-code/packages/klient +pnpm test EOS docker_args=( @@ -157,7 +157,7 @@ docker_args=( --rm --init --name "${CONTAINER}" - --workdir /workspace/kimi-code/packages/server-e2e + --workdir /workspace/kimi-code/packages/klient --env "KIMI_CODE_HOME=${KIMI_HOME_CONTAINER}" --env "KIMI_SERVER_E2E_PORT=${PORT}" --env "KIMI_SERVER_URL=http://127.0.0.1:${PORT}" diff --git a/packages/klient/src/channel.ts b/packages/klient/src/channel.ts deleted file mode 100644 index 9feb271e28..0000000000 --- a/packages/klient/src/channel.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Transport-agnostic channel contract for the `/api/v2` client. - * - * In the VS Code model the channel is bound to one Service (the URL carries the - * scope + the Service's decorator id) and `command` is the method name, invoked - * by reflection on the server. `listen` is for events over a persistent (WS) - * transport; the HTTP channel only implements `call`. - */ - -export interface IDisposable { - dispose(): void; -} - -export interface Event { - (listener: (event: T) => unknown, thisArg?: unknown, disposables?: IDisposable[]): IDisposable; -} - -/** The client-facing channel contract. Calls always carry the complete argument array. */ -export interface IChannel { - call(command: string, args?: unknown[]): Promise; - listen(event: string, arg?: unknown): Event; -} diff --git a/packages/klient/src/client.ts b/packages/klient/src/client.ts deleted file mode 100644 index be1a925a46..0000000000 --- a/packages/klient/src/client.ts +++ /dev/null @@ -1,135 +0,0 @@ -/** - * `/api/v2` client — three-level scope entry (core / session / agent) over the - * HTTP channel. - * - * const client = new Klient({ url: 'http://127.0.0.1:58627' }); - * await client.core(ISessionIndex).list({}); - * await client.session('s1').service(ISessionMetadata).read(); - * await client.session('s1').agent('a1').service(IAgentProfile).getModel(); - * - * The `agent-core-v2` service token is the whole key: its type parameter `T` - * types the returned proxy, and its decorator id (`String(id)`) is the channel - * name in the URL. Each scope level binds a channel to - * `/` and hands back a typed proxy via `makeProxy`, - * which forwards method calls verbatim to the server's reflection dispatcher. - */ - -import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; - -import type { IChannel } from './channel.js'; -import { HttpChannel, type HttpChannelOptions } from './httpChannel.js'; -import { makeProxy } from './proxy.js'; -import { WsKlient } from './wsKlient.js'; -import type { WsLikeCtor } from './wsSocket.js'; - -export interface KlientOptions { - /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ - readonly url: string; - /** Optional bearer token. */ - readonly token?: string; - /** `fetch` implementation; defaults to the global `fetch`. */ - readonly fetch?: typeof fetch; - /** WebSocket implementation for `ws()`; defaults to the global `WebSocket`. */ - readonly WebSocketImpl?: WsLikeCtor; -} - -export class Klient { - private readonly url: string; - private readonly token?: string; - private readonly fetchImpl?: typeof fetch; - private readonly wsImpl?: WsLikeCtor; - private wsKlient?: WsKlient; - - constructor(opts: KlientOptions) { - this.url = opts.url.replace(/\/$/, ''); - this.token = opts.token; - this.fetchImpl = opts.fetch; - this.wsImpl = opts.WebSocketImpl; - } - - private channelOptions(baseUrl: string): HttpChannelOptions { - return { baseUrl, token: this.token, fetch: this.fetchImpl }; - } - - /** Core-scoped Service, e.g. `client.core(ISessionIndex)`. */ - core(id: ServiceIdentifier): T { - return makeProxy( - new HttpChannel(this.channelOptions(`${this.url}/api/v2/${String(id)}`)), - ); - } - - /** Session scope entry point. */ - session(sessionId: string): SessionClient { - return new SessionClient(this.url, this.token, this.fetchImpl, sessionId); - } - - /** - * WebSocket counterpart of this client — same scopes and typed proxies over - * the persistent `/api/v2/ws` socket, plus event `listen`s. Lazily created - * on first call so one `Klient` holds at most one live socket; close it with - * `client.ws().close()`. After a close, the next `ws()` call lazily creates a - * fresh `WsKlient` (so React StrictMode's mount → unmount → mount cycle, - * whose cleanup closes the socket, recovers on the second mount). - */ - ws(): WsKlient { - if (this.wsKlient === undefined || this.wsKlient.state === 'closed') { - this.wsKlient = new WsKlient({ - url: this.url, - token: this.token, - WebSocketImpl: this.wsImpl, - }); - } - return this.wsKlient; - } -} - -export class SessionClient { - private readonly baseUrl: string; - - constructor( - url: string, - private readonly token: string | undefined, - private readonly fetchImpl: typeof fetch | undefined, - sessionId: string, - ) { - this.baseUrl = `${url.replace(/\/$/, '')}/api/v2/session/${encodeURIComponent(sessionId)}`; - } - - /** Session-scoped Service, e.g. `.service(ISessionMetadata)`. */ - service(id: ServiceIdentifier): T { - const channel: IChannel = new HttpChannel({ - baseUrl: `${this.baseUrl}/${String(id)}`, - token: this.token, - fetch: this.fetchImpl, - }); - return makeProxy(channel); - } - - /** Agent scope entry point. */ - agent(agentId: string): AgentClient { - return new AgentClient(this.baseUrl, this.token, this.fetchImpl, agentId); - } -} - -export class AgentClient { - private readonly baseUrl: string; - - constructor( - sessionBaseUrl: string, - private readonly token: string | undefined, - private readonly fetchImpl: typeof fetch | undefined, - agentId: string, - ) { - this.baseUrl = `${sessionBaseUrl}/agent/${encodeURIComponent(agentId)}`; - } - - /** Agent-scoped Service, e.g. `.service(IAgentProfile)`. */ - service(id: ServiceIdentifier): T { - const channel: IChannel = new HttpChannel({ - baseUrl: `${this.baseUrl}/${String(id)}`, - token: this.token, - fetch: this.fetchImpl, - }); - return makeProxy(channel); - } -} diff --git a/packages/klient/src/contract/agent/activity.ts b/packages/klient/src/contract/agent/activity.ts new file mode 100644 index 0000000000..5f9f0c5a08 --- /dev/null +++ b/packages/klient/src/contract/agent/activity.ts @@ -0,0 +1,75 @@ +/** + * `agentActivityView` — the agent's folded activity snapshot. Mirrors + * `agent-core-v2/agent/activityView/activityView.ts`. The `turn.origin` + * deep `PromptOrigin` union is mirrored as `unknown` (parity pins the + * engine → wire direction only, like `agentContextData.history`). + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const turnPhaseSchema = z.enum(['running', 'streaming', 'tool_call', 'retrying']); + +export const approvalRefSchema = z.object({ + approvalId: z.string(), + toolCallId: z.string().optional(), + since: z.number(), +}); + +export const toolCallRefSchema = z.object({ + toolCallId: z.string(), + name: z.string(), + since: z.number(), +}); + +export const activityRetryStateSchema = z.object({ + failedAttempt: z.number(), + nextAttempt: z.number(), + maxAttempts: z.number(), + delayMs: z.number(), + errorName: z.string().optional(), + statusCode: z.number().optional(), +}); + +export const activityTurnStateSchema = z.object({ + turnId: z.number(), + origin: z.unknown(), + phase: turnPhaseSchema, + stream: z.enum(['assistant', 'thinking', 'tool_call']).optional(), + step: z.number(), + ending: z.boolean(), + endingReason: z.enum(['aborted', 'max_steps', 'error']).optional(), + retry: activityRetryStateSchema.optional(), + pendingApprovals: z.array(approvalRefSchema), + activeToolCalls: z.array(toolCallRefSchema), + since: z.number(), +}); + +export const turnEndReasonSchema = z.enum(['completed', 'cancelled', 'failed', 'blocked']); + +export const activityLastTurnStateSchema = z.object({ + turnId: z.number(), + reason: turnEndReasonSchema, + durationMs: z.number().optional(), + at: z.number(), +}); + +export const backgroundRefSchema = z.object({ + kind: z.string(), + id: z.string(), + since: z.number(), +}); + +export const activityViewLifecycleSchema = z.enum(['ready', 'disposed']); + +export const agentActivityStateSchema = z.object({ + lifecycle: activityViewLifecycleSchema, + turn: activityTurnStateSchema.optional(), + lastTurn: activityLastTurnStateSchema.optional(), + background: z.array(backgroundRefSchema), +}); + +export const agentActivityViewContract = { + state: { input: z.tuple([]), output: agentActivityStateSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/agent/events.ts b/packages/klient/src/contract/agent/events.ts new file mode 100644 index 0000000000..6f7ced4662 --- /dev/null +++ b/packages/klient/src/contract/agent/events.ts @@ -0,0 +1,174 @@ +/** + * Klient-level agent-scope events — the public, typed, namespaced event + * surface of one agent. All registrations filter the per-agent `events` + * scope stream by `type`; the payload is the whole flat `{ type, ... }` + * event (schemas keep the `type` literal so listeners receive it intact). + * Payload shapes mirror `protocol/src/events.ts`; events that are loose in + * the engine (or absent from the protocol union) are `z.looseObject`s. + */ + +import { z } from 'zod'; + +import type { EventRegistration } from '../types.js'; + +/** + * Scope-stream registration (`kind: 'stream'`). Declared structurally here + * until `EventRegistration` in `../types.js` gains the `stream` variant; + * compatible with `src/core/events/hub.ts`, which already switches on it. + */ +interface StreamEventRegistration { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; +} + +type AgentEventRegistration = EventRegistration | StreamEventRegistration; + +// ── payload schemas ───────────────────────────────────────────────────────── + +export const turnStartedEventSchema = z.object({ + type: z.literal('turn.started'), + turnId: z.number(), + /** Protocol `PromptOrigin` union — mirrored as `unknown`. */ + origin: z.unknown(), +}); + +export const turnEndedEventSchema = z.object({ + type: z.literal('turn.ended'), + turnId: z.number(), + reason: z.enum(['completed', 'cancelled', 'failed', 'blocked']), + /** Protocol `KimiErrorPayload` — mirrored as `unknown`. */ + error: z.unknown().optional(), + durationMs: z.number().optional(), +}); + +export const assistantDeltaEventSchema = z.object({ + type: z.literal('assistant.delta'), + turnId: z.number(), + delta: z.string(), +}); + +export const thinkingDeltaEventSchema = z.object({ + type: z.literal('thinking.delta'), + turnId: z.number(), + delta: z.string(), +}); + +export const toolCallStartedEventSchema = z.object({ + type: z.literal('tool.call.started'), + turnId: z.number(), + toolCallId: z.string(), + name: z.string(), + args: z.unknown(), + description: z.string().optional(), + /** Protocol `ToolInputDisplay` — mirrored as `unknown`. */ + display: z.unknown().optional(), +}); + +export const toolResultEventSchema = z.object({ + type: z.literal('tool.result'), + turnId: z.number(), + toolCallId: z.string(), + output: z.unknown(), + isError: z.boolean().optional(), + synthetic: z.boolean().optional(), +}); + +export const promptCompletedEventSchema = z.object({ + type: z.literal('prompt.completed'), + promptId: z.string(), + /** ISO 8601 datetime string on the wire. */ + finishedAt: z.string(), + reason: z.enum(['completed', 'failed', 'blocked']).optional(), +}); + +export const promptAbortedEventSchema = z.object({ + type: z.literal('prompt.aborted'), + promptId: z.string(), + /** ISO 8601 datetime string on the wire. */ + abortedAt: z.string(), +}); + +/** Engine `permission.approval.requested` — not in the protocol union; loose. */ +export const permissionApprovalRequestedEventSchema = z.looseObject({ + turnId: z.number(), + toolCallId: z.string(), + toolName: z.string(), + action: z.string(), +}); + +/** Engine `permission.approval.resolved` — not in the protocol union; loose. */ +export const permissionApprovalResolvedEventSchema = z.looseObject({ + turnId: z.number(), + toolCallId: z.string(), +}); + +/** `error` payloads carry the full `KimiErrorPayload`; kept loose. */ +export const errorEventSchema = z.looseObject({ + message: z.string(), +}); + +export const warningEventSchema = z.object({ + type: z.literal('warning'), + message: z.string(), + code: z.string().optional(), +}); + +/** `agent.status.updated` carries a wide optional status bag; kept loose. */ +export const agentStatusUpdatedEventSchema = z.looseObject({ + phase: z.string().optional(), +}); + +// ── registrations ─────────────────────────────────────────────────────────── + +/** Public event name → payload type. Keys must stay in sync with `agentEvents`. */ +export interface AgentEventPayloads { + 'turn.started': z.infer; + 'turn.ended': z.infer; + 'assistant.delta': z.infer; + 'thinking.delta': z.infer; + 'tool.call.started': z.infer; + 'tool.result': z.infer; + 'prompt.completed': z.infer; + 'prompt.aborted': z.infer; + 'permission.approval.requested': z.infer; + 'permission.approval.resolved': z.infer; + error: z.infer; + warning: z.infer; + 'agent.status.updated': z.infer; +} + +export type AgentEventName = keyof AgentEventPayloads; + +/** Public event name → stream binding + payload schema. */ +export const agentEvents = { + 'turn.started': { kind: 'stream', name: 'events', type: 'turn.started', schema: turnStartedEventSchema }, + 'turn.ended': { kind: 'stream', name: 'events', type: 'turn.ended', schema: turnEndedEventSchema }, + 'assistant.delta': { kind: 'stream', name: 'events', type: 'assistant.delta', schema: assistantDeltaEventSchema }, + 'thinking.delta': { kind: 'stream', name: 'events', type: 'thinking.delta', schema: thinkingDeltaEventSchema }, + 'tool.call.started': { kind: 'stream', name: 'events', type: 'tool.call.started', schema: toolCallStartedEventSchema }, + 'tool.result': { kind: 'stream', name: 'events', type: 'tool.result', schema: toolResultEventSchema }, + 'prompt.completed': { kind: 'stream', name: 'events', type: 'prompt.completed', schema: promptCompletedEventSchema }, + 'prompt.aborted': { kind: 'stream', name: 'events', type: 'prompt.aborted', schema: promptAbortedEventSchema }, + 'permission.approval.requested': { + kind: 'stream', + name: 'events', + type: 'permission.approval.requested', + schema: permissionApprovalRequestedEventSchema, + }, + 'permission.approval.resolved': { + kind: 'stream', + name: 'events', + type: 'permission.approval.resolved', + schema: permissionApprovalResolvedEventSchema, + }, + error: { kind: 'stream', name: 'events', type: 'error', schema: errorEventSchema }, + warning: { kind: 'stream', name: 'events', type: 'warning', schema: warningEventSchema }, + 'agent.status.updated': { + kind: 'stream', + name: 'events', + type: 'agent.status.updated', + schema: agentStatusUpdatedEventSchema, + }, +} satisfies Record; diff --git a/packages/klient/src/contract/agent/rpc.ts b/packages/klient/src/contract/agent/rpc.ts new file mode 100644 index 0000000000..1e68532e67 --- /dev/null +++ b/packages/klient/src/contract/agent/rpc.ts @@ -0,0 +1,213 @@ +/** + * `agentRPCService` — the per-agent RPC surface. Mirrors the `AgentAPI` + * subset of `agent-core-v2/agent/rpc/core-api.ts`; every method takes one + * payload object. `PromptPayload.input` mirrors the `PromptPart` subset of + * `ContentPart` (text / image_url / video_url) from + * `agent-core-v2/app/llmProtocol/message.ts`. Task wire shapes mirror the + * `TaskInfo` union in `protocol/src/events.ts`. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +// ── prompt parts ──────────────────────────────────────────────────────────── + +const textPartSchema = z.object({ + type: z.literal('text'), + text: z.string(), +}); + +const imageUrlPartSchema = z.object({ + type: z.literal('image_url'), + imageUrl: z.object({ url: z.string(), id: z.string().optional() }), +}); + +const videoUrlPartSchema = z.object({ + type: z.literal('video_url'), + videoUrl: z.object({ url: z.string(), id: z.string().optional() }), +}); + +/** `PromptPart = Extract`. */ +export const promptPartSchema = z.discriminatedUnion('type', [ + textPartSchema, + imageUrlPartSchema, + videoUrlPartSchema, +]); + +// ── payloads / results ────────────────────────────────────────────────────── + +export const emptyPayloadSchema = z.object({}); + +export const promptPayloadSchema = z.object({ + input: z.array(promptPartSchema), +}); + +/** Same shape as `PromptPayload` in the engine. */ +export const steerPayloadSchema = promptPayloadSchema; + +export const promptLaunchResultSchema = z.object({ + turn_id: z.number(), +}); + +export const cancelPayloadSchema = z.object({ + turnId: z.number().optional(), +}); + +export const runShellCommandPayloadSchema = z.object({ + command: z.string(), + commandId: z.string().optional(), +}); + +export const shellCommandResultSchema = z.object({ + stdout: z.string(), + stderr: z.string(), + isError: z.boolean().optional(), + backgrounded: z.boolean().optional(), +}); + +export const cancelShellCommandPayloadSchema = z.object({ + commandId: z.string(), +}); + +export const setModelPayloadSchema = z.object({ + model: z.string(), +}); + +export const setModelResultSchema = z.object({ + model: z.string(), + providerName: z.string().optional(), +}); + +export const permissionModeSchema = z.enum(['manual', 'yolo', 'auto']); + +export const setPermissionPayloadSchema = z.object({ + mode: permissionModeSchema, +}); + +export const tokenUsageSchema = z.object({ + inputOther: z.number(), + output: z.number(), + inputCacheRead: z.number(), + inputCacheCreation: z.number(), +}); + +export const usageStatusSchema = z.object({ + byModel: z.record(z.string(), tokenUsageSchema).optional(), + currentTurn: tokenUsageSchema.optional(), + total: tokenUsageSchema.optional(), +}); + +/** + * `AgentContextData` — `history` items are full `ContextMessage`s (deep + * `Message` / `Tool` / `PromptOrigin` unions); mirrored as `unknown` entries. + */ +export const agentContextDataSchema = z.object({ + history: z.array(z.unknown()), + tokenCount: z.number(), +}); + +/** `PlanData = null | { id, content, path }` — null is JSON-representable. */ +export const planDataSchema = z.union([ + z.null(), + z.object({ + id: z.string(), + content: z.string(), + path: z.string(), + }), +]); + +export const cancelPlanPayloadSchema = z.object({ + id: z.string().optional(), +}); + +export const getTasksPayloadSchema = z.object({ + activeOnly: z.boolean().optional(), + limit: z.number().optional(), +}); + +const taskLifecycleStatusSchema = z.enum([ + 'running', + 'completed', + 'failed', + 'timed_out', + 'killed', + 'lost', +]); + +const taskInfoBaseFields = { + taskId: z.string(), + description: z.string(), + status: taskLifecycleStatusSchema, + detached: z.boolean().optional(), + startedAt: z.number(), + endedAt: z.union([z.number(), z.null()]), + stopReason: z.string().optional(), + terminalNotificationSuppressed: z.boolean().optional(), + timeoutMs: z.number().optional(), +} as const; + +/** Protocol `TaskInfo` union (`protocol/src/events.ts`). */ +export const agentTaskInfoSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('process'), + command: z.string(), + pid: z.number(), + exitCode: z.union([z.number(), z.null()]), + ...taskInfoBaseFields, + }), + z.object({ + kind: z.literal('agent'), + agentId: z.string().optional(), + subagentType: z.string().optional(), + ...taskInfoBaseFields, + }), + z.object({ + kind: z.literal('question'), + questionCount: z.number(), + toolCallId: z.string().optional(), + ...taskInfoBaseFields, + }), +]); + +export const stopTaskPayloadSchema = z.object({ + taskId: z.string(), + reason: z.string().optional(), +}); + +export const getTaskOutputPayloadSchema = z.object({ + taskId: z.string(), + tail: z.number().optional(), +}); + +// ── contract ──────────────────────────────────────────────────────────────── + +export const agentRpcContract = { + prompt: { input: z.tuple([promptPayloadSchema]), output: maybe(promptLaunchResultSchema) }, + steer: { input: z.tuple([steerPayloadSchema]), output: maybe(promptLaunchResultSchema) }, + cancel: { input: z.tuple([cancelPayloadSchema]), output: noResult }, + runShellCommand: { + input: z.tuple([runShellCommandPayloadSchema]), + output: shellCommandResultSchema, + }, + cancelShellCommand: { + input: z.tuple([cancelShellCommandPayloadSchema]), + output: noResult, + }, + getModel: { input: z.tuple([emptyPayloadSchema]), output: z.string() }, + setModel: { input: z.tuple([setModelPayloadSchema]), output: setModelResultSchema }, + setPermission: { input: z.tuple([setPermissionPayloadSchema]), output: noResult }, + getUsage: { input: z.tuple([emptyPayloadSchema]), output: usageStatusSchema }, + getContext: { input: z.tuple([emptyPayloadSchema]), output: agentContextDataSchema }, + getPlan: { input: z.tuple([emptyPayloadSchema]), output: planDataSchema }, + enterPlan: { input: z.tuple([emptyPayloadSchema]), output: noResult }, + clearPlan: { input: z.tuple([emptyPayloadSchema]), output: noResult }, + cancelPlan: { input: z.tuple([cancelPlanPayloadSchema]), output: noResult }, + getTasks: { + input: z.tuple([getTasksPayloadSchema]), + output: z.array(agentTaskInfoSchema), + }, + stopTask: { input: z.tuple([stopTaskPayloadSchema]), output: noResult }, + getTaskOutput: { input: z.tuple([getTaskOutputPayloadSchema]), output: z.string() }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/auth.ts b/packages/klient/src/contract/global/auth.ts new file mode 100644 index 0000000000..618cede734 --- /dev/null +++ b/packages/klient/src/contract/global/auth.ts @@ -0,0 +1,104 @@ +/** + * `oauthService` + `authSummaryService` — app-scope OAuth flow and auth + * summary. Mirrors `agent-core-v2/app/auth/auth.ts`; wire shapes mirror + * `protocol/src/rest/oauth.ts` (snake_case fields). `resolveTokenProvider` + * and `getCachedAccessToken` are excluded (non-serializable). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const oAuthFlowStatusSchema = z.enum([ + 'pending', + 'authenticated', + 'denied', + 'expired', + 'cancelled', +]); + +export const oAuthFlowStartSchema = z.discriminatedUnion('status', [ + z.object({ + flow_id: z.string(), + provider: z.string(), + status: z.literal('pending'), + verification_uri: z.string(), + verification_uri_complete: z.string(), + user_code: z.string(), + expires_in: z.number(), + interval: z.number(), + expires_at: z.string(), + }), + z.object({ + flow_id: z.string(), + provider: z.string(), + status: z.literal('authenticated'), + }), +]); + +export const oAuthFlowSnapshotSchema = z.object({ + flow_id: z.string(), + provider: z.string(), + status: oAuthFlowStatusSchema, + verification_uri: z.string(), + verification_uri_complete: z.string(), + user_code: z.string(), + expires_in: z.number(), + expires_at: z.string(), + interval: z.number(), + resolved_at: z.string().optional(), + error_message: z.string().optional(), +}); + +export const oAuthLoginCancelResponseSchema = z.object({ + cancelled: z.boolean(), + status: oAuthFlowStatusSchema, +}); + +export const oAuthLogoutResponseSchema = z.object({ + logged_out: z.literal(true), + provider: z.string(), +}); + +export const authStatusSchema = z.object({ + loggedIn: z.boolean(), + provider: z.string().optional(), +}); + +/** Same shape as `refreshProviderModelsResponseSchema` in `./catalog.js` — keep in sync. */ +export const refreshOAuthProviderModelsResponseSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +export const authContract = { + startLogin: { input: z.tuple([z.string().optional()]), output: oAuthFlowStartSchema }, + getFlow: { + input: z.tuple([z.string().optional()]), + output: maybe(oAuthFlowSnapshotSchema), + }, + cancelLogin: { + input: z.tuple([z.string().optional()]), + output: oAuthLoginCancelResponseSchema, + }, + logout: { input: z.tuple([z.string().optional()]), output: oAuthLogoutResponseSchema }, + status: { input: z.tuple([z.string().optional()]), output: authStatusSchema }, + refreshOAuthProviderModels: { + input: z.tuple([]), + output: refreshOAuthProviderModelsResponseSchema, + }, +} satisfies ServiceContract; + +export const authSummaryContract = { + summarize: { input: z.tuple([]), output: z.array(authStatusSchema) }, + ensureReady: { input: z.tuple([z.string().optional()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/catalog.ts b/packages/klient/src/contract/global/catalog.ts new file mode 100644 index 0000000000..7c7a685626 --- /dev/null +++ b/packages/klient/src/contract/global/catalog.ts @@ -0,0 +1,68 @@ +/** + * `modelCatalogService` — read-only catalog over configured providers and + * model aliases, plus the global default-model selection. Mirrors + * `agent-core-v2/app/modelCatalog/modelCatalog.ts`; wire shapes mirror + * `protocol/src/modelCatalog.ts` and `protocol/src/rest/modelCatalog.ts` + * (snake_case fields). + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const modelCatalogItemSchema = z.object({ + provider: z.string(), + model: z.string(), + display_name: z.string().optional(), + max_context_size: z.number(), + capabilities: z.array(z.string()).optional(), + support_efforts: z.array(z.string()).optional(), + default_effort: z.string().optional(), +}); + +export const providerCatalogStatusSchema = z.enum(['connected', 'error', 'unconfigured']); + +export const providerCatalogItemSchema = z.object({ + id: z.string(), + type: z.string(), + base_url: z.string().optional(), + default_model: z.string().optional(), + has_api_key: z.boolean(), + status: providerCatalogStatusSchema, + models: z.array(z.string()).optional(), +}); + +export const setDefaultModelResponseSchema = z.object({ + default_model: z.string(), + model: modelCatalogItemSchema, +}); + +export const refreshProviderModelsOptionsSchema = z.object({ + scope: z.enum(['all', 'oauth']).optional(), + providerId: z.string().optional(), +}); + +/** Same shape as `refreshOAuthProviderModelsResponseSchema` in `./auth.js` — keep in sync. */ +export const refreshProviderModelsResponseSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +export const catalogContract = { + listModels: { input: z.tuple([]), output: z.array(modelCatalogItemSchema) }, + listProviders: { input: z.tuple([]), output: z.array(providerCatalogItemSchema) }, + getProvider: { input: z.tuple([z.string()]), output: providerCatalogItemSchema }, + setDefaultModel: { input: z.tuple([z.string()]), output: setDefaultModelResponseSchema }, + refreshProviderModels: { + input: z.tuple([refreshProviderModelsOptionsSchema.optional()]), + output: refreshProviderModelsResponseSchema, + }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/config.ts b/packages/klient/src/contract/global/config.ts new file mode 100644 index 0000000000..80b7097a20 --- /dev/null +++ b/packages/klient/src/contract/global/config.ts @@ -0,0 +1,40 @@ +/** + * `configService` — layered global config service. Mirrors + * `agent-core-v2/app/config/config.ts`. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const configTargetSchema = z.enum(['user', 'memory']); + +export const configInspectValueSchema = z.object({ + value: z.unknown().optional(), + defaultValue: z.unknown().optional(), + userValue: z.unknown().optional(), + memoryValue: z.unknown().optional(), +}); + +export const configDiagnosticSchema = z.object({ + domain: z.string().optional(), + severity: z.enum(['warning', 'error']), + message: z.string(), +}); + +export const configContract = { + get: { input: z.tuple([z.string()]), output: z.unknown() }, + inspect: { input: z.tuple([z.string()]), output: configInspectValueSchema }, + getAll: { input: z.tuple([]), output: z.record(z.string(), z.unknown()) }, + set: { + input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]), + output: noResult, + }, + replace: { + input: z.tuple([z.string(), z.unknown(), configTargetSchema.optional()]), + output: noResult, + }, + reload: { input: z.tuple([]), output: noResult }, + diagnostics: { input: z.tuple([]), output: z.array(configDiagnosticSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/env.ts b/packages/klient/src/contract/global/env.ts new file mode 100644 index 0000000000..f784f391a6 --- /dev/null +++ b/packages/klient/src/contract/global/env.ts @@ -0,0 +1,26 @@ +/** + * `bootstrapService` — frozen startup snapshot: host facts and app path + * layout. Mirrors `agent-core-v2/app/bootstrap/bootstrap.ts`. The string + * properties are exposed as zero-arg reads. + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +const stringRead = { input: z.tuple([]), output: z.string() }; + +export const envContract = { + platform: stringRead, + arch: stringRead, + cwd: stringRead, + osHomeDir: stringRead, + homeDir: stringRead, + configPath: stringRead, + clientVersion: stringRead, + sessionsDir: stringRead, + blobsDir: stringRead, + storeDir: stringRead, + cacheDir: stringRead, + logsDir: stringRead, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/events.ts b/packages/klient/src/contract/global/events.ts new file mode 100644 index 0000000000..1f0b9e9c02 --- /dev/null +++ b/packages/klient/src/contract/global/events.ts @@ -0,0 +1,140 @@ +/** + * Klient-level global events — the public, typed, namespaced event surface. + * Each registration binds a public event name to its underlying source (the + * `IEventService` bus or one service's `onDid*` emitter) plus the zod schema + * its payload must satisfy. Consumers never see the engine's `onDid`/`onWill` + * naming; unknown bus event types are not forwarded. + */ + +import { z } from 'zod'; + +import type { ConfigChangedEvent } from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { ModelsChangedEvent } from '@moonshot-ai/agent-core-v2/app/model/model'; +import type { ProvidersChangedEvent } from '@moonshot-ai/agent-core-v2/app/provider/provider'; +import type { ReloadSummary } from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { IOAuthService } from '@moonshot-ai/agent-core-v2/app/auth/auth'; + +import { stringDeltaSchema } from '../helpers.js'; +import type { EventRegistration } from '../types.js'; + +/** Payload of `event.session.archived` on the global bus. */ +export interface SessionArchivedPayload { + readonly sessionId: string; +} + +/** Payload of `session.meta.updated` on the global bus (`agent/rpc/prompt-metadata.ts`). */ +export interface SessionMetaUpdatedPayload { + readonly agentId: string; + readonly sessionId: string; + readonly title?: string; + readonly patch: { + readonly title?: string; + readonly isCustomTitle?: boolean; + readonly lastPrompt: string; + }; +} + +/** Payload of `event.model_catalog.changed` — same shape as an OAuth refresh result. */ +export type CatalogChangedPayload = Awaited< + ReturnType +>; + +/** Public event name → payload type. Keys must stay in sync with `globalEvents`. */ +export interface KlientEventPayloads { + 'config.changed': ConfigChangedEvent; + 'config.sectionChanged': ConfigChangedEvent; + 'providers.changed': ProvidersChangedEvent; + 'models.changed': ModelsChangedEvent; + 'plugins.reloaded': ReloadSummary; + 'session.archived': SessionArchivedPayload; + 'session.metaUpdated': SessionMetaUpdatedPayload; + 'catalog.changed': CatalogChangedPayload; +} + +export type KlientEventName = keyof KlientEventPayloads; + +const configChangedSchema = z.object({ + domain: z.string(), + source: z.enum(['load', 'reload', 'set']), + value: z.unknown(), + previousValue: z.unknown(), +}); + +const reloadSummarySchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + errors: z.array(z.object({ id: z.string(), message: z.string() })), +}); + +const sessionMetaUpdatedSchema = z.object({ + agentId: z.string(), + sessionId: z.string(), + title: z.string().optional(), + patch: z.object({ + title: z.string().optional(), + isCustomTitle: z.boolean().optional(), + lastPrompt: z.string(), + }), +}); + +export const catalogChangedSchema = z.object({ + changed: z.array( + z.object({ + provider_id: z.string(), + provider_name: z.string(), + added: z.number(), + removed: z.number(), + }), + ), + unchanged: z.array(z.string()), + failed: z.array(z.object({ provider: z.string(), reason: z.string() })), +}); + +/** Public event name → source binding + payload schema. */ +export const globalEvents = { + 'config.changed': { + kind: 'emitter', + service: 'configService', + event: 'onDidChangeConfiguration', + schema: configChangedSchema, + }, + 'config.sectionChanged': { + kind: 'emitter', + service: 'configService', + event: 'onDidSectionChange', + schema: configChangedSchema, + }, + 'providers.changed': { + kind: 'emitter', + service: 'providerService', + event: 'onDidChangeProviders', + schema: stringDeltaSchema, + }, + 'models.changed': { + kind: 'emitter', + service: 'modelService', + event: 'onDidChangeModels', + schema: stringDeltaSchema, + }, + 'plugins.reloaded': { + kind: 'emitter', + service: 'pluginService', + event: 'onDidReload', + schema: reloadSummarySchema, + }, + 'session.archived': { + kind: 'bus', + type: 'event.session.archived', + schema: z.object({ sessionId: z.string() }), + }, + 'session.metaUpdated': { + kind: 'bus', + type: 'session.meta.updated', + schema: sessionMetaUpdatedSchema, + }, + 'catalog.changed': { + kind: 'bus', + type: 'event.model_catalog.changed', + schema: catalogChangedSchema, + }, +} satisfies Record; diff --git a/packages/klient/src/contract/global/flags.ts b/packages/klient/src/contract/global/flags.ts new file mode 100644 index 0000000000..d842fd5e7c --- /dev/null +++ b/packages/klient/src/contract/global/flags.ts @@ -0,0 +1,30 @@ +/** + * `flagService` — experimental-flag resolution. Mirrors + * `agent-core-v2/app/flag/flag.ts`. The `registry` property and + * `setConfigOverrides` are excluded (not part of the read-only wire surface). + */ + +import { z } from 'zod'; + +import { maybe } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const experimentalFeatureStateSchema = z.object({ + id: z.string(), + title: z.string(), + description: z.string(), + surface: z.enum(['core', 'tui', 'both']), + env: z.string(), + defaultEnabled: z.boolean(), + enabled: z.boolean(), + source: z.enum(['master-env', 'env', 'config', 'default']), + configValue: z.boolean().optional(), +}); + +export const flagsContract = { + enabled: { input: z.tuple([z.string()]), output: z.boolean() }, + snapshot: { input: z.tuple([]), output: z.record(z.string(), z.boolean()) }, + enabledIds: { input: z.tuple([]), output: z.array(z.string()) }, + explain: { input: z.tuple([z.string()]), output: maybe(experimentalFeatureStateSchema) }, + explainAll: { input: z.tuple([]), output: z.array(experimentalFeatureStateSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/hostFs.ts b/packages/klient/src/contract/global/hostFs.ts new file mode 100644 index 0000000000..1aa1e585ad --- /dev/null +++ b/packages/klient/src/contract/global/hostFs.ts @@ -0,0 +1,33 @@ +/** + * `hostFolderBrowser` — host-side folder picker for choosing a workspace + * folder. Mirrors `agent-core-v2/app/hostFolderBrowser/hostFolderBrowser.ts`; + * wire shapes mirror `protocol/src/rest/fsBrowse.ts` (snake_case fields). + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const fsBrowseEntrySchema = z.object({ + name: z.string().min(1), + path: z.string().min(1), + is_dir: z.literal(true), + is_git_repo: z.boolean(), + branch: z.string().optional(), +}); + +export const fsBrowseResponseSchema = z.object({ + path: z.string().min(1), + parent: z.string().min(1).nullable(), + entries: z.array(fsBrowseEntrySchema), +}); + +export const fsHomeResponseSchema = z.object({ + home: z.string().min(1), + recent_roots: z.array(z.string().min(1)), +}); + +export const hostFsContract = { + browse: { input: z.tuple([z.string().optional()]), output: fsBrowseResponseSchema }, + home: { input: z.tuple([]), output: fsHomeResponseSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/models.ts b/packages/klient/src/contract/global/models.ts new file mode 100644 index 0000000000..e61fcf9a0a --- /dev/null +++ b/packages/klient/src/contract/global/models.ts @@ -0,0 +1,77 @@ +/** + * `modelService` — model configuration registry. Mirrors + * `agent-core-v2/app/model/model.ts` (`ModelSchema`, including its + * passthrough of unknown keys) and `agent-core-v2/app/protocol/protocol.ts` + * (`ProtocolSchema`). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +const protocolSchema = z.enum([ + 'kimi', + 'anthropic', + 'openai', + 'openai_responses', + 'google-genai', + 'vertexai', +]); + +const oAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +const modelBaseSchema = z.object({ + providerId: z.string().optional(), + + baseUrl: z.string().optional(), + apiKey: z.string().optional(), + oauth: oAuthRefSchema.optional(), + + protocol: protocolSchema.optional(), + + name: z.string().optional(), + aliases: z.array(z.string()).optional(), + + provider: z.string().optional(), + model: z.string().optional(), + maxContextSize: z.number().int().min(1).optional(), + maxOutputSize: z.number().int().min(1).optional(), + capabilities: z.array(z.string()).optional(), + displayName: z.string().optional(), + reasoningKey: z.string().optional(), + adaptiveThinking: z.boolean().optional(), + betaApi: z.boolean().optional(), + supportEfforts: z.array(z.string()).optional(), + defaultEffort: z.string().optional(), +}); + +const modelOverrideSchema = modelBaseSchema + .omit({ + providerId: true, + baseUrl: true, + apiKey: true, + oauth: true, + protocol: true, + name: true, + aliases: true, + provider: true, + model: true, + betaApi: true, + }) + .partial(); + +export const modelConfigSchema = modelBaseSchema + .extend({ overrides: modelOverrideSchema.optional() }) + .passthrough(); + +export const modelsContract = { + get: { input: z.tuple([z.string()]), output: maybe(modelConfigSchema) }, + list: { input: z.tuple([]), output: z.record(z.string(), modelConfigSchema) }, + set: { input: z.tuple([z.string(), modelConfigSchema]), output: noResult }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts new file mode 100644 index 0000000000..ef0e2bacaa --- /dev/null +++ b/packages/klient/src/contract/global/plugins.ts @@ -0,0 +1,239 @@ +/** + * `pluginService` — plugin management and consumption. Mirrors + * `agent-core-v2/app/plugin/plugin.ts` and `agent-core-v2/app/plugin/types.ts`; + * nested `McpServerConfig` mirrors `agent-core-v2/agent/mcp/config-schema.ts`, + * `HookDefConfig` mirrors `agent-core-v2/agent/externalHooks/configSection.ts`. + * `pluginSkillRoots`, `enabledSessionStarts`, `enabledMcpServers`, and + * `enabledHooks` are excluded (not part of the klient wire surface). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const pluginDiagnosticSchema = z.object({ + severity: z.enum(['error', 'warn', 'info']), + message: z.string(), +}); + +const pluginAuthorSchema = z.object({ + name: z.string().optional(), + email: z.string().optional(), +}); + +const pluginSessionStartSchema = z.object({ + skill: z.string(), +}); + +const pluginInterfaceSchema = z.object({ + displayName: z.string().optional(), + shortDescription: z.string().optional(), + longDescription: z.string().optional(), + developerName: z.string().optional(), + websiteURL: z.string().optional(), +}); + +const stringRecordSchema = z.record(z.string(), z.string()); + +const mcpServerCommonFields = { + enabled: z.boolean().optional(), + startupTimeoutMs: z.number().int().min(1).optional(), + toolTimeoutMs: z.number().int().min(1).optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +const mcpServerConfigSchema = z.discriminatedUnion('transport', [ + z.object({ + transport: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringRecordSchema.optional(), + cwd: z.string().optional(), + executor: z.enum(['local', 'kaos']).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('sse'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), +]); + +const hookDefSchema = z.object({ + event: z.enum([ + 'PreToolUse', + 'PostToolUse', + 'PostToolUseFailure', + 'PermissionRequest', + 'PermissionResult', + 'UserPromptSubmit', + 'Stop', + 'StopFailure', + 'Interrupt', + 'SessionStart', + 'SessionEnd', + 'SubagentStart', + 'SubagentStop', + 'PreCompact', + 'PostCompact', + 'Notification', + ]), + matcher: z.string().optional(), + command: z.string().min(1), + timeout: z.number().int().min(1).max(600).optional(), +}); + +const pluginCommandEntrySchema = z.object({ + path: z.string(), + name: z.string(), +}); + +const pluginManifestKindSchema = z.enum(['kimi-plugin-root', 'kimi-plugin-dir']); + +const pluginSourceSchema = z.enum(['local-path', 'zip-url', 'github']); + +const pluginStateSchema = z.enum(['ok', 'error']); + +const pluginGithubRefSchema = z.object({ + kind: z.enum(['branch', 'tag', 'sha']), + value: z.string(), +}); + +export const pluginManifestSchema = z.object({ + name: z.string(), + version: z.string().optional(), + description: z.string().optional(), + keywords: z.array(z.string()).optional(), + author: pluginAuthorSchema.optional(), + homepage: z.string().optional(), + license: z.string().optional(), + skills: z.array(z.string()).optional(), + sessionStart: pluginSessionStartSchema.optional(), + mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), + hooks: z.array(hookDefSchema).optional(), + commands: z.array(pluginCommandEntrySchema).optional(), + interface: pluginInterfaceSchema.optional(), + skillInstructions: z.string().optional(), +}); + +export const pluginMcpServerInfoSchema = z.object({ + name: z.string(), + runtimeName: z.string(), + enabled: z.boolean(), + transport: z.enum(['stdio', 'http', 'sse']), + command: z.string().optional(), + args: z.array(z.string()).optional(), + cwd: z.string().optional(), + url: z.string().optional(), + envKeys: z.array(z.string()).optional(), + headerKeys: z.array(z.string()).optional(), +}); + +export const pluginGithubMetadataSchema = z.object({ + owner: z.string(), + repo: z.string(), + ref: pluginGithubRefSchema, + installedSha: z.string().optional(), +}); + +export const pluginSummarySchema = z.object({ + id: z.string(), + displayName: z.string(), + version: z.string().optional(), + enabled: z.boolean(), + state: pluginStateSchema, + skillCount: z.number(), + mcpServerCount: z.number(), + enabledMcpServerCount: z.number(), + hookCount: z.number(), + commandCount: z.number(), + hasErrors: z.boolean(), + source: pluginSourceSchema, + originalSource: z.string().optional(), + github: pluginGithubMetadataSchema.optional(), +}); + +export const pluginInfoSchema = pluginSummarySchema.extend({ + root: z.string(), + installedAt: z.string(), + updatedAt: z.string().optional(), + manifestKind: pluginManifestKindSchema.optional(), + manifestPath: z.string().optional(), + manifest: pluginManifestSchema.optional(), + mcpServers: z.array(pluginMcpServerInfoSchema), + shadowedManifestPath: z.string().optional(), + diagnostics: z.array(pluginDiagnosticSchema), +}); + +/** Same shape as `reloadSummarySchema` in `./events.js` — keep in sync. */ +export const reloadSummarySchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + errors: z.array(z.object({ id: z.string(), message: z.string() })), +}); + +export const pluginUpdateStatusSchema = z.object({ + id: z.string(), + source: pluginSourceSchema, + current: pluginGithubRefSchema.optional(), + latest: pluginGithubRefSchema, + displayVersion: z.string(), + updateAvailable: z.boolean(), +}); + +export const pluginCommandDefSchema = z.object({ + pluginId: z.string(), + name: z.string(), + description: z.string(), + body: z.string(), + path: z.string(), +}); + +export const installPluginInputSchema = z.object({ + source: z.string(), +}); + +export const setPluginEnabledInputSchema = z.object({ + id: z.string(), + enabled: z.boolean(), +}); + +export const setPluginMcpServerEnabledInputSchema = z.object({ + id: z.string(), + server: z.string(), + enabled: z.boolean(), +}); + +export const removePluginInputSchema = z.object({ + id: z.string(), +}); + +export const getPluginInfoInputSchema = z.object({ + id: z.string(), +}); + +export const pluginsContract = { + listPlugins: { input: z.tuple([]), output: z.array(pluginSummarySchema) }, + installPlugin: { input: z.tuple([installPluginInputSchema]), output: pluginSummarySchema }, + setPluginEnabled: { input: z.tuple([setPluginEnabledInputSchema]), output: noResult }, + setPluginMcpServerEnabled: { + input: z.tuple([setPluginMcpServerEnabledInputSchema]), + output: noResult, + }, + removePlugin: { input: z.tuple([removePluginInputSchema]), output: noResult }, + reloadPlugins: { input: z.tuple([]), output: reloadSummarySchema }, + getPluginInfo: { input: z.tuple([getPluginInfoInputSchema]), output: pluginInfoSchema }, + listPluginCommands: { input: z.tuple([]), output: z.array(pluginCommandDefSchema) }, + checkUpdates: { input: z.tuple([]), output: z.array(pluginUpdateStatusSchema) }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/providers.ts b/packages/klient/src/contract/global/providers.ts new file mode 100644 index 0000000000..72f158de0d --- /dev/null +++ b/packages/klient/src/contract/global/providers.ts @@ -0,0 +1,50 @@ +/** + * `providerService` — provider configuration registry. Mirrors + * `agent-core-v2/app/provider/provider.ts` (`ProviderConfigSchema`). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +const providerTypeSchema = z.enum([ + 'anthropic', + 'openai', + 'kimi', + 'google-genai', + 'openai_responses', + 'vertexai', +]); + +const oAuthRefSchema = z.object({ + storage: z.enum(['file', 'keyring']), + key: z.string().min(1), + oauthHost: z.string().min(1).optional(), +}); + +const stringRecordSchema = z.record(z.string(), z.string()); + +const modelSourceSchema = z.enum(['static', 'discover', 'oauth-catalog']); + +export const providerConfigSchema = z.object({ + platformId: z.string().optional(), + modelSource: modelSourceSchema.optional(), + + baseUrl: z.string().optional(), + customHeaders: stringRecordSchema.optional(), + defaultModel: z.string().optional(), + + type: providerTypeSchema.optional(), + apiKey: z.string().optional(), + oauth: oAuthRefSchema.optional(), + env: stringRecordSchema.optional(), + source: z.record(z.string(), z.unknown()).optional(), +}); + +export const providersContract = { + get: { input: z.tuple([z.string()]), output: maybe(providerConfigSchema) }, + list: { input: z.tuple([]), output: z.record(z.string(), providerConfigSchema) }, + set: { input: z.tuple([z.string(), providerConfigSchema]), output: noResult }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/sessions.ts b/packages/klient/src/contract/global/sessions.ts new file mode 100644 index 0000000000..3e389bdafe --- /dev/null +++ b/packages/klient/src/contract/global/sessions.ts @@ -0,0 +1,36 @@ +/** + * `sessionIndex` — the persisted session read model. Mirrors + * `agent-core-v2/app/sessionIndex/sessionIndex.ts`. + */ + +import { z } from 'zod'; + +import { maybe, pageOf } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const sessionSummarySchema = z.object({ + id: z.string(), + workspaceId: z.string(), + cwd: z.string().optional(), + title: z.string().optional(), + lastPrompt: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), + archived: z.boolean(), + custom: z.record(z.string(), z.unknown()).optional(), +}); + +export const sessionListQuerySchema = z.object({ + workspaceId: z.string().optional(), + sessionId: z.string().optional(), + includeArchived: z.boolean().optional(), + cursor: z.string().optional(), + limit: z.number().optional(), + childOf: z.string().optional(), +}); + +export const sessionsContract = { + list: { input: z.tuple([sessionListQuerySchema]), output: pageOf(sessionSummarySchema) }, + get: { input: z.tuple([z.string()]), output: maybe(sessionSummarySchema) }, + countActive: { input: z.tuple([z.string()]), output: z.number() }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/global/workspaces.ts b/packages/klient/src/contract/global/workspaces.ts new file mode 100644 index 0000000000..d5577f2c19 --- /dev/null +++ b/packages/klient/src/contract/global/workspaces.ts @@ -0,0 +1,35 @@ +/** + * `workspaceRegistry` — process-wide catalog of known workspaces. Mirrors + * `agent-core-v2/app/workspaceRegistry/workspaceRegistry.ts`. + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const workspaceSchema = z.object({ + id: z.string(), + root: z.string(), + name: z.string(), + createdAt: z.number(), + lastOpenedAt: z.number(), +}); + +export const workspaceUpdateSchema = z.object({ + name: z.string().optional(), +}); + +export const workspacesContract = { + list: { input: z.tuple([]), output: z.array(workspaceSchema) }, + get: { input: z.tuple([z.string()]), output: maybe(workspaceSchema) }, + createOrTouch: { + input: z.tuple([z.string(), z.string().optional()]), + output: workspaceSchema, + }, + update: { + input: z.tuple([z.string(), workspaceUpdateSchema]), + output: maybe(workspaceSchema), + }, + delete: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/helpers.ts b/packages/klient/src/contract/helpers.ts new file mode 100644 index 0000000000..377d5cc940 --- /dev/null +++ b/packages/klient/src/contract/helpers.ts @@ -0,0 +1,35 @@ +/** + * Shared schema helpers for contract fragments. + */ + +import { z } from 'zod'; + +/** `Page` on the wire (`persistence/interface/queryStore.ts`). */ +export const pageOf = (item: T) => + z.object({ + items: z.array(item), + nextCursor: z.string().optional(), + }); + +/** + * Engine `X | undefined` returns cross the wire as `null` over HTTP (JSON + * has no `undefined`) and as `undefined` in-process — accept both and + * normalize to `undefined`. + */ +export const maybe = (schema: T) => + z.union([schema, z.null(), z.undefined()]).transform((value) => value ?? undefined); + +/** + * `void` method results arrive as `null` over HTTP (JSON has no `undefined`) + * and as `undefined` in-process — accept both and normalize to `undefined`. + */ +export const noResult = z + .union([z.void(), z.null()]) + .transform(() => undefined); + +/** Engine `{ added, removed, changed }` change-set events. */ +export const stringDeltaSchema = z.object({ + added: z.array(z.string()), + removed: z.array(z.string()), + changed: z.array(z.string()), +}); diff --git a/packages/klient/src/contract/index.ts b/packages/klient/src/contract/index.ts new file mode 100644 index 0000000000..4ec7235ba9 --- /dev/null +++ b/packages/klient/src/contract/index.ts @@ -0,0 +1,54 @@ +/** + * The aggregated klient contract — service wire name → method → zod + * input/output schemas, across the core/session/agent scopes. The klient + * factory validates every call against this table; transports never see it. + * Event registrations live in the per-scope `events.ts` files alongside + * their payload schemas. + */ + +import type { KlientContract } from './types.js'; +import { agentActivityViewContract } from './agent/activity.js'; +import { agentRpcContract } from './agent/rpc.js'; +import { authContract, authSummaryContract } from './global/auth.js'; +import { catalogContract } from './global/catalog.js'; +import { configContract } from './global/config.js'; +import { envContract } from './global/env.js'; +import { flagsContract } from './global/flags.js'; +import { hostFsContract } from './global/hostFs.js'; +import { modelsContract } from './global/models.js'; +import { pluginsContract } from './global/plugins.js'; +import { providersContract } from './global/providers.js'; +import { sessionsContract } from './global/sessions.js'; +import { workspacesContract } from './global/workspaces.js'; +import { sessionApprovalContract } from './session/approval.js'; +import { sessionInteractionContract } from './session/interaction.js'; +import { sessionLifecycleContract } from './session/lifecycle.js'; +import { sessionMetadataContract } from './session/metadata.js'; +import { sessionQuestionContract } from './session/question.js'; + +export const globalContract: KlientContract = { + // core (app scope) + sessionIndex: sessionsContract, + workspaceRegistry: workspacesContract, + configService: configContract, + providerService: providersContract, + modelService: modelsContract, + modelCatalogService: catalogContract, + oauthService: authContract, + authSummaryService: authSummaryContract, + flagService: flagsContract, + pluginService: pluginsContract, + hostFolderBrowser: hostFsContract, + bootstrapService: envContract, + // session scope (+ the app-registered lifecycle service) + sessionLifecycleService: sessionLifecycleContract, + sessionMetadata: sessionMetadataContract, + sessionInteractionService: sessionInteractionContract, + sessionApprovalService: sessionApprovalContract, + sessionQuestionService: sessionQuestionContract, + // agent scope + agentRPCService: agentRpcContract, + agentActivityView: agentActivityViewContract, +}; + +export type { KlientContract, ProcedureContract, ServiceContract } from './types.js'; diff --git a/packages/klient/src/contract/session/approval.ts b/packages/klient/src/contract/session/approval.ts new file mode 100644 index 0000000000..8256c33765 --- /dev/null +++ b/packages/klient/src/contract/session/approval.ts @@ -0,0 +1,35 @@ +/** + * `sessionApprovalService` — session-scope approval broker. Mirrors + * `agent-core-v2/session/approval/approval.ts`. `ApprovalRequest.display` is + * the protocol `ToolInputDisplay` union (huge); it crosses the wire + * uninspected, so it is `z.unknown()` here. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const approvalRequestSchema = z.object({ + id: z.string().optional(), + sessionId: z.string().optional(), + agentId: z.string().optional(), + turnId: z.number().optional(), + toolCallId: z.string().optional(), + toolName: z.string(), + action: z.string(), + /** Protocol `ToolInputDisplay` — mirrored as `unknown` (see file header). */ + display: z.unknown(), +}); + +export const approvalResponseSchema = z.object({ + decision: z.enum(['approved', 'rejected', 'cancelled']), + scope: z.literal('session').optional(), + feedback: z.string().optional(), + selectedLabel: z.string().optional(), +}); + +export const sessionApprovalContract = { + listPending: { input: z.tuple([]), output: z.array(approvalRequestSchema) }, + decide: { input: z.tuple([z.string(), approvalResponseSchema]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/events.ts b/packages/klient/src/contract/session/events.ts new file mode 100644 index 0000000000..75f037bb0c --- /dev/null +++ b/packages/klient/src/contract/session/events.ts @@ -0,0 +1,66 @@ +/** + * Klient-level session-scope events — the public, typed, namespaced event + * surface of one session. Mirrors the pattern of `../global/events.ts`; + * stream names match the kap-server session event map (`interactions`, + * `interactions:resolved`). + */ + +import { z } from 'zod'; + +import type { + Interaction, + InteractionResolution, +} from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; +import type { SessionMetadataChangedEvent } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; + +import type { EventRegistration } from '../types.js'; +import { + interactionResolutionSchema, + interactionSchema, +} from './interaction.js'; +import { sessionMetadataChangedEventSchema } from './metadata.js'; + +/** + * Scope-stream registration (`kind: 'stream'`). Declared structurally here + * until `EventRegistration` in `../types.js` gains the `stream` variant; + * compatible with `src/core/events/hub.ts`, which already switches on it. + */ +interface StreamEventRegistration { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; +} + +type SessionEventRegistration = EventRegistration | StreamEventRegistration; + +/** Public event name → payload type. Keys must stay in sync with `sessionEvents`. */ +export interface SessionEventPayloads { + 'metadata.changed': SessionMetadataChangedEvent; + 'interactions.changed': readonly Interaction[]; + 'interactions.resolved': InteractionResolution; +} + +export type SessionEventName = keyof SessionEventPayloads; + +/** Public event name → source binding + payload schema. */ +export const sessionEvents = { + 'metadata.changed': { + kind: 'emitter', + service: 'sessionMetadata', + event: 'onDidChangeMetadata', + schema: sessionMetadataChangedEventSchema, + }, + // Passthrough stream (no `type` filter): the source pushes the full + // pending interaction set on every change. + 'interactions.changed': { + kind: 'stream', + name: 'interactions', + schema: z.array(interactionSchema), + }, + 'interactions.resolved': { + kind: 'stream', + name: 'interactions:resolved', + schema: interactionResolutionSchema, + }, +} satisfies Record; diff --git a/packages/klient/src/contract/session/interaction.ts b/packages/klient/src/contract/session/interaction.ts new file mode 100644 index 0000000000..a7d5153b44 --- /dev/null +++ b/packages/klient/src/contract/session/interaction.ts @@ -0,0 +1,38 @@ +/** + * `sessionInteractionService` — blocking human-in-the-loop request kernel. + * Mirrors `agent-core-v2/session/interaction/interaction.ts`. + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const interactionKindSchema = z.enum(['approval', 'question', 'user_tool']); + +export const interactionOriginSchema = z.object({ + agentId: z.string().optional(), + turnId: z.number().optional(), +}); + +export const interactionSchema = z.object({ + id: z.string(), + kind: interactionKindSchema, + payload: z.unknown(), + origin: interactionOriginSchema, + createdAt: z.number(), +}); + +export const interactionResolutionSchema = z.object({ + id: z.string(), + response: z.unknown(), +}); + +export const sessionInteractionContract = { + listPending: { + input: z.tuple([interactionKindSchema.optional()]), + output: z.array(interactionSchema), + }, + respond: { input: z.tuple([z.string(), z.unknown()]), output: noResult }, + isRecentlyResolved: { input: z.tuple([z.string()]), output: z.boolean() }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts new file mode 100644 index 0000000000..355ceb401c --- /dev/null +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -0,0 +1,86 @@ +/** + * `sessionLifecycleService` — creates and tracks sessions at the process + * root. Mirrors `agent-core-v2/app/sessionLifecycle/sessionLifecycle.ts`. + * The engine returns `ISessionScopeHandle`s; over JSON only the plain data + * fields survive, so the wire keeps `{ id, kind }` (loose — extra fields may + * appear in-process). + */ + +import { z } from 'zod'; + +import { maybe, noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +/** + * Mirror of `mcpServerConfigSchema` in `../global/plugins.js` — kept local + * because that fragment does not export its copy; keep the two in sync. + * Mirrors `agent-core-v2/agent/mcp/config-schema.ts`. + */ +const stringRecordSchema = z.record(z.string(), z.string()); + +const mcpServerCommonFields = { + enabled: z.boolean().optional(), + startupTimeoutMs: z.number().int().min(1).optional(), + toolTimeoutMs: z.number().int().min(1).optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +const mcpServerConfigSchema = z.discriminatedUnion('transport', [ + z.object({ + transport: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringRecordSchema.optional(), + cwd: z.string().optional(), + executor: z.enum(['local', 'kaos']).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('sse'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), +]); + +export const createSessionOptionsSchema = z.object({ + sessionId: z.string().optional(), + workDir: z.string(), + additionalDirs: z.array(z.string()).optional(), + mcpServers: z.record(z.string(), mcpServerConfigSchema).optional(), +}); + +export const forkSessionOptionsSchema = z.object({ + sourceSessionId: z.string(), + newSessionId: z.string().optional(), + title: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), +}); + +/** Same fields as `ForkSessionOptions` in the engine — keep in sync. */ +export const createChildSessionOptionsSchema = forkSessionOptionsSchema; + +/** `ISessionScopeHandle` as it survives JSON — `{ id, kind }` plus extras. */ +export const handleWireSchema = z.looseObject({ + id: z.string(), + kind: z.number(), +}); + +export const sessionLifecycleContract = { + create: { input: z.tuple([createSessionOptionsSchema]), output: handleWireSchema }, + resume: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, + close: { input: z.tuple([z.string()]), output: noResult }, + archive: { input: z.tuple([z.string()]), output: noResult }, + restore: { input: z.tuple([z.string()]), output: maybe(handleWireSchema) }, + fork: { input: z.tuple([forkSessionOptionsSchema]), output: handleWireSchema }, + createChild: { input: z.tuple([createChildSessionOptionsSchema]), output: handleWireSchema }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/metadata.ts b/packages/klient/src/contract/session/metadata.ts new file mode 100644 index 0000000000..13eaabc023 --- /dev/null +++ b/packages/klient/src/contract/session/metadata.ts @@ -0,0 +1,76 @@ +/** + * `sessionMetadata` — typed session metadata. Mirrors + * `agent-core-v2/session/sessionMetadata/sessionMetadata.ts`. The `ready` + * promise property is excluded (not a wire method). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const agentMetaSchema = z.object({ + homedir: z.string().optional(), + type: z.enum(['main', 'sub', 'independent']).optional(), + parentAgentId: z.union([z.string(), z.null()]).optional(), + forkedFrom: z.string().optional(), + labels: z.record(z.string(), z.string()).optional(), + swarmItem: z.string().optional(), +}); + +export const sessionMetaSchema = z.object({ + id: z.string(), + version: z.number().optional(), + title: z.string().optional(), + isCustomTitle: z.boolean().optional(), + lastPrompt: z.string().optional(), + createdAt: z.number(), + updatedAt: z.number(), + archived: z.boolean(), + cwd: z.string().optional(), + forkedFrom: z.string().optional(), + agents: z.record(z.string(), agentMetaSchema).optional(), + custom: z.record(z.string(), z.unknown()).optional(), +}); + +/** `Partial>` — every key optional. */ +export const sessionMetaPatchSchema = z.object({ + version: z.number().optional(), + title: z.string().optional(), + isCustomTitle: z.boolean().optional(), + lastPrompt: z.string().optional(), + updatedAt: z.number().optional(), + archived: z.boolean().optional(), + cwd: z.string().optional(), + forkedFrom: z.string().optional(), + agents: z.record(z.string(), agentMetaSchema).optional(), + custom: z.record(z.string(), z.unknown()).optional(), +}); + +/** `keyof SessionMeta` — keep in sync with `sessionMetaSchema`. */ +export const sessionMetaKeySchema = z.enum([ + 'id', + 'version', + 'title', + 'isCustomTitle', + 'lastPrompt', + 'createdAt', + 'updatedAt', + 'archived', + 'cwd', + 'forkedFrom', + 'agents', + 'custom', +]); + +export const sessionMetadataChangedEventSchema = z.object({ + changed: z.array(sessionMetaKeySchema), +}); + +export const sessionMetadataContract = { + read: { input: z.tuple([]), output: sessionMetaSchema }, + update: { input: z.tuple([sessionMetaPatchSchema]), output: noResult }, + setTitle: { input: z.tuple([z.string()]), output: noResult }, + setArchived: { input: z.tuple([z.boolean()]), output: noResult }, + registerAgent: { input: z.tuple([z.string(), agentMetaSchema]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/session/question.ts b/packages/klient/src/contract/session/question.ts new file mode 100644 index 0000000000..470bcc6696 --- /dev/null +++ b/packages/klient/src/contract/session/question.ts @@ -0,0 +1,56 @@ +/** + * `sessionQuestionService` — ask-user request broker. Mirrors + * `agent-core-v2/session/question/question.ts` (the in-process camelCase + * representation; the snake_case protocol shape is adapted at the edge). + */ + +import { z } from 'zod'; + +import { noResult } from '../helpers.js'; +import type { ServiceContract } from '../types.js'; + +export const questionOptionSchema = z.object({ + label: z.string(), + description: z.string().optional(), +}); + +export const questionItemSchema = z.object({ + question: z.string(), + header: z.string().optional(), + body: z.string().optional(), + options: z.array(questionOptionSchema), + multiSelect: z.boolean().optional(), + otherLabel: z.string().optional(), + otherDescription: z.string().optional(), +}); + +/** `QuestionAnswers = Record`. */ +export const questionAnswersSchema = z.record( + z.string(), + z.union([z.string(), z.literal(true)]), +); + +export const questionResponseSchema = z.object({ + answers: questionAnswersSchema, + method: z.enum(['enter', 'space', 'number_key']).optional(), +}); + +/** `QuestionResult = null | QuestionAnswers | QuestionResponse`. */ +export const questionResultSchema = z.union([ + z.null(), + questionAnswersSchema, + questionResponseSchema, +]); + +export const questionRequestSchema = z.object({ + id: z.string().optional(), + turnId: z.number().optional(), + toolCallId: z.string().optional(), + questions: z.array(questionItemSchema), +}); + +export const sessionQuestionContract = { + listPending: { input: z.tuple([]), output: z.array(questionRequestSchema) }, + answer: { input: z.tuple([z.string(), questionResultSchema]), output: noResult }, + dismiss: { input: z.tuple([z.string()]), output: noResult }, +} satisfies ServiceContract; diff --git a/packages/klient/src/contract/types.ts b/packages/klient/src/contract/types.ts new file mode 100644 index 0000000000..707a227fe9 --- /dev/null +++ b/packages/klient/src/contract/types.ts @@ -0,0 +1,46 @@ +/** + * Contract vocabulary. A procedure mirrors one engine service method: `input` + * is a zod tuple over its positional arguments, `output` the zod schema of its + * resolved result. The facade reshapes positional args into single-object + * params; the wire keeps the engine's original argument order so contracts + * stay mechanical to write and audit. + */ + +import type { z } from 'zod'; + +export interface ProcedureContract { + /** Tuple schema over the engine method's positional args. */ + readonly input: z.ZodType; + /** Schema of the method's resolved return value as it appears on the wire. */ + readonly output: z.ZodType; +} + +/** method name → procedure */ +export type ServiceContract = Readonly>; + +/** service wire name (decorator id string) → its methods */ +export type KlientContract = Readonly>; + +/** + * Where a klient-level event reads from: + * - `bus` — filter the process-wide `IEventService` stream by `type` + * (payload unwrapped from `{ type, payload }`). + * - `stream` — a named scope stream (`events`, `interactions`, + * `interactions:resolved`); with `type` set, only flat `{ type, ...fields }` + * events of that type are forwarded, whole. + * - `emitter` — subscribe one service's `onDid*` property. + */ +export type EventRegistration = + | { readonly kind: 'bus'; readonly type: string; readonly schema: z.ZodType } + | { + readonly kind: 'stream'; + readonly name: string; + readonly type?: string; + readonly schema: z.ZodType; + } + | { + readonly kind: 'emitter'; + readonly service: string; + readonly event: string; + readonly schema: z.ZodType; + }; diff --git a/packages/klient/src/core/channel.ts b/packages/klient/src/core/channel.ts new file mode 100644 index 0000000000..f24678cff5 --- /dev/null +++ b/packages/klient/src/core/channel.ts @@ -0,0 +1,50 @@ +/** + * Transport SPI — the single abstraction every klient transport implements. + * + * A `KlientChannel` carries service calls and event subscriptions for one + * scope triple. The facade above it never knows which transport is underneath + * (http, ipc, or in-memory); transports never know which facade method + * triggered a frame. `ScopeRef` already carries session/agent coordinates so + * future session/agent facades plug in without changing this interface. + */ + +export interface IDisposable { + dispose(): void; +} + +/** Scope coordinates of a call/subscription. Empty object = core (app) scope. */ +export interface ScopeRef { + readonly sessionId?: string; + readonly agentId?: string; +} + +/** + * Where an event subscription reads from: + * - `stream` — a scope's named event stream, mirroring kap-server's WS + * `eventMap`: core `events` (the global `IEventService` bus), session + * `interactions` / `interactions:resolved`, agent `events` (the per-agent + * `IEventBus`). The scope coordinates disambiguate which scope's stream. + * - `emitter` — one service's `onDid*` `Event` property, addressed by the + * service's wire name and the property name (e.g. `onDidChangeModels`). + */ +export type EventSourceRef = + | { readonly kind: 'stream'; readonly name: string } + | { readonly kind: 'emitter'; readonly service: string; readonly event: string }; + +export interface KlientChannel { + /** Invoke `service.method(...args)` in the given scope; resolves with the raw wire result. */ + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise; + /** + * Subscribe to an event source; `handler` receives raw wire payloads. + * `onError` reports asynchronous subscription failures (bad source, dropped + * remote subscription) — synchronous validation may also throw. + */ + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable; + /** Tear the transport down (sockets, lazy bridges). Rejects in-flight calls. */ + close(): Promise; +} diff --git a/packages/klient/src/errors.ts b/packages/klient/src/core/errors.ts similarity index 81% rename from packages/klient/src/errors.ts rename to packages/klient/src/core/errors.ts index b655802de5..bb46bd1fa5 100644 --- a/packages/klient/src/errors.ts +++ b/packages/klient/src/core/errors.ts @@ -1,5 +1,5 @@ /** - * Client-side RPC error surfaced when the `/api/v2` envelope carries a non-zero + * Client-side RPC error surfaced when the wire envelope carries a non-zero * `code`. Mirrors the server envelope (`{ code, msg, data, request_id }`) — the * numeric `code` is the stable branch key across the wire, not `instanceof`. */ diff --git a/packages/klient/src/core/events/hub.ts b/packages/klient/src/core/events/hub.ts new file mode 100644 index 0000000000..cb14b4dcd3 --- /dev/null +++ b/packages/klient/src/core/events/hub.ts @@ -0,0 +1,227 @@ +/** + * The event hub — klient-level event forwarding. It exposes typed, namespaced + * events and hides the engine's `onDid*`/`onWill*` surface: each public event + * name resolves to one registration (a global-bus type filter, a scope + * stream, or a service emitter). Underlying channel subscriptions are shared + * per source and ref-counted by listener count. Payloads are validated + * against the event schema before delivery; bad payloads are dropped and + * reported through `onError`, never thrown. + * + * One hub serves one scope: the global klient hub binds `{}`, session/agent + * handles bind their scope coordinates, so `stream` sources resolve to the + * right scope's event stream on every transport. + */ + +import type { IDisposable, KlientChannel, ScopeRef } from '../channel.js'; +import type { EventRegistration } from '#/contract/types'; +import type { KlientEventPayloads } from '#/contract/global/events'; +import { parseEvent } from '../validation.js'; + +export interface KlientEvents { + on( + event: E, + listener: (payload: TPayloadMap[E]) => void, + ): IDisposable; + /** Validation failures and listener exceptions surface here. */ + onError(listener: (error: Error) => void): IDisposable; +} + +type AnyListener = (payload: never) => void; + +interface SharedSub { + readonly reg: EventRegistration; + disposable: IDisposable; + refs: number; +} + +/** Stable identity of a registration's underlying channel subscription. */ +function keyOf(reg: EventRegistration): string { + switch (reg.kind) { + case 'bus': + return 'bus'; + case 'stream': + return `stream:${reg.name}`; + case 'emitter': + return `emitter:${reg.service}:${reg.event}`; + } +} + +function rawTypeOf(raw: unknown): string | undefined { + if (typeof raw !== 'object' || raw === null) return undefined; + const type = (raw as { type?: unknown }).type; + return typeof type === 'string' ? type : undefined; +} + +export class EventHub + implements KlientEvents +{ + private readonly listeners = new Map>(); + private readonly subs = new Map(); + private readonly errorListeners = new Set<(error: Error) => void>(); + private closed = false; + + constructor( + private readonly channel: KlientChannel, + private readonly validate: boolean, + private readonly scope: ScopeRef, + private readonly registrations: Record, + ) {} + + on( + event: E, + listener: (payload: TPayloadMap[E]) => void, + ): IDisposable { + if (this.closed) throw new Error('event hub is closed'); + if (this.registrations[event] === undefined) { + throw new Error(`unknown event: ${event}`); + } + let set = this.listeners.get(event); + if (set === undefined) { + set = new Set(); + this.listeners.set(event, set); + } + const entry = listener as AnyListener; + set.add(entry); + this.acquire(event); + + let disposed = false; + return { + dispose: () => { + if (disposed) return; + disposed = true; + set.delete(entry); + if (set.size === 0) { + this.listeners.delete(event); + } + this.release(event); + }, + }; + } + + onError(listener: (error: Error) => void): IDisposable { + this.errorListeners.add(listener); + return { + dispose: () => { + this.errorListeners.delete(listener); + }, + }; + } + + /** Detach every subscription; the hub may not be reused after. */ + close(): void { + this.closed = true; + for (const sub of this.subs.values()) { + sub.disposable.dispose(); + } + this.subs.clear(); + this.listeners.clear(); + } + + private acquire(event: string): void { + const reg = this.registrations[event]; + if (reg === undefined) return; + const key = keyOf(reg); + let sub = this.subs.get(key); + if (sub === undefined) { + sub = { + reg, + disposable: this.subscribe(key, reg), + refs: 0, + }; + this.subs.set(key, sub); + } + sub.refs += 1; + } + + private release(event: string): void { + const reg = this.registrations[event]; + if (reg === undefined) return; + const key = keyOf(reg); + const sub = this.subs.get(key); + if (sub === undefined) return; + sub.refs -= 1; + if (sub.refs <= 0) { + sub.disposable.dispose(); + this.subs.delete(key); + } + } + + private subscribe(key: string, reg: EventRegistration): IDisposable { + if (reg.kind === 'emitter') { + return this.channel.listen( + this.scope, + { kind: 'emitter', service: reg.service, event: reg.event }, + (data) => { + this.deliver(key, data); + }, + (error) => { + this.reportError(error); + }, + ); + } + const name = reg.kind === 'bus' ? 'events' : reg.name; + return this.channel.listen( + this.scope, + { kind: 'stream', name }, + (data) => { + this.deliver(key, data); + }, + (error) => { + this.reportError(error); + }, + ); + } + + /** Fan one raw payload out to the registrations attached to this source. */ + private deliver(key: string, raw: unknown): void { + for (const [event, reg] of Object.entries(this.registrations)) { + if (keyOf(reg) !== key || !this.listeners.has(event)) continue; + if (reg.kind === 'bus') { + // Global bus events are `{ type, payload }` facts; only registered + // types are forwarded, with the payload unwrapped. + if (rawTypeOf(raw) !== reg.type) continue; + this.deliverValidated(event, reg, (raw as { payload?: unknown }).payload); + continue; + } + if (reg.kind === 'stream' && reg.type !== undefined) { + // Scoped streams (e.g. the agent `events` bus) carry flat + // `{ type, ...fields }` events; forward the whole event. + if (rawTypeOf(raw) !== reg.type) continue; + this.deliverValidated(event, reg, raw); + continue; + } + this.deliverValidated(event, reg, raw); + } + } + + private deliverValidated(event: string, reg: EventRegistration, data: unknown): void { + let payload: unknown = data; + if (this.validate) { + const parsed = parseEvent(event, reg.schema, data); + if (!parsed.ok) { + this.reportError(parsed.error); + return; + } + payload = parsed.data; + } + const set = this.listeners.get(event); + if (set === undefined) return; + for (const listener of set) { + try { + (listener as (payload: unknown) => void)(payload); + } catch (error) { + this.reportError(error instanceof Error ? error : new Error(String(error))); + } + } + } + + private reportError(error: Error): void { + for (const listener of this.errorListeners) { + try { + listener(error); + } catch { + // error listeners must not take the hub down + } + } + } +} diff --git a/packages/klient/src/core/facade/agent.ts b/packages/klient/src/core/facade/agent.ts new file mode 100644 index 0000000000..f3537561b3 --- /dev/null +++ b/packages/klient/src/core/facade/agent.ts @@ -0,0 +1,68 @@ +/** + * The agent facade — one `session.agent(id)` handle over the `agentRPCService` + * channel (the single agent-scope facade service the wire exposes). Prompt + * streaming is NOT on this interface: it flows through the agent's `events` + * hub (`turn.*`, `assistant.delta`, `tool.call.*`, `prompt.completed`, …). + */ + +import type { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import type { ContentPart } from '@moonshot-ai/agent-core-v2/app/llmProtocol/message'; +import type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; + +import type { ScopeRef } from '../channel.js'; +import type { ScopedCaller } from './session.js'; + +// Wire-type aliases derived through the RPC interface (keeps klient free of +// protocol-package imports). +export type PromptLaunchResult = Awaited>; +export type ShellCommandResult = Awaited>; +export type SetModelResult = Awaited>; +export type UsageStatus = Awaited>; +export type AgentContextData = Awaited>; +export type PlanData = Awaited>; +export type AgentTaskInfo = Awaited>[number]; + +export interface AgentFacade { + prompt(input: { input: readonly ContentPart[] }): Promise; + steer(input: { input: readonly ContentPart[] }): Promise; + cancel(input?: { turnId?: number }): Promise; + runShellCommand(input: { command: string; commandId?: string }): Promise; + cancelShellCommand(input: { commandId: string }): Promise; + getModel(): Promise; + setModel(model: string): Promise; + setPermission(mode: PermissionMode): Promise; + getUsage(): Promise; + getContext(): Promise; + getPlan(): Promise; + enterPlan(): Promise; + clearPlan(): Promise; + cancelPlan(input?: { id?: string }): Promise; + getTasks(input?: { activeOnly?: boolean; limit?: number }): Promise; + stopTask(input: { taskId: string; reason?: string }): Promise; + getTaskOutput(input: { taskId: string; tail?: number }): Promise; +} + +export function createAgentFacade(call: ScopedCaller, scope: ScopeRef): AgentFacade { + const rpc = (method: string, payload: unknown): Promise => + call(scope, 'agentRPCService', method, [payload]); + + return { + prompt: (input) => rpc('prompt', input) as Promise, + steer: (input) => rpc('steer', input) as Promise, + cancel: (input) => rpc('cancel', input ?? {}) as Promise, + runShellCommand: (input) => rpc('runShellCommand', input) as Promise, + cancelShellCommand: (input) => rpc('cancelShellCommand', input) as Promise, + getModel: () => rpc('getModel', {}) as Promise, + setModel: (model) => rpc('setModel', { model }) as Promise, + setPermission: (mode) => rpc('setPermission', { mode }) as Promise, + getUsage: () => rpc('getUsage', {}) as Promise, + getContext: () => rpc('getContext', {}) as Promise, + getPlan: () => rpc('getPlan', {}) as Promise, + enterPlan: () => rpc('enterPlan', {}) as Promise, + clearPlan: () => rpc('clearPlan', {}) as Promise, + cancelPlan: (input) => rpc('cancelPlan', input ?? {}) as Promise, + getTasks: (input) => rpc('getTasks', input ?? {}) as Promise, + stopTask: (input) => rpc('stopTask', input) as Promise, + getTaskOutput: (input) => rpc('getTaskOutput', input) as Promise, + }; +} diff --git a/packages/klient/src/core/facade/global.ts b/packages/klient/src/core/facade/global.ts new file mode 100644 index 0000000000..6c29bb0f8a --- /dev/null +++ b/packages/klient/src/core/facade/global.ts @@ -0,0 +1,380 @@ +/** + * The `global` facade — aggregated, single-object-param methods over the + * engine's app-scope services. Each method maps to one underlying service + * call (except `env()`, which fans out and merges); the `Caller` underneath + * applies contract validation and hands the call to the transport. Facade + * code never sees service tokens, scope routing, or transport details. + */ + +import type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import type { SessionMeta } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/queryStore'; +import type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +import type { + ConfigDiagnostic, + ConfigInspectValue, + ConfigTarget, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { ProviderConfig } from '@moonshot-ai/agent-core-v2/app/provider/provider'; +import type { + AuthStatus, + IOAuthService, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { ModelConfig } from '@moonshot-ai/agent-core-v2/app/model/model'; +import type { + IModelCatalogService, +} from '@moonshot-ai/agent-core-v2/app/modelCatalog/modelCatalog'; +import type { + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; + +/** Low-level caller the klient factory builds: routes + validates one service call. */ +export type Caller = (service: string, method: string, args: unknown[]) => Promise; + +/** Scoped variant — the factory's real signature; global methods bind the core scope. */ +export type ScopedCaller = ( + scope: { readonly sessionId?: string; readonly agentId?: string }, + service: string, + method: string, + args: unknown[], +) => Promise; + +// --------------------------------------------------------------------------- +// Wire-type aliases for shapes the engine sources from `@moonshot-ai/protocol` +// (not a direct klient dependency) — derived through the service interfaces. +// --------------------------------------------------------------------------- + +export type RefreshProviderModelsResponse = Awaited< + ReturnType +>; +export type OAuthFlowStart = Awaited>; +export type OAuthFlowSnapshot = NonNullable>>; +export type OAuthLoginCancelResponse = Awaited>; +export type OAuthLogoutResponse = Awaited>; + +export type ModelCatalogItem = Awaited>[number]; +export type ProviderCatalogItem = Awaited< + ReturnType +>[number]; +export type SetDefaultModelResponse = Awaited< + ReturnType +>; +export type RefreshProviderModelsOptions = NonNullable< + Parameters[0] +>; + +/** String-literal form of the engine's `ConfigTarget` enum, so consumers never import the enum value. */ +export type ConfigTargetLiteral = `${ConfigTarget}`; + +// --------------------------------------------------------------------------- +// Facade interfaces +// --------------------------------------------------------------------------- + +export interface GlobalSessionsFacade { + list(query: SessionListQuery): Promise>; + get(id: string): Promise; + countActive(workspaceId: string): Promise; + /** + * Create a session rooted at `workDir` (the workspace is registered + * implicitly), optionally titled. Returns the persisted metadata. No agent + * is created — `session(id).agent('main')` materializes it on first use. + */ + create(input: { + workDir: string; + additionalDirs?: readonly string[]; + title?: string; + }): Promise; +} + +export interface GlobalWorkspacesFacade { + list(): Promise; + get(id: string): Promise; + createOrTouch(input: { root: string; name?: string }): Promise; + update(input: { id: string; patch: WorkspaceUpdate }): Promise; + delete(id: string): Promise; +} + +export interface GlobalConfigFacade { + get(domain: string): Promise; + getAll(): Promise>; + inspect(domain: string): Promise>; + set(input: { domain: string; patch: unknown; target?: ConfigTargetLiteral }): Promise; + replace(input: { + domain: string; + value: unknown; + target?: ConfigTargetLiteral; + }): Promise; + reload(): Promise; + diagnostics(): Promise; +} + +export interface GlobalProvidersFacade { + list(): Promise>>; + get(name: string): Promise; + set(input: { name: string; config: ProviderConfig }): Promise; + delete(name: string): Promise; +} + +export interface GlobalModelsFacade { + list(): Promise>>; + get(id: string): Promise; + set(input: { id: string; config: ModelConfig }): Promise; + delete(id: string): Promise; +} + +export interface GlobalCatalogFacade { + listModels(): Promise; + listProviders(): Promise; + getProvider(providerId: string): Promise; + setDefaultModel(modelId: string): Promise; + refresh(input?: RefreshProviderModelsOptions): Promise; +} + +export interface GlobalAuthFacade { + status(provider?: string): Promise; + summarize(): Promise; + startLogin(provider?: string): Promise; + flow(provider?: string): Promise; + cancelLogin(provider?: string): Promise; + logout(provider?: string): Promise; + /** + * @deprecated Use `catalog.refresh({ scope: 'oauth' })` — the model catalog + * owns provider-model refresh; this alias remains for one release cycle. + */ + refreshProviderModels(): Promise; +} + +export interface GlobalFlagsFacade { + list(): Promise; + enabled(id: string): Promise; + enabledIds(): Promise; + explain(id: string): Promise; + snapshot(): Promise>; +} + +export interface GlobalPluginsFacade { + list(): Promise; + info(id: string): Promise; + install(source: string): Promise; + setEnabled(input: { id: string; enabled: boolean }): Promise; + setMcpServerEnabled(input: { id: string; server: string; enabled: boolean }): Promise; + remove(id: string): Promise; + reload(): Promise; + checkUpdates(): Promise; + listCommands(): Promise; +} + +export interface GlobalHostFsFacade { + browse(absPath?: string): Promise; + home(): Promise; +} + +/** Aggregated host/environment snapshot (`bootstrapService` properties). */ +export interface KlientEnvInfo { + readonly platform: string; + readonly arch: string; + readonly cwd: string; + readonly osHomeDir: string; + readonly homeDir: string; + readonly configPath: string; + readonly clientVersion: string; + readonly sessionsDir: string; + readonly blobsDir: string; + readonly storeDir: string; + readonly cacheDir: string; + readonly logsDir: string; +} + +export interface GlobalFacade { + readonly sessions: GlobalSessionsFacade; + readonly workspaces: GlobalWorkspacesFacade; + readonly config: GlobalConfigFacade; + readonly providers: GlobalProvidersFacade; + readonly models: GlobalModelsFacade; + readonly catalog: GlobalCatalogFacade; + readonly auth: GlobalAuthFacade; + readonly flags: GlobalFlagsFacade; + readonly plugins: GlobalPluginsFacade; + readonly hostFs: GlobalHostFsFacade; + env(): Promise; +} + +// --------------------------------------------------------------------------- +// Implementation — thin reshaping over `Caller`. Casts are safe by +// construction: the contract validates outputs, and type-parity assertions +// tie every contract schema to its engine type. +// --------------------------------------------------------------------------- + +const ENV_PROPERTIES = [ + 'platform', + 'arch', + 'cwd', + 'osHomeDir', + 'homeDir', + 'configPath', + 'clientVersion', + 'sessionsDir', + 'blobsDir', + 'storeDir', + 'cacheDir', + 'logsDir', +] as const; + +export function createGlobalFacade(scoped: ScopedCaller): GlobalFacade { + const call: Caller = (service, method, args) => scoped({}, service, method, args); + // The bootstrap snapshot is frozen at process start, so the aggregated + // env() result can never change — resolve it once and reuse the promise. + let envPromise: Promise | undefined; + const env = (): Promise => { + envPromise ??= Promise.all( + ENV_PROPERTIES.map((prop) => call('bootstrapService', prop, []) as Promise), + ).then( + (values) => + Object.fromEntries( + ENV_PROPERTIES.map((prop, index) => [prop, values[index]]), + ) as unknown as KlientEnvInfo, + ); + return envPromise; + }; + + return { + sessions: { + list: (query) => call('sessionIndex', 'list', [query]) as Promise>, + get: (id) => call('sessionIndex', 'get', [id]) as Promise, + countActive: (workspaceId) => + call('sessionIndex', 'countActive', [workspaceId]) as Promise, + create: async ({ workDir, additionalDirs, title }) => { + const handle = (await scoped({}, 'sessionLifecycleService', 'create', [ + { workDir, additionalDirs }, + ])) as { id: string }; + const scope = { sessionId: handle.id }; + if (title !== undefined) { + await scoped(scope, 'sessionMetadata', 'setTitle', [title]); + } + return scoped(scope, 'sessionMetadata', 'read', []) as Promise; + }, + }, + + workspaces: { + list: () => call('workspaceRegistry', 'list', []) as Promise, + get: (id) => call('workspaceRegistry', 'get', [id]) as Promise, + createOrTouch: ({ root, name }) => + call('workspaceRegistry', 'createOrTouch', [root, name]) as Promise, + update: ({ id, patch }) => + call('workspaceRegistry', 'update', [id, patch]) as Promise, + delete: (id) => call('workspaceRegistry', 'delete', [id]) as Promise, + }, + + config: { + get: (domain: string) => call('configService', 'get', [domain]) as Promise, + getAll: () => call('configService', 'getAll', []) as Promise>, + inspect: (domain: string) => + call('configService', 'inspect', [domain]) as Promise>, + set: ({ domain, patch, target }) => + call('configService', 'set', [domain, patch, target]) as Promise, + replace: ({ domain, value, target }) => + call('configService', 'replace', [domain, value, target]) as Promise, + reload: () => call('configService', 'reload', []) as Promise, + diagnostics: () => + call('configService', 'diagnostics', []) as Promise, + }, + + providers: { + list: () => + call('providerService', 'list', []) as Promise>>, + get: (name) => call('providerService', 'get', [name]) as Promise, + set: ({ name, config }) => call('providerService', 'set', [name, config]) as Promise, + delete: (name) => call('providerService', 'delete', [name]) as Promise, + }, + + models: { + list: () => + call('modelService', 'list', []) as Promise>>, + get: (id) => call('modelService', 'get', [id]) as Promise, + set: ({ id, config }) => call('modelService', 'set', [id, config]) as Promise, + delete: (id) => call('modelService', 'delete', [id]) as Promise, + }, + + catalog: { + listModels: () => + call('modelCatalogService', 'listModels', []) as Promise, + listProviders: () => + call('modelCatalogService', 'listProviders', []) as Promise< + readonly ProviderCatalogItem[] + >, + getProvider: (providerId) => + call('modelCatalogService', 'getProvider', [providerId]) as Promise, + setDefaultModel: (modelId) => + call('modelCatalogService', 'setDefaultModel', [modelId]) as Promise< + SetDefaultModelResponse + >, + refresh: (input) => + call('modelCatalogService', 'refreshProviderModels', [ + input, + ]) as Promise, + }, + + auth: { + status: (provider) => call('oauthService', 'status', [provider]) as Promise, + summarize: () => call('authSummaryService', 'summarize', []) as Promise, + startLogin: (provider) => + call('oauthService', 'startLogin', [provider]) as Promise, + flow: (provider) => + call('oauthService', 'getFlow', [provider]) as Promise, + cancelLogin: (provider) => + call('oauthService', 'cancelLogin', [provider]) as Promise, + logout: (provider) => + call('oauthService', 'logout', [provider]) as Promise, + refreshProviderModels: () => + call('oauthService', 'refreshOAuthProviderModels', []) as Promise, + }, + + flags: { + list: () => call('flagService', 'explainAll', []) as Promise, + enabled: (id) => call('flagService', 'enabled', [id]) as Promise, + enabledIds: () => call('flagService', 'enabledIds', []) as Promise, + explain: (id) => + call('flagService', 'explain', [id]) as Promise, + snapshot: () => call('flagService', 'snapshot', []) as Promise>, + }, + + plugins: { + list: () => call('pluginService', 'listPlugins', []) as Promise, + info: (id) => call('pluginService', 'getPluginInfo', [{ id }]) as Promise, + install: (source) => + call('pluginService', 'installPlugin', [{ source }]) as Promise, + setEnabled: (input) => call('pluginService', 'setPluginEnabled', [input]) as Promise, + setMcpServerEnabled: (input) => + call('pluginService', 'setPluginMcpServerEnabled', [input]) as Promise, + remove: (id) => call('pluginService', 'removePlugin', [{ id }]) as Promise, + reload: () => call('pluginService', 'reloadPlugins', []) as Promise, + checkUpdates: () => + call('pluginService', 'checkUpdates', []) as Promise, + listCommands: () => + call('pluginService', 'listPluginCommands', []) as Promise, + }, + + hostFs: { + browse: (absPath) => + call('hostFolderBrowser', 'browse', [absPath]) as Promise, + home: () => call('hostFolderBrowser', 'home', []) as Promise, + }, + + env, + }; +} diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts new file mode 100644 index 0000000000..cce957aa60 --- /dev/null +++ b/packages/klient/src/core/facade/session.ts @@ -0,0 +1,174 @@ +/** + * The session facade — one `klient.session(id)` handle aggregating the + * session-scope services (metadata, activity, approvals, questions, + * interactions) plus the app-scope lifecycle service for close/archive/ + * restore/fork/createChild. `agents()` reads the metadata registry (agent + * handles are not serializable, so no agent-lifecycle channel exists on the + * wire). + */ + +import type { AgentActivityState } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import type { + AgentMeta, + SessionMeta, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/session/approval/approval'; +import type { + QuestionRequest, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/session/question/question'; +import type { + Interaction, + InteractionKind, +} from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; + +import type { ScopeRef } from '../channel.js'; +import type { ScopedCaller } from './global.js'; + +export type { ScopedCaller } from './global.js'; + +/** What `sessionLifecycleService.create/fork/createChild` leaves on the wire. */ +interface HandleWire { + readonly id: string; +} + +export interface SessionApprovalsFacade { + list(): Promise; + decide(id: string, response: ApprovalResponse): Promise; +} + +export interface SessionQuestionsFacade { + list(): Promise; + answer(id: string, result: QuestionResult): Promise; + dismiss(id: string): Promise; +} + +export interface SessionInteractionsFacade { + list(kind?: InteractionKind): Promise; + respond(id: string, response: unknown): Promise; +} + +/** + * Derived session lifecycle phase. The engine retired its `sessionActivity` + * service (#1751) — busy is now derived from agent activity views — so the + * facade composes the phase from the pending interaction lists and each + * agent's `agentActivityView`, keeping the retired service's precedence. + */ +export type SessionStatus = 'running' | 'idle' | 'awaiting_approval' | 'awaiting_question'; + +export interface SessionFacade { + get(): Promise; + setTitle(title: string): Promise; + update(patch: SessionMetaPatch): Promise; + setArchived(archived: boolean): Promise; + status(): Promise; + close(): Promise; + archive(): Promise; + /** Re-materialize a closed session; `false` when it no longer exists. */ + restore(): Promise; + fork(input?: { title?: string; metadata?: Record }): Promise; + createChild(input?: { + title?: string; + metadata?: Record; + }): Promise; + readonly approvals: SessionApprovalsFacade; + readonly questions: SessionQuestionsFacade; + readonly interactions: SessionInteractionsFacade; + /** Agent id → metadata for every agent registered in this session. */ + agents(): Promise>>; +} + +export function createSessionFacade(call: ScopedCaller, sessionId: string): SessionFacade { + const scope: ScopeRef = { sessionId }; + const read = (): Promise => + call(scope, 'sessionMetadata', 'read', []) as Promise; + const spawn = async ( + method: 'fork' | 'createChild', + input: { title?: string; metadata?: Record } = {}, + ): Promise => { + const handle = (await call({}, 'sessionLifecycleService', method, [ + { sourceSessionId: sessionId, title: input.title, metadata: input.metadata }, + ])) as HandleWire; + return call({ sessionId: handle.id }, 'sessionMetadata', 'read', []) as Promise; + }; + + return { + get: read, + setTitle: (title) => call(scope, 'sessionMetadata', 'setTitle', [title]) as Promise, + update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, + setArchived: (archived) => + call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise, + status: async () => { + const pending = (kind: 'approval' | 'question') => + call(scope, 'sessionInteractionService', 'listPending', [kind]) as Promise< + readonly unknown[] + >; + if ((await pending('approval')).length > 0) return 'awaiting_approval'; + if ((await pending('question')).length > 0) return 'awaiting_question'; + const meta = await read(); + for (const agentId of Object.keys(meta.agents ?? {})) { + try { + const state = (await call( + { sessionId, agentId }, + 'agentActivityView', + 'state', + [], + )) as AgentActivityState; + if (state.turn !== undefined || state.background.length > 0) return 'running'; + } catch { + // Agents stay registered after their live handle is gone; the scope + // probe fails for a dead agent, so treat it as not active — the same + // view the retired service had from iterating live handles only. + } + } + return 'idle'; + }, + close: () => call({}, 'sessionLifecycleService', 'close', [sessionId]) as Promise, + archive: () => call({}, 'sessionLifecycleService', 'archive', [sessionId]) as Promise, + restore: async () => { + const handle = (await call({}, 'sessionLifecycleService', 'restore', [ + sessionId, + ])) as HandleWire | null; + return handle !== null; + }, + fork: (input) => spawn('fork', input), + createChild: (input) => spawn('createChild', input), + + approvals: { + list: () => + call(scope, 'sessionApprovalService', 'listPending', []) as Promise< + readonly ApprovalRequest[] + >, + decide: (id, response) => + call(scope, 'sessionApprovalService', 'decide', [id, response]) as Promise, + }, + + questions: { + list: () => + call(scope, 'sessionQuestionService', 'listPending', []) as Promise< + readonly QuestionRequest[] + >, + answer: (id, result) => + call(scope, 'sessionQuestionService', 'answer', [id, result]) as Promise, + dismiss: (id) => call(scope, 'sessionQuestionService', 'dismiss', [id]) as Promise, + }, + + interactions: { + list: (kind) => + call(scope, 'sessionInteractionService', 'listPending', [kind]) as Promise< + readonly Interaction[] + >, + respond: (id, response) => + call(scope, 'sessionInteractionService', 'respond', [id, response]) as Promise, + }, + + agents: async () => { + const meta = await read(); + return meta.agents ?? {}; + }, + }; +} diff --git a/packages/klient/src/core/klient.ts b/packages/klient/src/core/klient.ts new file mode 100644 index 0000000000..621c6855a1 --- /dev/null +++ b/packages/klient/src/core/klient.ts @@ -0,0 +1,98 @@ +/** + * The transport-agnostic klient factory. Every transport entry point + * (`@moonshot-ai/klient/http|ipc|memory`) builds a `KlientChannel` and hands + * it here; the returned `Klient` is identical in shape and behavior no matter + * which transport carried the bytes. + */ + +import type { KlientChannel, ScopeRef } from './channel.js'; +import { globalContract } from '#/contract/index'; +import { globalEvents, type KlientEventPayloads } from '#/contract/global/events'; +import { sessionEvents, type SessionEventPayloads } from '#/contract/session/events'; +import { agentEvents, type AgentEventPayloads } from '#/contract/agent/events'; +import type { EventRegistration } from '#/contract/types'; +import { EventHub, type KlientEvents } from './events/hub.js'; +import { createGlobalFacade, type GlobalFacade, type ScopedCaller } from './facade/global.js'; +import { createSessionFacade, type SessionFacade } from './facade/session.js'; +import { createAgentFacade, type AgentFacade } from './facade/agent.js'; +import { parseInput, parseOutput } from './validation.js'; + +export interface KlientOptions { + /** + * Validate wire inputs/outputs and event payloads against the contract. + * Default `true`. Disable only on measured hot paths — validation is cheap + * (sub-µs for typical payloads) and is the drift tripwire. + */ + readonly validate?: boolean; +} + +export interface SessionHandle extends SessionFacade { + readonly events: KlientEvents; + agent(agentId: string): AgentHandle; +} + +export interface AgentHandle extends AgentFacade { + readonly events: KlientEvents; +} + +export interface Klient { + readonly global: GlobalFacade; + readonly events: KlientEvents; + session(sessionId: string): SessionHandle; + close(): Promise; +} + +export function createKlientFromChannel( + channel: KlientChannel, + options: KlientOptions = {}, +): Klient { + const validate = options.validate ?? true; + + const call: ScopedCaller = async (scope, service, method, args) => { + const procedure = globalContract[service]?.[method]; + if (procedure === undefined) { + // A facade method without a contract entry is a klient bug, not a wire error. + throw new Error(`no contract registered for ${service}.${method}`); + } + const name = `${service}.${method}`; + const wireArgs = validate ? parseInput(name, procedure, args) : args; + const data = await channel.call(scope, service, method, wireArgs); + return validate ? parseOutput(name, procedure, data) : data; + }; + + const hubs = new Set<{ close(): void }>(); + const makeHub = ( + scope: ScopeRef, + registrations: Record, + ): KlientEvents => { + const hub = new EventHub(channel, validate, scope, registrations); + hubs.add(hub); + return hub; + }; + + return { + global: createGlobalFacade(call), + events: makeHub({}, globalEvents), + session(sessionId: string): SessionHandle { + const scope: ScopeRef = { sessionId }; + return { + ...createSessionFacade(call, sessionId), + events: makeHub(scope, sessionEvents), + agent(agentId: string): AgentHandle { + const agentScope: ScopeRef = { sessionId, agentId }; + return { + ...createAgentFacade(call, agentScope), + events: makeHub(agentScope, agentEvents), + }; + }, + }; + }, + close: () => { + for (const hub of hubs) { + hub.close(); + } + hubs.clear(); + return channel.close(); + }, + }; +} diff --git a/packages/klient/src/core/validation.ts b/packages/klient/src/core/validation.ts new file mode 100644 index 0000000000..3ae01bb7cd --- /dev/null +++ b/packages/klient/src/core/validation.ts @@ -0,0 +1,70 @@ +/** + * zod-backed wire validation. Every procedure in the contract validates its + * input tuple before it goes on the wire (caller bugs) and its output when it + * comes back (server drift). Event payloads are validated on delivery; + * failures there are reported, not thrown, so one bad event cannot kill a + * subscription stream. + */ + +import { z } from 'zod'; + +import type { ProcedureContract } from '#/contract/types'; + +export type ValidationPhase = 'input' | 'output' | 'event'; + +export class KlientValidationError extends Error { + constructor( + readonly phase: ValidationPhase, + /** `service.method` for calls, the klient event name for events. */ + readonly procedure: string, + readonly issues: z.ZodError['issues'], + /** The offending raw payload (input args, output data, or event data). */ + readonly payload: unknown, + ) { + super( + `${phase} validation failed for ${procedure}: ${issues + .map((issue) => `${issue.path.join('.') || '(root)'}: ${issue.message}`) + .join('; ')}`, + ); + this.name = 'KlientValidationError'; + } +} + +/** Parse the positional-args tuple; returns the normalized args to send. */ +export function parseInput( + procedure: string, + contract: ProcedureContract, + args: unknown[], +): unknown[] { + const result = contract.input.safeParse(args); + if (!result.success) { + throw new KlientValidationError('input', procedure, result.error.issues, args); + } + return result.data as unknown[]; +} + +/** Parse a wire result; returns the normalized data to hand to the caller. */ +export function parseOutput( + procedure: string, + contract: ProcedureContract, + data: unknown, +): unknown { + const result = contract.output.safeParse(data); + if (!result.success) { + throw new KlientValidationError('output', procedure, result.error.issues, data); + } + return result.data; +} + +/** Parse an event payload without throwing; `undefined` on failure. */ +export function parseEvent( + event: string, + schema: z.ZodType, + data: unknown, +): { ok: true; data: unknown } | { ok: false; error: KlientValidationError } { + const result = schema.safeParse(data); + if (!result.success) { + return { ok: false, error: new KlientValidationError('event', event, result.error.issues, data) }; + } + return { ok: true, data: result.data }; +} diff --git a/packages/klient/src/httpChannel.ts b/packages/klient/src/httpChannel.ts deleted file mode 100644 index df9cf3e14f..0000000000 --- a/packages/klient/src/httpChannel.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * `fetch`-backed `IChannel` for the `/api/v2` HTTP surface. - * - * Every call `POST`s the `resource:action` command to the scope base URL with - * the single argument as a JSON body, then unwraps the project envelope: a - * non-zero `code` throws `RPCError`, otherwise `data` is returned. The - * server accepts `GET` for readonly actions too, but the client does not need - * it. `fetch` is injectable so tests and non-global runtimes can supply it. - */ - -import type { IChannel } from './channel.js'; -import { RPCError } from './errors.js'; - -interface Envelope { - readonly code: number; - readonly msg: string; - readonly data: T; - readonly request_id: string; - readonly details?: unknown; -} - -export interface HttpChannelOptions { - /** Scope base URL, e.g. `http://127.0.0.1:58627/api/v2[/session/:sid]`. */ - readonly baseUrl: string; - /** Optional bearer token. */ - readonly token?: string; - /** `fetch` implementation; defaults to the global `fetch`. */ - readonly fetch?: typeof fetch; -} - -export class HttpChannel implements IChannel { - private readonly baseUrl: string; - private readonly token?: string; - private readonly fetchImpl: typeof fetch; - - constructor(opts: HttpChannelOptions) { - this.baseUrl = opts.baseUrl.replace(/\/$/, ''); - this.token = opts.token; - // Bind the global fetch: browsers throw "Illegal invocation" when the - // native function is invoked with a non-Window receiver. - this.fetchImpl = opts.fetch ?? fetch.bind(globalThis); - } - - async call(command: string, args: unknown[] = []): Promise { - const headers: Record = {}; - let body: string | undefined; - if (args.length > 0) { - headers['content-type'] = 'application/json'; - body = JSON.stringify(args); - } - if (this.token !== undefined) { - headers['authorization'] = `Bearer ${this.token}`; - } - const res = await this.fetchImpl(`${this.baseUrl}/${command}`, { - method: 'POST', - headers, - body, - }); - const envelope = (await res.json()) as Envelope; - if (envelope.code !== 0) { - throw new RPCError(envelope.code, envelope.msg, envelope.details); - } - return envelope.data; - } - - listen(_event: string, _arg?: unknown): import('./channel.js').Event { - throw new Error('events are not supported over the HTTP channel; use the WS transport'); - } -} diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index 267e84d81d..ff3101461c 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -1,35 +1,128 @@ /** - * `@moonshot-ai/klient` public surface — the `/api/v2` channel, the typed - * service proxy, the scope-routed client, and the explicit service - * implementations. Service interfaces and tokens are imported directly from - * `agent-core-v2` leaf subpaths by consumers. + * `@moonshot-ai/klient` public surface — the transport-agnostic client facade + * over the agent-core-v2 engine. Create a klient with one of the transport + * entry points (`@moonshot-ai/klient/http`, `/ipc`, or `/memory`); everything + * exported here behaves identically regardless of which one carried the + * bytes. */ -export type { IChannel } from './channel.js'; -export { RPCError } from './errors.js'; -export { HttpChannel, type HttpChannelOptions } from './httpChannel.js'; -export { makeProxy } from './proxy.js'; +export type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from './core/channel.js'; +export { RPCError } from './core/errors.js'; +export { KlientValidationError, type ValidationPhase } from './core/validation.js'; export { - AgentClient, - Klient, - SessionClient, + createKlientFromChannel, + type AgentHandle, + type Klient, type KlientOptions, -} from './client.js'; -export { SessionIndexClient } from './services/sessionIndex.js'; -export { - WsSocket, - type WsLike, - type WsLikeCtor, - type WsScopeIds, - type WsScopeKind, - type WsSocketOptions, - type WsSocketState, - type WsSubscription, -} from './wsSocket.js'; -export { WsChannel, type WsChannelOptions } from './wsChannel.js'; -export { - WsAgentClient, - WsKlient, - WsSessionClient, - type WsKlientOptions, -} from './wsKlient.js'; + type SessionHandle, +} from './core/klient.js'; +export type { KlientEvents } from './core/events/hub.js'; +export type { Caller, ScopedCaller } from './core/facade/global.js'; + +export type { + ConfigTargetLiteral, + GlobalAuthFacade, + GlobalCatalogFacade, + GlobalConfigFacade, + GlobalFacade, + GlobalFlagsFacade, + GlobalHostFsFacade, + GlobalModelsFacade, + GlobalPluginsFacade, + GlobalProvidersFacade, + GlobalSessionsFacade, + GlobalWorkspacesFacade, + KlientEnvInfo, + ModelCatalogItem, + OAuthFlowSnapshot, + OAuthFlowStart, + OAuthLoginCancelResponse, + OAuthLogoutResponse, + ProviderCatalogItem, + RefreshProviderModelsOptions, + RefreshProviderModelsResponse, + SetDefaultModelResponse, +} from './core/facade/global.js'; + +export type { + SessionApprovalsFacade, + SessionFacade, + SessionInteractionsFacade, + SessionQuestionsFacade, + SessionStatus, +} from './core/facade/session.js'; +export type { + AgentContextData, + AgentFacade, + AgentTaskInfo, + PlanData, + PromptLaunchResult, + SetModelResult, + ShellCommandResult, + UsageStatus, +} from './core/facade/agent.js'; + +export type { + CatalogChangedPayload, + KlientEventName, + KlientEventPayloads, + SessionArchivedPayload, + SessionMetaUpdatedPayload, +} from './contract/global/events.js'; +export type { SessionEventPayloads } from './contract/session/events.js'; +export type { AgentEventPayloads } from './contract/agent/events.js'; + +// Wire types re-exported for consumer convenience (type-only; the engine is +// not pulled in at runtime for http consumers). +export type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +export type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/queryStore'; +export type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +export type { + ConfigDiagnostic, + ConfigInspectValue, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +export type { ProviderConfig } from '@moonshot-ai/agent-core-v2/app/provider/provider'; +export type { AuthStatus } from '@moonshot-ai/agent-core-v2/app/auth/auth'; +export type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +export type { ModelConfig } from '@moonshot-ai/agent-core-v2/app/model/model'; +export type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +export type { + PluginCommandDef, + PluginInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; +export type { + AgentMeta, + SessionMeta, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +export type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/session/approval/approval'; +export type { + QuestionRequest, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/session/question/question'; +export type { + Interaction, + InteractionKind, +} from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; +export type { ContentPart } from '@moonshot-ai/agent-core-v2/app/llmProtocol/message'; +export type { PermissionMode } from '@moonshot-ai/agent-core-v2/agent/permissionPolicy/types'; diff --git a/packages/klient/src/proxy.ts b/packages/klient/src/proxy.ts deleted file mode 100644 index bf53dba74f..0000000000 --- a/packages/klient/src/proxy.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Typed proxy turning an `IChannel` (bound to one Service) into a value - * satisfying that Service's interface `T`. - * - * Members named `onUpperCase` become channel events; every other property access - * becomes a function forwarding its complete argument array to `channel.call`. - * This is VS Code's `ProxyChannel.toService`: the shared interface `T` is the - * whole contract, with no per-method allowlist or renaming. - */ - -import type { IChannel } from './channel.js'; - -export function makeProxy(channel: IChannel): T { - return new Proxy({} as T, { - get(_target, prop) { - if (typeof prop !== 'string') return undefined; - if (/^on[A-Z]/.test(prop)) return channel.listen(prop); - return (...args: unknown[]) => channel.call(prop, args); - }, - }); -} diff --git a/packages/klient/src/services/sessionIndex.ts b/packages/klient/src/services/sessionIndex.ts deleted file mode 100644 index abb404f1e5..0000000000 --- a/packages/klient/src/services/sessionIndex.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * Explicit, fully-typed `ISessionIndex` implementation over the HTTP channel. - * - * The hand-written counterpart to `makeProxy`: useful when a single interface - * wants a named class, bespoke argument shaping, or a place to hang client-side - * behavior. The injected `IChannel` must be bound to the `sessionIndex` scope - * URL (`/api/v2/sessionIndex`); methods are forwarded by name (`list` / - * `get` / `countActive`). `ISessionIndex` carries only data methods (no `Event` - * / stream / handle members), so it can be implemented faithfully. - */ - -import type { - ISessionIndex, - SessionListQuery, - SessionSummary, -} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import type { Page } from '@moonshot-ai/agent-core-v2/persistence/interface/queryStore'; - -import type { IChannel } from '../channel.js'; - -export class SessionIndexClient implements ISessionIndex { - declare readonly _serviceBrand: undefined; - - constructor(private readonly channel: IChannel) {} - - list(query: SessionListQuery): Promise> { - return this.channel.call>('list', [query]); - } - - get(id: string): Promise { - return this.channel.call('get', [id]); - } - - countActive(workspaceId: string): Promise { - return this.channel.call('countActive', [workspaceId]); - } -} diff --git a/packages/klient/src/transports/http/channel.ts b/packages/klient/src/transports/http/channel.ts new file mode 100644 index 0000000000..8f8f101799 --- /dev/null +++ b/packages/klient/src/transports/http/channel.ts @@ -0,0 +1,106 @@ +/** + * HTTP channel — one `POST /api/v2/[scope/]service/method` per call with the + * args tuple as the JSON body, unwrapping the server envelope. `listen` is + * delegated to a lazily created WS event bridge, so the transport exposes + * the full `KlientChannel` surface despite HTTP being request/response only. + */ + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import type { WsLikeCtor } from '../ws/wsSocket.js'; +import { WsEventBridge } from './eventBridge.js'; + +interface Envelope { + readonly code: number; + readonly msg: string; + readonly data: T; + readonly request_id: string; + readonly details?: unknown; +} + +export interface HttpChannelOptions { + /** Server base URL, e.g. `http://127.0.0.1:58627`. */ + readonly url: string; + readonly token?: string; + /** `fetch` implementation; defaults to the global `fetch`. */ + readonly fetch?: typeof fetch; + /** WebSocket implementation for the lazy event bridge (Node ≥ 21 / browsers have one). */ + readonly WebSocketImpl?: WsLikeCtor; +} + +function scopePath(scope: ScopeRef): string { + let path = ''; + if (scope.sessionId !== undefined) { + path += `/session/${encodeURIComponent(scope.sessionId)}`; + } + if (scope.agentId !== undefined) { + path += `/agent/${encodeURIComponent(scope.agentId)}`; + } + return path; +} + +export class HttpChannel implements KlientChannel { + private readonly baseUrl: string; + private readonly url: string; + private readonly token?: string; + private readonly fetchImpl: typeof fetch; + private readonly WebSocketImpl?: WsLikeCtor; + private eventBridge: WsEventBridge | undefined; + + constructor(options: HttpChannelOptions) { + this.url = options.url; + this.baseUrl = `${options.url.replace(/\/$/, '')}/api/v2`; + this.token = options.token; + // Bind the global fetch: browsers throw "Illegal invocation" when the + // native function is invoked with a non-Window receiver. + this.fetchImpl = options.fetch ?? fetch.bind(globalThis); + this.WebSocketImpl = options.WebSocketImpl; + } + + async call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { + const headers: Record = {}; + let body: string | undefined; + if (args.length > 0) { + headers['content-type'] = 'application/json'; + body = JSON.stringify(args); + } + if (this.token !== undefined) { + headers['authorization'] = `Bearer ${this.token}`; + } + const res = await this.fetchImpl(`${this.baseUrl}${scopePath(scope)}/${service}/${method}`, { + method: 'POST', + headers, + body, + }); + const envelope = (await res.json()) as Envelope; + if (envelope.code !== 0) { + throw new RPCError(envelope.code, envelope.msg, envelope.details); + } + return envelope.data; + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + this.eventBridge ??= new WsEventBridge({ + url: this.url, + token: this.token, + WebSocketImpl: this.WebSocketImpl, + }); + return this.eventBridge.listen(scope, source, handler, onError); + } + + close(): Promise { + this.eventBridge?.close(); + this.eventBridge = undefined; + return Promise.resolve(); + } +} diff --git a/packages/klient/src/transports/http/eventBridge.ts b/packages/klient/src/transports/http/eventBridge.ts new file mode 100644 index 0000000000..de23e25437 --- /dev/null +++ b/packages/klient/src/transports/http/eventBridge.ts @@ -0,0 +1,63 @@ +/** + * Event bridge for the HTTP transport — HTTP has no push channel, so the + * first event subscription lazily opens one `/api/v2/ws` socket that carries + * every subscription from then on. The facade never sees this: events arrive + * exactly as they do over the ipc/memory transports. The socket is created + * on first use and torn down by `close()`. + */ + +import type { EventSourceRef, IDisposable, ScopeRef } from '../../core/channel.js'; +import { + WsSocket, + type WsLikeCtor, + type WsScopeIds, + type WsScopeKind, +} from '../ws/wsSocket.js'; + +export interface WsEventBridgeOptions { + /** Server base URL (`http(s)://…`); the ws URL is derived from it. */ + readonly url: string; + readonly token?: string; + readonly WebSocketImpl?: WsLikeCtor; +} + +function scopeKindOf(scope: ScopeRef): WsScopeKind { + if (scope.agentId !== undefined) return 'agent'; + if (scope.sessionId !== undefined) return 'session'; + return 'core'; +} + +function scopeIdsOf(scope: ScopeRef): WsScopeIds { + return { sessionId: scope.sessionId, agentId: scope.agentId }; +} + +export class WsEventBridge { + private readonly socket: WsSocket; + + constructor(options: WsEventBridgeOptions) { + this.socket = new WsSocket({ + url: options.url, + token: options.token, + WebSocketImpl: options.WebSocketImpl, + }); + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + const kind = scopeKindOf(scope); + const ids = scopeIdsOf(scope); + if (source.kind === 'stream') { + // kap-server's eventMap binds service-less listens per scope. + return this.socket.listen(kind, source.name, ids, handler, undefined, onError); + } + return this.socket.listen(kind, source.event, ids, handler, source.service, onError); + } + + close(): void { + this.socket.close(); + } +} diff --git a/packages/klient/src/transports/http/index.ts b/packages/klient/src/transports/http/index.ts new file mode 100644 index 0000000000..92203a219b --- /dev/null +++ b/packages/klient/src/transports/http/index.ts @@ -0,0 +1,15 @@ +/** + * `createKlient` over HTTP(S) against kap-server's `/api/v2` surface. Event + * subscriptions transparently ride a lazily opened WebSocket — after + * initialization the klient behaves exactly like its ipc/memory siblings. + * Browser-safe: only `fetch` + an injectable `WebSocket` are required. + */ + +import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; +import { HttpChannel, type HttpChannelOptions } from './channel.js'; + +export interface HttpKlientOptions extends KlientOptions, HttpChannelOptions {} + +export function createKlient(options: HttpKlientOptions): Klient { + return createKlientFromChannel(new HttpChannel(options), options); +} diff --git a/packages/klient/src/transports/ipc/channel.ts b/packages/klient/src/transports/ipc/channel.ts new file mode 100644 index 0000000000..f5ec38024b --- /dev/null +++ b/packages/klient/src/transports/ipc/channel.ts @@ -0,0 +1,227 @@ +/** + * IPC client channel — connects to a `serveKlientIpc` host over a unix + * domain socket. Calls are correlated by client-chosen ids with a per-call + * deadline; event subscriptions are registered before the handshake + * completes and flushed once it does. There is no automatic reconnect: a + * broken socket rejects in-flight calls and stays closed (the WS transport + * owns the resumable-connection story). + */ + +import { createConnection, type Socket } from 'node:net'; + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { encodeFrame, NdjsonDecoder, type IpcFrame } from './codec.js'; + +const DEFAULT_CALL_TIMEOUT_MS = 30_000; + +export interface IpcChannelOptions { + readonly socketPath: string; + readonly token?: string; + /** Per-call deadline (ms). Default `30000`; `0` disables. */ + readonly callTimeoutMs?: number; +} + +interface PendingCall { + readonly resolve: (data: unknown) => void; + readonly reject: (err: Error) => void; + readonly timer: ReturnType | undefined; +} + +function scopeKindOf(scope: ScopeRef): 'core' | 'session' | 'agent' { + if (scope.agentId !== undefined) return 'agent'; + if (scope.sessionId !== undefined) return 'session'; + return 'core'; +} + +export class IpcChannel implements KlientChannel { + private readonly socket: Socket; + private readonly decoder = new NdjsonDecoder(); + private readonly callTimeoutMs: number; + private readonly pending = new Map(); + private readonly listens = new Map< + string, + { handler: (data: unknown) => void; onError?: (error: Error) => void } + >(); + private readonly ready: Promise; + private closed = false; + private seq = 0; + private readonly idPrefix = `i${Date.now().toString(36)}`; + + constructor(options: IpcChannelOptions) { + this.callTimeoutMs = options.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; + this.socket = createConnection(options.socketPath); + this.ready = new Promise((resolve, reject) => { + const onError = (error: Error): void => { + reject(error); + }; + this.socket.once('error', onError); + this.socket.once('connect', () => { + // The host sends `ready` immediately; answer with the handshake. + this.send({ type: 'hello', token: options.token }); + this.socket.off('error', onError); + resolve(); + }); + }); + // The promise is consumed lazily by call/listen; never let it reject unhandled. + this.ready.catch(() => {}); + + this.socket.on('data', (chunk) => { + for (const frame of this.decoder.push(chunk.toString('utf8'))) { + this.onFrame(frame); + } + }); + this.socket.on('close', () => { + this.closed = true; + this.failAll(new Error('ipc closed')); + this.listens.clear(); + }); + this.socket.on('error', () => { + // 'close' always follows; teardown lives there. + }); + } + + async call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { + await this.ready; + if (this.closed) throw new Error('ipc closed'); + const id = this.nextId(); + const promise = new Promise((resolve, reject) => { + const timer = + this.callTimeoutMs > 0 + ? setTimeout(() => { + this.pending.delete(id); + reject(new RPCError(50001, `call timed out after ${this.callTimeoutMs}ms`)); + }, this.callTimeoutMs) + : undefined; + this.pending.set(id, { resolve, reject, timer }); + }); + this.send({ + type: 'call', + id, + scope: scopeKindOf(scope), + service, + method, + arg: args, + sessionId: scope.sessionId, + agentId: scope.agentId, + }); + return promise; + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + const id = this.nextId(); + this.listens.set(id, { handler, onError }); + const base = { + type: 'listen', + id, + scope: scopeKindOf(scope), + sessionId: scope.sessionId, + agentId: scope.agentId, + }; + const frame: IpcFrame = + source.kind === 'stream' + ? { ...base, event: source.name } + : { ...base, service: source.service, event: source.event }; + void this.ready.then(() => { + this.send(frame); + }); + return { + dispose: () => { + if (!this.listens.delete(id)) return; + void this.ready.then(() => { + this.send({ type: 'unlisten', id }); + }); + }, + }; + } + + close(): Promise { + if (this.closed) return Promise.resolve(); + this.closed = true; + this.failAll(new Error('ipc closed')); + this.listens.clear(); + this.socket.end(); + return Promise.resolve(); + } + + // ------------------------------------------------------------------------- + + private nextId(): string { + this.seq += 1; + return `${this.idPrefix}_${this.seq}`; + } + + private onFrame(frame: IpcFrame): void { + const id = typeof frame.id === 'string' ? frame.id : ''; + switch (frame.type) { + case 'ready': + return; + case 'result': { + const p = this.take(id); + p?.resolve(frame.data); + return; + } + case 'error': { + const error = new RPCError( + typeof frame.code === 'number' ? frame.code : 50001, + frame.msg ?? 'error', + ); + const p = this.take(id); + if (p !== undefined) { + p.reject(error); + return; + } + const sub = this.listens.get(id); + if (sub !== undefined) { + this.listens.delete(id); + sub.onError?.(error); + } + return; + } + case 'listen_result': + return; + case 'event': { + this.listens.get(id)?.handler(frame.data); + return; + } + default: + return; + } + } + + private take(id: string): PendingCall | undefined { + const p = this.pending.get(id); + if (p !== undefined) { + this.pending.delete(id); + if (p.timer !== undefined) clearTimeout(p.timer); + } + return p; + } + + private failAll(err: Error): void { + for (const p of this.pending.values()) { + if (p.timer !== undefined) clearTimeout(p.timer); + p.reject(err); + } + this.pending.clear(); + } + + private send(frame: IpcFrame): void { + if (this.closed || this.socket.destroyed) return; + try { + this.socket.write(encodeFrame(frame)); + } catch { + // best-effort; the close handler handles teardown + } + } +} diff --git a/packages/klient/src/transports/ipc/codec.ts b/packages/klient/src/transports/ipc/codec.ts new file mode 100644 index 0000000000..5f905cd1c2 --- /dev/null +++ b/packages/klient/src/transports/ipc/codec.ts @@ -0,0 +1,49 @@ +/** + * IPC wire framing — newline-delimited JSON over a `node:net` stream. The + * frame shapes deliberately mirror the `/api/v2/ws` protocol + * (`hello`/`call`/`listen`/`unlisten` ↔ `ready`/`result`/`error`/ + * `listen_result`/`event`) so the two socket transports stay interchangeable; + * only the byte pipe differs. + */ + +/** One NDJSON message. `type` discriminates; other fields depend on it. */ +export interface IpcFrame { + readonly type: string; + readonly id?: string; + readonly scope?: string; + readonly service?: string; + readonly method?: string; + readonly arg?: unknown; + readonly sessionId?: string; + readonly agentId?: string; + readonly event?: string; + readonly token?: string; + readonly code?: number; + readonly msg?: string; + readonly data?: unknown; +} + +export function encodeFrame(frame: IpcFrame): string { + return `${JSON.stringify(frame)}\n`; +} + +/** Incremental NDJSON decoder; malformed lines are dropped, mirroring the WS side. */ +export class NdjsonDecoder { + private buffer = ''; + + push(chunk: string): IpcFrame[] { + this.buffer += chunk; + const lines = this.buffer.split('\n'); + this.buffer = lines.pop() ?? ''; + const frames: IpcFrame[] = []; + for (const line of lines) { + if (line.length === 0) continue; + try { + frames.push(JSON.parse(line) as IpcFrame); + } catch { + // drop malformed frames + } + } + return frames; + } +} diff --git a/packages/klient/src/transports/ipc/host.ts b/packages/klient/src/transports/ipc/host.ts new file mode 100644 index 0000000000..f4d67d89c3 --- /dev/null +++ b/packages/klient/src/transports/ipc/host.ts @@ -0,0 +1,189 @@ +/** + * IPC host — serves one engine scope over a unix domain socket. Incoming + * frames are bridged to the shared in-process dispatcher (the same code the + * memory transport uses), so ipc and in-memory behavior are identical by + * construction; only serialization separates them. + */ + +import { createServer, type Server, type Socket } from 'node:net'; +import { unlink } from 'node:fs/promises'; + +import type { EventSourceRef, IDisposable, ScopeRef } from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { createMemoryDispatcher, type ScopeLike } from '../memory/dispatcher.js'; +import { encodeFrame, NdjsonDecoder, type IpcFrame } from './codec.js'; + +const REQUEST_INVALID = 40001; +const UNAUTHORIZED = 40100; + +export interface ServeKlientIpcOptions { + /** A bootstrapped engine app scope (same value `createKlient({ scope })` takes). */ + readonly scope: ScopeLike; + /** Unix socket path to listen on. A stale file at the path is removed first. */ + readonly socketPath: string; + /** Optional token; when set, the client's `hello` must carry the same token. */ + readonly token?: string; +} + +export interface KlientIpcHost { + readonly socketPath: string; + close(): Promise; +} + +function scopeRefFromFrame(frame: IpcFrame): ScopeRef { + const scope: { sessionId?: string; agentId?: string } = {}; + if (typeof frame.sessionId === 'string') scope.sessionId = frame.sessionId; + if (typeof frame.agentId === 'string') scope.agentId = frame.agentId; + return scope; +} + +function eventSourceFromFrame(frame: IpcFrame): EventSourceRef { + if (typeof frame.service === 'string' && typeof frame.event === 'string') { + return { kind: 'emitter', service: frame.service, event: frame.event }; + } + if (typeof frame.event === 'string' && frame.event.length > 0) { + return { kind: 'stream', name: frame.event }; + } + throw new RPCError(REQUEST_INVALID, `unknown event stream: ${String(frame.event)}`); +} + +export async function serveKlientIpc(options: ServeKlientIpcOptions): Promise { + const dispatcher = createMemoryDispatcher(options.scope); + + // Best-effort cleanup of a stale socket file; ignore everything but a real + // leftover (ENOENT = nothing to remove). + try { + await unlink(options.socketPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + } + + const connections = new Set(); + + const server: Server = createServer((socket) => { + connections.add(socket); + const decoder = new NdjsonDecoder(); + const listens = new Map(); + let helloDone = false; + + const send = (frame: IpcFrame): void => { + if (!socket.destroyed) socket.write(encodeFrame(frame)); + }; + const sendError = (id: string, error: unknown): void => { + if (error instanceof RPCError) { + send({ type: 'error', id, code: error.code, msg: error.message }); + } else { + send({ + type: 'error', + id, + code: 50001, + msg: error instanceof Error ? error.message : String(error), + }); + } + }; + + const handleFrame = (frame: IpcFrame): void => { + const id = typeof frame.id === 'string' ? frame.id : ''; + switch (frame.type) { + case 'hello': { + if (options.token !== undefined && frame.token !== options.token) { + send({ type: 'error', id: 'hello', code: UNAUTHORIZED, msg: 'unauthorized' }); + socket.end(); + return; + } + helloDone = true; + return; + } + case 'call': { + if (!helloDone) { + sendError(id, new RPCError(REQUEST_INVALID, 'expected hello first')); + return; + } + const args = Array.isArray(frame.arg) ? frame.arg : frame.arg === undefined ? [] : [frame.arg]; + dispatcher + .call(scopeRefFromFrame(frame), String(frame.service), String(frame.method), args) + .then((data) => { + send({ type: 'result', id, data }); + }) + .catch((error: unknown) => { + sendError(id, error); + }); + return; + } + case 'listen': { + if (!helloDone) { + sendError(id, new RPCError(REQUEST_INVALID, 'expected hello first')); + return; + } + try { + const source = eventSourceFromFrame(frame); + const sub = dispatcher.listen( + scopeRefFromFrame(frame), + source, + (data) => { + send({ type: 'event', id, data }); + }, + (error) => { + sendError(id, error); + }, + ); + listens.set(id, sub); + send({ type: 'listen_result', id }); + } catch (error) { + sendError(id, error); + } + return; + } + case 'unlisten': { + listens.get(id)?.dispose(); + listens.delete(id); + return; + } + default: + return; + } + }; + + socket.on('data', (chunk) => { + for (const frame of decoder.push(chunk.toString('utf8'))) { + handleFrame(frame); + } + }); + const teardown = (): void => { + for (const sub of listens.values()) sub.dispose(); + listens.clear(); + connections.delete(socket); + }; + socket.on('close', teardown); + socket.on('error', teardown); + + send({ type: 'ready' }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(options.socketPath, resolve); + }); + + return { + socketPath: options.socketPath, + close: () => { + for (const socket of connections) { + socket.destroy(); + } + connections.clear(); + return new Promise((resolve) => { + server.close(() => { + void unlink(options.socketPath).then( + () => { + resolve(); + }, + () => { + resolve(); + }, + ); + }); + }); + }, + }; +} diff --git a/packages/klient/src/transports/ipc/index.ts b/packages/klient/src/transports/ipc/index.ts new file mode 100644 index 0000000000..58fe1e7243 --- /dev/null +++ b/packages/klient/src/transports/ipc/index.ts @@ -0,0 +1,19 @@ +/** + * `createKlient` over a unix domain socket, plus the matching host + * (`serveKlientIpc`) for processes embedding the engine. Node-only. + */ + +import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; +import { IpcChannel, type IpcChannelOptions } from './channel.js'; + +export { + serveKlientIpc, + type KlientIpcHost, + type ServeKlientIpcOptions, +} from './host.js'; + +export interface IpcKlientOptions extends KlientOptions, IpcChannelOptions {} + +export function createKlient(options: IpcKlientOptions): Klient { + return createKlientFromChannel(new IpcChannel(options), options); +} diff --git a/packages/klient/src/transports/memory/dispatcher.ts b/packages/klient/src/transports/memory/dispatcher.ts new file mode 100644 index 0000000000..1cb04c5f50 --- /dev/null +++ b/packages/klient/src/transports/memory/dispatcher.ts @@ -0,0 +1,186 @@ +/** + * In-process dispatcher — resolves a wire triple `(service, method, args)` + * against a live engine scope and mirrors kap-server's dispatcher semantics + * (reflection call, non-function members are property reads, `main` agent + * auto-materialized via `ensureMainAgent`). Scope routing walks + * `ISessionLifecycleService` / `IAgentLifecycleService` exactly like the + * server's `resolveScope`. Every argument, result, and event payload passes + * through `wireClone` (a JSON round-trip), so consumers observe + * byte-identical data no matter whether the call crossed a socket or stayed + * in-process — and non-serializable leaks fail early. + * + * Shared by the memory transport and the IPC host, which guarantees ipc and + * memory behave identically by construction. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { IAgentLifecycleService } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/agentLifecycle'; +import { ensureMainAgent } from '@moonshot-ai/agent-core-v2/session/agentLifecycle/mainAgent'; +import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; +import { IEventBus } from '@moonshot-ai/agent-core-v2/app/event/eventBus'; + +import type { EventSourceRef, IDisposable, ScopeRef } from '../../core/channel.js'; +import { RPCError } from '../../core/errors.js'; +import { IEventService, serviceTokens } from './serviceRegistry.js'; + +/** Structural minimum of an engine `Scope` / `IScopeHandle`. */ +export interface ScopeLike { + readonly accessor: { + get(id: ServiceIdentifier): T; + }; +} + +/** JSON round-trip so in-process data matches wire data exactly. */ +export function wireClone(value: T): T { + if (value === undefined) return value; + return JSON.parse(JSON.stringify(value)) as T; +} + +export interface MemoryDispatcher { + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise; + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable; +} + +const REQUEST_INVALID = 40001; +const NOT_FOUND = 40404; + +type ScopeKind = 'core' | 'session' | 'agent'; + +interface ResolvedScope { + readonly kind: ScopeKind; + readonly like: ScopeLike; +} + +export function createMemoryDispatcher(root: ScopeLike): MemoryDispatcher { + /** Mirrors kap-server's `resolveScope`, incl. main-agent materialization. */ + async function resolveScope(scope: ScopeRef): Promise { + if (scope.sessionId === undefined) return { kind: 'core', like: root }; + const session = root.accessor.get(ISessionLifecycleService).get(scope.sessionId); + if (session === undefined) { + throw new RPCError(NOT_FOUND, `session not found: ${scope.sessionId}`); + } + if (scope.agentId === undefined) return { kind: 'session', like: session }; + if (scope.agentId === 'main') { + return { kind: 'agent', like: await ensureMainAgent(session) }; + } + const agent = session.accessor.get(IAgentLifecycleService).get(scope.agentId); + if (agent === undefined) { + throw new RPCError(NOT_FOUND, `agent not found: ${scope.agentId}`); + } + return { kind: 'agent', like: agent }; + } + + function resolveService(resolved: ResolvedScope, service: string): Record { + const token = serviceTokens[service]; + if (token === undefined) { + throw new RPCError(REQUEST_INVALID, `unknown service: ${service}`); + } + return resolved.like.accessor.get(token) as Record; + } + + /** Mirrors kap-server's WS `eventMap` per scope kind. */ + function subscribeStream( + resolved: ResolvedScope, + name: string, + handler: (data: unknown) => void, + ): IDisposable { + if (resolved.kind === 'core' && name === 'events') { + const bus = resolved.like.accessor.get(IEventService); + return bus.subscribe((event) => { + handler(wireClone(event)); + }); + } + if (resolved.kind === 'session' && name === 'interactions') { + const interaction = resolved.like.accessor.get(ISessionInteractionService); + return interaction.onDidChangePending(() => { + handler(wireClone(interaction.listPending())); + }); + } + if (resolved.kind === 'session' && name === 'interactions:resolved') { + const interaction = resolved.like.accessor.get(ISessionInteractionService); + return interaction.onDidResolve((resolution) => { + handler(wireClone(resolution)); + }); + } + if (resolved.kind === 'agent' && name === 'events') { + const bus = resolved.like.accessor.get(IEventBus); + return bus.subscribe((event) => { + handler(wireClone(event)); + }); + } + throw new RPCError(REQUEST_INVALID, `unknown event stream: ${name} (${resolved.kind})`); + } + + function subscribeSource( + resolved: ResolvedScope, + source: EventSourceRef, + handler: (data: unknown) => void, + ): IDisposable { + if (source.kind === 'stream') { + return subscribeStream(resolved, source.name, handler); + } + if (!/^on[A-Z]/.test(source.event)) { + throw new RPCError(REQUEST_INVALID, `not an event property: ${source.event}`); + } + const instance = resolveService(resolved, source.service); + const emitter = instance[source.event]; + if (typeof emitter !== 'function') { + throw new RPCError(REQUEST_INVALID, `event not found: ${source.service}.${source.event}`); + } + return (emitter as (listener: (data: unknown) => void) => IDisposable).call( + instance, + (data) => { + handler(wireClone(data)); + }, + ); + } + + return { + async call(scope, service, method, args) { + const resolved = await resolveScope(scope); + const instance = resolveService(resolved, service); + const member = instance[method]; + if (member === undefined) { + throw new RPCError(REQUEST_INVALID, `method not found: ${service}.${method}`); + } + if (typeof member !== 'function') { + return wireClone(member); + } + const clonedArgs = args.map(wireClone); + const result = await (member as (...a: unknown[]) => unknown).apply(instance, clonedArgs); + return wireClone(result); + }, + + listen(scope, source, handler, onError) { + // Scope resolution can be async (main-agent materialization); the + // subscription attaches once settled. Disposing early cancels it. + let inner: IDisposable | undefined; + let disposed = false; + void resolveScope(scope).then( + (resolved) => { + if (disposed) return; + try { + inner = subscribeSource(resolved, source, handler); + } catch (error) { + onError?.(error instanceof Error ? error : new Error(String(error))); + } + }, + (error: unknown) => { + onError?.(error instanceof Error ? error : new Error(String(error))); + }, + ); + return { + dispose: () => { + disposed = true; + inner?.dispose(); + }, + }; + }, + }; +} diff --git a/packages/klient/src/transports/memory/index.ts b/packages/klient/src/transports/memory/index.ts new file mode 100644 index 0000000000..f5f6eb3bf8 --- /dev/null +++ b/packages/klient/src/transports/memory/index.ts @@ -0,0 +1,57 @@ +/** + * `createKlient` over an in-process engine scope — the host bootstraps the + * engine (`bootstrap()` from agent-core-v2) and passes the app scope (or its + * handle) in. Calls and events never leave the process, but everything the + * facade returns has crossed the same JSON round-trip as the networked + * transports, so behavior is indistinguishable. + */ + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../../core/channel.js'; +import { createKlientFromChannel, type Klient, type KlientOptions } from '../../core/klient.js'; +import { createMemoryDispatcher, type ScopeLike } from './dispatcher.js'; + +export type { ScopeLike } from './dispatcher.js'; + +export interface MemoryKlientOptions extends KlientOptions { + /** + * A bootstrapped engine app scope (`bootstrap(...).app` or an + * `IAppScopeHandle`). The klient does NOT own its lifecycle — `close()` + * leaves the scope alone. + */ + readonly scope: ScopeLike; +} + +class MemoryChannel implements KlientChannel { + private readonly dispatcher; + + constructor(scope: ScopeLike) { + this.dispatcher = createMemoryDispatcher(scope); + } + + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { + return this.dispatcher.call(scope, service, method, args); + } + + listen( + scope: ScopeRef, + source: EventSourceRef, + handler: (data: unknown) => void, + onError?: (error: Error) => void, + ): IDisposable { + return this.dispatcher.listen(scope, source, handler, onError); + } + + close(): Promise { + // The scope belongs to the host; nothing transport-side to release. + return Promise.resolve(); + } +} + +export function createKlient(options: MemoryKlientOptions): Klient { + return createKlientFromChannel(new MemoryChannel(options.scope), options); +} diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts new file mode 100644 index 0000000000..4191cc6f83 --- /dev/null +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -0,0 +1,55 @@ +/** + * Service name → DI token registry for the in-process dispatcher. Only leaf + * modules are imported (tokens + types) — never the engine root barrel, so + * hosting klient in-process does not force the full registration side effects + * beyond what the host already bootstrapped. + */ + +import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; +import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import { IWorkspaceRegistry } from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +import { IConfigService } from '@moonshot-ai/agent-core-v2/app/config/config'; +import { IModelService } from '@moonshot-ai/agent-core-v2/app/model/model'; +import { IModelCatalogService } from '@moonshot-ai/agent-core-v2/app/modelCatalog/modelCatalog'; +import { IProviderService } from '@moonshot-ai/agent-core-v2/app/provider/provider'; +import { + IAuthSummaryService, + IOAuthService, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import { IFlagService } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import { IPluginService } from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; +import { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; +import { IEventService } from '@moonshot-ai/agent-core-v2/app/event/event'; +import { IHostFolderBrowser } from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import { ISessionLifecycleService } from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import { ISessionInteractionService } from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; +import { ISessionApprovalService } from '@moonshot-ai/agent-core-v2/session/approval/approval'; +import { ISessionQuestionService } from '@moonshot-ai/agent-core-v2/session/question/question'; +import { IAgentRPCService } from '@moonshot-ai/agent-core-v2/agent/rpc/rpc'; +import { IAgentActivityView } from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; + +/** Wire service name (decorator id string) → token. */ +export const serviceTokens: Readonly>> = { + sessionIndex: ISessionIndex, + workspaceRegistry: IWorkspaceRegistry, + configService: IConfigService, + modelService: IModelService, + modelCatalogService: IModelCatalogService, + providerService: IProviderService, + oauthService: IOAuthService, + authSummaryService: IAuthSummaryService, + flagService: IFlagService, + pluginService: IPluginService, + hostFolderBrowser: IHostFolderBrowser, + bootstrapService: IBootstrapService, + sessionLifecycleService: ISessionLifecycleService, + sessionMetadata: ISessionMetadata, + sessionInteractionService: ISessionInteractionService, + sessionApprovalService: ISessionApprovalService, + sessionQuestionService: ISessionQuestionService, + agentRPCService: IAgentRPCService, + agentActivityView: IAgentActivityView, +}; + +export { IEventService }; diff --git a/packages/klient/src/wsSocket.ts b/packages/klient/src/transports/ws/wsSocket.ts similarity index 99% rename from packages/klient/src/wsSocket.ts rename to packages/klient/src/transports/ws/wsSocket.ts index 5d6da792b1..d345410dc4 100644 --- a/packages/klient/src/wsSocket.ts +++ b/packages/klient/src/transports/ws/wsSocket.ts @@ -15,7 +15,7 @@ * implementation can be injected for tests. */ -import { RPCError } from './errors.js'; +import { RPCError } from '../../core/errors.js'; /** Wire scope kinds, mirroring kap-server's `ScopeKind`. */ export type WsScopeKind = 'core' | 'session' | 'agent'; diff --git a/packages/klient/src/wsChannel.ts b/packages/klient/src/wsChannel.ts deleted file mode 100644 index edc1853813..0000000000 --- a/packages/klient/src/wsChannel.ts +++ /dev/null @@ -1,79 +0,0 @@ -/** - * `WsChannel` — an `IChannel` bound to one Service that forwards `call`s over - * the shared `/api/v2/ws` socket instead of HTTP. Same VS Code shape as - * `HttpChannel` (the URL equivalent is the `{scope, service, ids}` triple the - * socket puts on each frame), so the same `makeProxy` turns it into a typed - * Service client. `listen` here takes a handler and returns a subscription - * that survives reconnects until disposed. - */ - -import type { Event, IChannel } from './channel.js'; -import type { WsScopeIds, WsScopeKind, WsSocket } from './wsSocket.js'; - -export interface WsChannelOptions { - readonly socket: WsSocket; - readonly scope: WsScopeKind; - /** Service channel name (the decorator id, `String(id)`). */ - readonly service: string; - readonly sessionId?: string; - readonly agentId?: string; -} - -interface SharedEvent { - readonly listeners: Set<{ listener: (data: unknown) => unknown; thisArg: unknown }>; - remote?: { dispose(): void }; -} - -export class WsChannel implements IChannel { - private readonly socket: WsSocket; - private readonly scope: WsScopeKind; - private readonly service: string; - private readonly ids: WsScopeIds; - private readonly events = new Map(); - - constructor(opts: WsChannelOptions) { - this.socket = opts.socket; - this.scope = opts.scope; - this.service = opts.service; - this.ids = { sessionId: opts.sessionId, agentId: opts.agentId }; - } - - call(command: string, args: unknown[] = []): Promise { - return this.socket.call(this.scope, this.service, command, args, this.ids); - } - - listen(event: string): Event { - let shared = this.events.get(event); - if (shared === undefined) { - shared = { listeners: new Set() }; - this.events.set(event, shared); - } - return (listener, thisArg, disposables) => { - const entry = { listener: listener as (data: unknown) => unknown, thisArg }; - shared.listeners.add(entry); - shared.remote ??= this.socket.listen( - this.scope, - event, - this.ids, - (data) => { - for (const current of shared.listeners) current.listener.call(current.thisArg, data); - }, - this.service, - ); - let disposed = false; - const subscription = { - dispose: (): void => { - if (disposed) return; - disposed = true; - shared.listeners.delete(entry); - if (shared.listeners.size === 0) { - shared.remote?.dispose(); - shared.remote = undefined; - } - }, - }; - disposables?.push(subscription); - return subscription; - }; - } -} diff --git a/packages/klient/src/wsKlient.ts b/packages/klient/src/wsKlient.ts deleted file mode 100644 index 89eba28628..0000000000 --- a/packages/klient/src/wsKlient.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * `WsKlient` — the `/api/v2` scope-entry client over the WebSocket transport. - * - * Mirrors `Klient`'s three-level scope entry (`core` / `session` / `agent`), - * but every Service call rides the shared `WsSocket`, and each scope level - * also exposes `listen(event, handler)` for the server's event streams - * (`core` → `events`; `session` → `interactions` / `interactions:resolved`; - * `agent` → `events`): - * - * const ws = new WsKlient({ url: 'http://127.0.0.1:58627', token }); - * await ws.core(ISessionIndex).list({}); - * const sub = ws.session('s1').agent('main').listen('events', (e) => ...); - * sub.dispose(); ws.close(); - * - * Prefer `Klient#ws()` over constructing this directly so HTTP and WS share - * one configured endpoint. - */ - -import type { ServiceIdentifier } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; - -import { makeProxy } from './proxy.js'; -import { WsChannel } from './wsChannel.js'; -import { - WsSocket, - type WsSocketOptions, - type WsSocketState, - type WsSubscription, -} from './wsSocket.js'; - -export type WsKlientOptions = WsSocketOptions; - -export class WsKlient { - private readonly socket: WsSocket; - - constructor(opts: WsKlientOptions) { - this.socket = new WsSocket(opts); - } - - /** Core-scoped Service over WS, e.g. `ws.core(ISessionIndex)`. */ - core(id: ServiceIdentifier): T { - return makeProxy(new WsChannel({ socket: this.socket, scope: 'core', service: String(id) })); - } - - /** Session scope entry point. */ - session(sessionId: string): WsSessionClient { - return new WsSessionClient(this.socket, sessionId); - } - - /** Subscribe to a core-scoped event stream (e.g. `events`). */ - listen(event: string, handler: (data: unknown) => void): WsSubscription { - return this.socket.listen('core', event, {}, handler); - } - - get state(): WsSocketState { - return this.socket.currentState; - } - - onDidChangeState(listener: (state: WsSocketState) => void): WsSubscription { - return this.socket.onDidChangeState(listener); - } - - onDidListenError(listener: Parameters[0]): WsSubscription { - return this.socket.onDidListenError(listener); - } - - close(): void { - this.socket.close(); - } -} - -export class WsSessionClient { - constructor( - private readonly socket: WsSocket, - private readonly sessionId: string, - ) {} - - /** Session-scoped Service over WS, e.g. `.service(ISessionMetadata)`. */ - service(id: ServiceIdentifier): T { - return makeProxy( - new WsChannel({ - socket: this.socket, - scope: 'session', - service: String(id), - sessionId: this.sessionId, - }), - ); - } - - /** Subscribe to a session-scoped event stream (e.g. `interactions`). */ - listen(event: string, handler: (data: unknown) => void): WsSubscription { - return this.socket.listen('session', event, { sessionId: this.sessionId }, handler); - } - - /** Agent scope entry point. */ - agent(agentId: string): WsAgentClient { - return new WsAgentClient(this.socket, this.sessionId, agentId); - } -} - -export class WsAgentClient { - constructor( - private readonly socket: WsSocket, - private readonly sessionId: string, - private readonly agentId: string, - ) {} - - /** Agent-scoped Service over WS, e.g. `.service(IAgentProfileService)`. */ - service(id: ServiceIdentifier): T { - return makeProxy( - new WsChannel({ - socket: this.socket, - scope: 'agent', - service: String(id), - sessionId: this.sessionId, - agentId: this.agentId, - }), - ); - } - - /** Subscribe to an agent-scoped event stream (e.g. `events`). */ - listen(event: string, handler: (data: unknown) => void): WsSubscription { - return this.socket.listen( - 'agent', - event, - { sessionId: this.sessionId, agentId: this.agentId }, - handler, - ); - } -} diff --git a/packages/klient/test/client.test.ts b/packages/klient/test/client.test.ts deleted file mode 100644 index 1f433b5261..0000000000 --- a/packages/klient/test/client.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; -import { createDecorator } from '@moonshot-ai/agent-core-v2/_base/di/instantiation'; - -import { Klient } from '../src/client.js'; -import { HttpChannel } from '../src/httpChannel.js'; -import { SessionIndexClient } from '../src/services/sessionIndex.js'; - -interface IAgentProfileLike { - getModel(): Promise; -} -const IAgentProfileLike = createDecorator('agentProfileLike'); - -function jsonResponse(body: unknown, status = 200): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'content-type': 'application/json' }, - }); -} - -describe('HttpChannel', () => { - const fetchMock = vi.fn(); - afterEach(() => { - fetchMock.mockReset(); - }); - - it('POSTs the method name to the service URL and unwraps envelope data', async () => { - fetchMock.mockResolvedValue( - jsonResponse({ code: 0, msg: 'ok', data: { items: [] }, request_id: 'r1' }), - ); - const channel = new HttpChannel({ - baseUrl: 'http://127.0.0.1:58627/api/v2/sessionIndex', - fetch: fetchMock, - }); - - const data = await channel.call('list', [{ workspaceId: 'w1' }]); - - expect(data).toEqual({ items: [] }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0]!; - expect(url as string).toBe('http://127.0.0.1:58627/api/v2/sessionIndex/list'); - expect(init?.method).toBe('POST'); - expect(init?.body).toBe(JSON.stringify([{ workspaceId: 'w1' }])); - expect((init?.headers as Record)['content-type']).toBe('application/json'); - }); - - it('omits the body when no argument is given', async () => { - fetchMock.mockResolvedValue(jsonResponse({ code: 0, msg: 'ok', data: 3, request_id: 'r2' })); - const channel = new HttpChannel({ baseUrl: 'http://x/api/v2/sessionIndex', fetch: fetchMock }); - - await channel.call('countActive'); - - const [, init] = fetchMock.mock.calls[0]!; - expect(init?.body).toBeUndefined(); - }); - - it('sends a bearer token when provided', async () => { - fetchMock.mockResolvedValue(jsonResponse({ code: 0, msg: 'ok', data: null, request_id: 'r3' })); - const channel = new HttpChannel({ - baseUrl: 'http://x/api/v2/sessionIndex', - token: 't', - fetch: fetchMock, - }); - - await channel.call('list'); - - const [, init] = fetchMock.mock.calls[0]!; - expect((init?.headers as Record)['authorization']).toBe('Bearer t'); - }); - - it('throws RPCError on a non-zero envelope code', async () => { - fetchMock.mockResolvedValue( - jsonResponse({ code: 40001, msg: 'method not found', data: null, request_id: 'r4' }), - ); - const channel = new HttpChannel({ baseUrl: 'http://x/api/v2/sessionIndex', fetch: fetchMock }); - - await expect(channel.call('nope')).rejects.toMatchObject({ - name: 'RPCError', - code: 40001, - }); - }); -}); - -describe('Klient scope routing', () => { - it('routes core / session / agent scopes by decorator id', async () => { - const calls: string[] = []; - const fetchMock = vi.fn().mockImplementation((input) => { - calls.push(input as string); - return Promise.resolve(jsonResponse({ code: 0, msg: 'ok', data: null, request_id: 'r' })); - }); - const client = new Klient({ url: 'http://127.0.0.1:58627', fetch: fetchMock }); - - await client.core(ISessionIndex).list({}); - await client.session('s 1').service(ISessionMetadata).read(); - await client.session('s 1').agent('a 1').service(IAgentProfileLike).getModel(); - - expect(calls).toEqual([ - 'http://127.0.0.1:58627/api/v2/sessionIndex/list', - 'http://127.0.0.1:58627/api/v2/session/s%201/sessionMetadata/read', - 'http://127.0.0.1:58627/api/v2/session/s%201/agent/a%201/agentProfileLike/getModel', - ]); - }); -}); - -describe('SessionIndexClient (explicit implementation)', () => { - it('implements ISessionIndex over the channel', async () => { - const page = { - items: [{ id: 's1', workspaceId: 'w1', createdAt: 1, updatedAt: 2, archived: false }], - }; - const fetchMock = vi - .fn() - .mockImplementation(() => - Promise.resolve(jsonResponse({ code: 0, msg: 'ok', data: page, request_id: 'r' })), - ); - const channel = new HttpChannel({ baseUrl: 'http://x/api/v2/sessionIndex', fetch: fetchMock }); - - const index: ISessionIndex = new SessionIndexClient(channel); - await expect(index.list({})).resolves.toEqual(page); - await index.get('s1'); - await index.countActive('w1'); - - const urls = fetchMock.mock.calls.map((c) => c[0] as string); - expect(urls).toEqual([ - 'http://x/api/v2/sessionIndex/list', - 'http://x/api/v2/sessionIndex/get', - 'http://x/api/v2/sessionIndex/countActive', - ]); - }); -}); diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts new file mode 100644 index 0000000000..b9ad1cbae2 --- /dev/null +++ b/packages/klient/test/contract-parity.ts @@ -0,0 +1,542 @@ +/** + * Compile-time parity checks between klient wire schemas and the engine + * types they mirror. Plain `.ts` (not `.test.ts`) — vitest must not pick it + * up; `tsc -p tsconfig.json --noEmit` is the check. + * + * Wire shapes the engine imports from `@moonshot-ai/protocol` are reached + * through indexed access on the engine service interfaces, since klient does + * not depend on the protocol package directly. + */ + +import type { z } from 'zod'; + +import type { + ActivityLastTurnState, + ActivityRetryState, + ActivityTurnState, + ActivityViewLifecycle, + AgentActivityState, + ApprovalRef, + BackgroundRef, + ToolCallRef, + TurnPhase, +} from '@moonshot-ai/agent-core-v2/agent/activityView/activityView'; +import type { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +import type { TurnEndReason } from '@moonshot-ai/agent-core-v2/agent/loop/turnEvents'; +import type { PlanData } from '@moonshot-ai/agent-core-v2/agent/plan/plan'; +import type { + AgentAPI, + PromptPart, +} from '@moonshot-ai/agent-core-v2/agent/rpc/core-api'; +import type { ISessionScopeHandle } from '@moonshot-ai/agent-core-v2/_base/di/scope'; +import type { + CreateChildSessionOptions, + CreateSessionOptions, + ForkSessionOptions, +} from '@moonshot-ai/agent-core-v2/app/sessionLifecycle/sessionLifecycle'; +import type { + ApprovalRequest, + ApprovalResponse, +} from '@moonshot-ai/agent-core-v2/session/approval/approval'; +import type { + Interaction, + InteractionResolution, +} from '@moonshot-ai/agent-core-v2/session/interaction/interaction'; +import type { + QuestionAnswers, + QuestionItem, + QuestionOption, + QuestionRequest, + QuestionResponse, + QuestionResult, +} from '@moonshot-ai/agent-core-v2/session/question/question'; +import type { + AgentMeta, + SessionMeta, + SessionMetadataChangedEvent, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { + AuthStatus, + IOAuthService, +} from '@moonshot-ai/agent-core-v2/app/auth/auth'; +import type { IBootstrapService } from '@moonshot-ai/agent-core-v2/app/bootstrap/bootstrap'; +import type { + ConfigDiagnostic, + ConfigInspectValue, + ConfigTarget, +} from '@moonshot-ai/agent-core-v2/app/config/config'; +import type { ExperimentalFeatureState } from '@moonshot-ai/agent-core-v2/app/flag/flag'; +import type { + FsBrowseResponse, + FsHomeResponse, +} from '@moonshot-ai/agent-core-v2/app/hostFolderBrowser/hostFolderBrowser'; +import type { ModelConfig } from '@moonshot-ai/agent-core-v2/app/model/model'; +import type { + IModelCatalogService, +} from '@moonshot-ai/agent-core-v2/app/modelCatalog/modelCatalog'; +import type { + GetPluginInfoInput, + InstallPluginInput, + RemovePluginInput, + SetPluginEnabledInput, + SetPluginMcpServerEnabledInput, +} from '@moonshot-ai/agent-core-v2/app/plugin/plugin'; +import type { + PluginCommandDef, + PluginDiagnostic, + PluginGithubMetadata, + PluginInfo, + PluginManifest, + PluginMcpServerInfo, + PluginSummary, + PluginUpdateStatus, + ReloadSummary, +} from '@moonshot-ai/agent-core-v2/app/plugin/types'; +import type { ProviderConfig } from '@moonshot-ai/agent-core-v2/app/provider/provider'; +import type { + SessionListQuery, + SessionSummary, +} from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; +import type { + Workspace, + WorkspaceUpdate, +} from '@moonshot-ai/agent-core-v2/app/workspaceRegistry/workspaceRegistry'; +// Test-only: `@moonshot-ai/protocol` is a devDependency; importing its types +// here (never in `src/`) strengthens parity for the agent event stream. +import type { + AssistantDeltaEvent, + PromptAbortedEvent, + PromptCompletedEvent, + TaskInfo, + ThinkingDeltaEvent, + ToolCallStartedEvent, + ToolResultEvent, + TurnEndedEvent, + TurnStartedEvent, + WarningEvent, +} from '@moonshot-ai/protocol'; + +import { + activityLastTurnStateSchema, + activityRetryStateSchema, + activityTurnStateSchema, + activityViewLifecycleSchema, + agentActivityStateSchema, + approvalRefSchema, + backgroundRefSchema, + toolCallRefSchema, + turnEndReasonSchema, + turnPhaseSchema, +} from '../src/contract/agent/activity.js'; +import { + agentContextDataSchema, + agentTaskInfoSchema, + cancelPayloadSchema, + cancelPlanPayloadSchema, + cancelShellCommandPayloadSchema, + emptyPayloadSchema, + getTaskOutputPayloadSchema, + getTasksPayloadSchema, + planDataSchema, + promptLaunchResultSchema, + promptPartSchema, + promptPayloadSchema, + runShellCommandPayloadSchema, + setModelPayloadSchema, + setModelResultSchema, + setPermissionPayloadSchema, + shellCommandResultSchema, + steerPayloadSchema, + stopTaskPayloadSchema, + tokenUsageSchema, + usageStatusSchema, +} from '../src/contract/agent/rpc.js'; +import { + assistantDeltaEventSchema, + promptAbortedEventSchema, + promptCompletedEventSchema, + thinkingDeltaEventSchema, + toolCallStartedEventSchema, + toolResultEventSchema, + turnEndedEventSchema, + turnStartedEventSchema, + warningEventSchema, +} from '../src/contract/agent/events.js'; +import { + approvalRequestSchema, + approvalResponseSchema, +} from '../src/contract/session/approval.js'; +import { + createChildSessionOptionsSchema, + createSessionOptionsSchema, + forkSessionOptionsSchema, + handleWireSchema, +} from '../src/contract/session/lifecycle.js'; +import { + interactionResolutionSchema, + interactionSchema, +} from '../src/contract/session/interaction.js'; +import { + agentMetaSchema, + sessionMetaPatchSchema, + sessionMetaSchema, + sessionMetadataChangedEventSchema, +} from '../src/contract/session/metadata.js'; +import { + questionAnswersSchema, + questionItemSchema, + questionOptionSchema, + questionRequestSchema, + questionResponseSchema, + questionResultSchema, +} from '../src/contract/session/question.js'; + +import { + authStatusSchema, + oAuthFlowSnapshotSchema, + oAuthFlowStartSchema, + oAuthLoginCancelResponseSchema, + oAuthLogoutResponseSchema, + refreshOAuthProviderModelsResponseSchema, +} from '../src/contract/global/auth.js'; +import { + configDiagnosticSchema, + configInspectValueSchema, + configTargetSchema, +} from '../src/contract/global/config.js'; +import { + modelCatalogItemSchema, + providerCatalogItemSchema, + refreshProviderModelsOptionsSchema, + refreshProviderModelsResponseSchema, + setDefaultModelResponseSchema, +} from '../src/contract/global/catalog.js'; +import { experimentalFeatureStateSchema } from '../src/contract/global/flags.js'; +import { + fsBrowseResponseSchema, + fsHomeResponseSchema, +} from '../src/contract/global/hostFs.js'; +import { modelConfigSchema } from '../src/contract/global/models.js'; +import { + getPluginInfoInputSchema, + installPluginInputSchema, + pluginCommandDefSchema, + pluginDiagnosticSchema, + pluginGithubMetadataSchema, + pluginInfoSchema, + pluginManifestSchema, + pluginMcpServerInfoSchema, + pluginSummarySchema, + pluginUpdateStatusSchema, + reloadSummarySchema, + removePluginInputSchema, + setPluginEnabledInputSchema, + setPluginMcpServerEnabledInputSchema, +} from '../src/contract/global/plugins.js'; +import { providerConfigSchema } from '../src/contract/global/providers.js'; +import { + sessionListQuerySchema, + sessionSummarySchema, +} from '../src/contract/global/sessions.js'; +import { + workspaceSchema, + workspaceUpdateSchema, +} from '../src/contract/global/workspaces.js'; + +import type { AssertWire, MutableDeep } from './helpers/typeAssert.js'; + +/** One-directional: the engine type must be assignable TO the schema's infer. */ +type AssertEngineToWire = [MutableDeep] extends [ + z.infer, +] + ? true + : never; + +/** One-directional: the schema's infer must be assignable TO the engine type. */ +type AssertWireToEngine = [z.infer] extends [ + MutableDeep, +] + ? true + : never; + +// Protocol wire shapes, derived from the engine interfaces (no direct +// `@moonshot-ai/protocol` dependency in klient). +type OAuthFlowStart = Awaited>; +type OAuthFlowSnapshot = NonNullable>; +type OAuthLoginCancelResponse = Awaited>; +type OAuthLogoutResponse = Awaited>; +type RefreshOAuthProviderModelsResponse = Awaited< + ReturnType +>; +/** String-enum value union (`'user' | 'memory'`). */ +type ConfigTargetValues = `${ConfigTarget}`; + +// sessions.ts +const _sessionSummary: AssertWire = true; +const _sessionListQuery: AssertWire = true; + +// workspaces.ts +const _workspace: AssertWire = true; +const _workspaceUpdate: AssertWire = true; + +// config.ts +// One-directional: the engine declares the `ConfigInspectValue` keys as +// required with `| undefined` values, while the wire schema marks them +// `.optional()`; optional → required is not assignable, so only the +// engine → wire direction holds. +const _configInspectValue: AssertEngineToWire = + true; +const _configDiagnostic: AssertWire = true; +const _configTarget: AssertWire = true; + +// providers.ts +const _providerConfig: AssertWire = true; + +// auth.ts +const _oAuthFlowStart: AssertWire = true; +const _oAuthFlowSnapshot: AssertWire = true; +const _oAuthLoginCancelResponse: AssertWire< + typeof oAuthLoginCancelResponseSchema, + OAuthLoginCancelResponse +> = true; +const _oAuthLogoutResponse: AssertWire = + true; +const _authStatus: AssertWire = true; +const _refreshOAuthProviderModelsResponse: AssertWire< + typeof refreshOAuthProviderModelsResponseSchema, + RefreshOAuthProviderModelsResponse +> = true; + +// flags.ts +const _experimentalFeatureState: AssertWire< + typeof experimentalFeatureStateSchema, + ExperimentalFeatureState +> = true; + +// hostFs.ts +const _fsBrowseResponse: AssertWire = true; +const _fsHomeResponse: AssertWire = true; + +// catalog.ts — protocol wire shapes derived through the catalog service interface. +type ModelCatalogItem = Awaited>[number]; +type ProviderCatalogItem = Awaited>[number]; +type SetDefaultModelResponse = Awaited>; +type RefreshProviderModelsOptions = NonNullable< + Parameters[0] +>; +type RefreshProviderModelsResponse = Awaited< + ReturnType +>; +const _modelCatalogItem: AssertWire = true; +const _providerCatalogItem: AssertWire = + true; +const _setDefaultModelResponse: AssertWire< + typeof setDefaultModelResponseSchema, + SetDefaultModelResponse +> = true; +const _refreshProviderModelsOptions: AssertWire< + typeof refreshProviderModelsOptionsSchema, + RefreshProviderModelsOptions +> = true; +const _refreshProviderModelsResponse: AssertWire< + typeof refreshProviderModelsResponseSchema, + RefreshProviderModelsResponse +> = true; + +// models.ts +const _modelConfig: AssertWire = true; + +// plugins.ts +const _pluginSummary: AssertWire = true; +const _pluginInfo: AssertWire = true; +const _pluginManifest: AssertWire = true; +const _pluginMcpServerInfo: AssertWire = + true; +const _pluginDiagnostic: AssertWire = true; +const _pluginGithubMetadata: AssertWire = + true; +const _reloadSummary: AssertWire = true; +const _pluginUpdateStatus: AssertWire = true; +const _pluginCommandDef: AssertWire = true; +const _installPluginInput: AssertWire = true; +const _setPluginEnabledInput: AssertWire< + typeof setPluginEnabledInputSchema, + SetPluginEnabledInput +> = true; +const _setPluginMcpServerEnabledInput: AssertWire< + typeof setPluginMcpServerEnabledInputSchema, + SetPluginMcpServerEnabledInput +> = true; +const _removePluginInput: AssertWire = true; +const _getPluginInfoInput: AssertWire = true; + +// env.ts has no named schemas; `platform` narrows to `NodeJS.Platform` in the +// engine — assert the bootstrap properties are all strings instead. +type _bootstrapStringProps = AssertStringProps< + Pick< + IBootstrapService, + | 'platform' + | 'arch' + | 'cwd' + | 'osHomeDir' + | 'homeDir' + | 'configPath' + | 'clientVersion' + | 'sessionsDir' + | 'blobsDir' + | 'storeDir' + | 'cacheDir' + | 'logsDir' + > +>; +type AssertStringProps = T extends Record ? true : never; +const _envProps: _bootstrapStringProps = true; + +// ── session scope ─────────────────────────────────────────────────────────── + +// session/metadata.ts +const _sessionMeta: AssertWire = true; +const _agentMeta: AssertWire = true; +const _sessionMetaPatch: AssertWire = true; +const _sessionMetadataChangedEvent: AssertWire< + typeof sessionMetadataChangedEventSchema, + SessionMetadataChangedEvent +> = true; + +// session/lifecycle.ts +const _createSessionOptions: AssertWire = + true; +const _forkSessionOptions: AssertWire = true; +const _createChildSessionOptions: AssertWire< + typeof createChildSessionOptionsSchema, + CreateChildSessionOptions +> = true; +// One-directional: the wire handle is `z.looseObject` — the in-process +// `ISessionScopeHandle` carries an `accessor` and `dispose()` that JSON +// drops, so only the engine → wire direction holds. +const _handleWire: AssertEngineToWire = true; + +// session/interaction.ts +const _interaction: AssertWire = true; +const _interactionResolution: AssertWire< + typeof interactionResolutionSchema, + InteractionResolution +> = true; + +// session/approval.ts +// One-directional: `display` is the protocol `ToolInputDisplay` union (huge) +// and crosses the wire as `unknown`; the wire schema cannot be assignable +// back to the engine type. +const _approvalRequest: AssertEngineToWire = true; +const _approvalResponse: AssertWire = true; + +// session/question.ts +const _questionRequest: AssertWire = true; +const _questionItem: AssertWire = true; +const _questionOption: AssertWire = true; +const _questionAnswers: AssertWire = true; +const _questionResponse: AssertWire = true; +const _questionResult: AssertWire = true; + +// agent/activity.ts +const _turnPhase: AssertWire = true; +const _approvalRef: AssertWire = true; +const _toolCallRef: AssertWire = true; +const _activityRetryState: AssertWire = true; +// One-directional: `origin` is the deep `PromptOrigin` union mirrored as +// `unknown`; the wire schema cannot be assignable back to the engine type. +const _activityTurnState: AssertEngineToWire = + true; +const _turnEndReason: AssertWire = true; +const _activityLastTurnState: AssertWire< + typeof activityLastTurnStateSchema, + ActivityLastTurnState +> = true; +const _backgroundRef: AssertWire = true; +const _activityViewLifecycle: AssertWire = + true; +const _agentActivityState: AssertEngineToWire = + true; + +// ── agent scope (rpc.ts) ──────────────────────────────────────────────────── +// Payload/result types are reached through the `AgentAPI` interface so the +// assertions track the exact methods the contract mirrors. +type PromptPayload = Parameters[0]; +type PromptLaunchResult = NonNullable>; +type SteerPayload = Parameters[0]; +type CancelPayload = Parameters[0]; +type RunShellCommandPayload = Parameters[0]; +type ShellCommandResult = ReturnType; +type CancelShellCommandPayload = Parameters[0]; +type SetModelPayload = Parameters[0]; +type SetModelResult = ReturnType; +type SetPermissionPayload = Parameters[0]; +type UsageStatus = ReturnType; +type TokenUsage = NonNullable; +type GetTasksPayload = Parameters[0]; +type StopTaskPayload = Parameters[0]; +type GetTaskOutputPayload = Parameters[0]; +type CancelPlanPayload = Parameters[0]; +type EmptyPayload = Parameters[0]; + +const _emptyPayload: AssertWire = true; +const _promptPart: AssertWire = true; +// One-directional (wire → engine): the engine's `PromptPayload.input` accepts +// the full `ContentPart` union (also think/audio parts); the wire mirrors the +// `PromptPart` subset clients may send, so the reverse direction fails. +const _promptPayload: AssertWireToEngine = true; +const _steerPayload: AssertWireToEngine = true; +const _promptLaunchResult: AssertWire = true; +const _cancelPayload: AssertWire = true; +const _runShellCommandPayload: AssertWire< + typeof runShellCommandPayloadSchema, + RunShellCommandPayload +> = true; +const _shellCommandResult: AssertWire = true; +const _cancelShellCommandPayload: AssertWire< + typeof cancelShellCommandPayloadSchema, + CancelShellCommandPayload +> = true; +const _setModelPayload: AssertWire = true; +const _setModelResult: AssertWire = true; +const _setPermissionPayload: AssertWire = + true; +const _tokenUsage: AssertWire = true; +const _usageStatus: AssertWire = true; +// One-directional: `history` entries are full `ContextMessage`s (deep +// `Message`/`Tool`/`PromptOrigin` unions) mirrored as `unknown`. +const _agentContextData: AssertEngineToWire = true; +const _planData: AssertWire = true; +const _cancelPlanPayload: AssertWire = true; +const _getTasksPayload: AssertWire = true; +// The wire task union mirrors the protocol `TaskInfo`; the engine's +// declaration-merged `AgentTaskInfo` is structurally identical but depends on +// tool-module augmentation, so parity is pinned to the protocol type. +const _agentTaskInfo: AssertWire = true; +const _stopTaskPayload: AssertWire = true; +const _getTaskOutputPayload: AssertWire = + true; + +// ── agent scope (events.ts) ───────────────────────────────────────────────── +// Parity against the protocol event types (the stream carries flat +// `{ type, ... }` events; schemas keep the `type` literal). One-directional +// where a field is mirrored as `unknown`. +const _turnStartedEvent: AssertEngineToWire = true; +const _turnEndedEvent: AssertEngineToWire = true; +const _assistantDeltaEvent: AssertWire = + true; +const _thinkingDeltaEvent: AssertWire = true; +const _toolCallStartedEvent: AssertEngineToWire< + typeof toolCallStartedEventSchema, + ToolCallStartedEvent +> = true; +const _toolResultEvent: AssertWire = true; +const _promptCompletedEvent: AssertWire = + true; +const _promptAbortedEvent: AssertWire = true; +const _warningEvent: AssertWire = true; +// No parity assertions for `errorEventSchema`, `permissionApproval*Schema`, +// and `agentStatusUpdatedEventSchema`: they are deliberately `z.looseObject`s +// (index signature breaks both-ways assignability) — `permission.approval.*` +// is not part of the protocol event union at all. diff --git a/packages/klient/test/e2e/dual/01-prompt.test.ts b/packages/klient/test/e2e/dual/01-prompt.test.ts new file mode 100644 index 0000000000..8ddc3e3b94 --- /dev/null +++ b/packages/klient/test/e2e/dual/01-prompt.test.ts @@ -0,0 +1,38 @@ +/** + * Dual-backend rewrite of scenario `01-create-and-send`: create a session, + * run one prompt to completion, and assert the assistant replied — driven + * entirely through the klient facade + agent events, on both backends. + */ +import { expect, it } from 'vitest'; + +import { DUAL_MODEL_ID, defineDualSuite, onceEvent } from '../../helpers/dual.js'; + +defineDualSuite('prompt round-trip', { requiresModel: true }, ({ klient }) => { + it( + 'creates a session, runs a prompt, and accumulates the assistant reply', + async () => { + const k = klient(); + const session = await k.global.sessions.create({ workDir: process.cwd() }); + const handle = k.session(session.id); + const agent = handle.agent('main'); + await agent.setModel(DUAL_MODEL_ID); + + const deltas: string[] = []; + agent.events.on('assistant.delta', (event) => { + deltas.push(event.delta); + }); + const completed = onceEvent(agent.events, 'prompt.completed', 120_000); + const launched = await agent.prompt({ + input: [{ type: 'text', text: 'Reply with the single word "OK" and nothing else.' }], + }); + expect(launched).toBeTruthy(); + + await completed; + expect(deltas.join('')).toContain('OK'); + + await handle.archive(); + expect((await k.global.sessions.get(session.id))?.archived).toBe(true); + }, + 180_000, + ); +}); diff --git a/packages/klient/test/e2e/dual/02-approval.test.ts b/packages/klient/test/e2e/dual/02-approval.test.ts new file mode 100644 index 0000000000..17d3e20e2b --- /dev/null +++ b/packages/klient/test/e2e/dual/02-approval.test.ts @@ -0,0 +1,56 @@ +/** + * Dual-backend rewrite of scenario `02-tool-call-with-approval`: a Bash tool + * call blocks on a manual approval; the client lists and approves it through + * the session facade, and the turn completes with the canary in the output. + */ +import { expect, it } from 'vitest'; + +import { DUAL_MODEL_ID, defineDualSuite, onceEvent } from '../../helpers/dual.js'; + +defineDualSuite('tool call approval', { requiresModel: true }, ({ klient }) => { + it( + 'approval requested → listed → approved → prompt completes', + async () => { + const k = klient(); + const canary = `DUAL_APPROVAL_${process.pid}_${Date.now()}`; + const session = await k.global.sessions.create({ workDir: process.cwd() }); + const handle = k.session(session.id); + const agent = handle.agent('main'); + await agent.setModel(DUAL_MODEL_ID); + await agent.setPermission('manual'); + + const outputs: string[] = []; + agent.events.on('tool.result', (event) => { + outputs.push(JSON.stringify(event.output)); + }); + const approvalRequested = onceEvent(agent.events, 'permission.approval.requested', 180_000); + const completed = onceEvent(agent.events, 'prompt.completed', 240_000); + + await agent.prompt({ + input: [ + { + type: 'text', + text: `Use the Bash tool to run \`echo ${canary}\` and then report the output back.`, + }, + ], + }); + + const requested = await approvalRequested; + expect(requested.toolName.length).toBeGreaterThan(0); + + const pending = await handle.approvals.list(); + expect(pending.length).toBeGreaterThan(0); + const target = pending[0]!; + await handle.approvals.decide(target.id ?? target.toolCallId ?? '', { + decision: 'approved', + }); + expect(await handle.approvals.list()).toHaveLength(0); + + await completed; + expect(outputs.join('\n')).toContain(canary); + + await handle.archive(); + }, + 300_000, + ); +}); diff --git a/packages/klient/test/e2e/dual/05-workspace.test.ts b/packages/klient/test/e2e/dual/05-workspace.test.ts new file mode 100644 index 0000000000..5343622555 --- /dev/null +++ b/packages/klient/test/e2e/dual/05-workspace.test.ts @@ -0,0 +1,42 @@ +/** + * Dual-backend rewrite of scenario `05-workspace`: workspace registration, + * session creation rooted at the workspace, index linkage, and delete + * semantics — all through the global facade. No model required. + */ +import { mkdtemp, realpath, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { expect, it } from 'vitest'; + +import { defineDualSuite } from '../../helpers/dual.js'; + +defineDualSuite('workspace', {}, ({ klient }) => { + it('workspace + session lifecycle', async () => { + const k = klient(); + const root = await realpath(await mkdtemp(join(tmpdir(), 'klient-dual-ws-'))); + try { + const ws = await k.global.workspaces.createOrTouch({ root, name: 'dual-workspace' }); + expect(ws.id.length).toBeGreaterThan(0); + expect(ws.name).toBe('dual-workspace'); + + const got = await k.global.workspaces.get(ws.id); + expect(got?.root).toBe(ws.root); + + const session = await k.global.sessions.create({ workDir: ws.root }); + expect(session.cwd).toBe(ws.root); + + const listed = await k.global.sessions.list({ workspaceId: ws.id }); + expect(listed.items.some((s) => s.id === session.id)).toBe(true); + + await k.global.workspaces.delete(ws.id); + expect((await k.global.workspaces.list()).some((w) => w.id === ws.id)).toBe(false); + + // Deleting the workspace does not delete the session. + expect((await k.session(session.id).get()).id).toBe(session.id); + await k.session(session.id).archive(); + } finally { + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + } + }, 30_000); +}); diff --git a/packages/klient/test/e2e/dual/06-catalog.test.ts b/packages/klient/test/e2e/dual/06-catalog.test.ts new file mode 100644 index 0000000000..d8f9d83659 --- /dev/null +++ b/packages/klient/test/e2e/dual/06-catalog.test.ts @@ -0,0 +1,31 @@ +/** + * Dual-backend rewrite of scenario `06-model-catalog`: catalog and auth read + * models respond, a provider round-trips by id, and setting the default + * model echoes it back. No live model calls required. + */ +import { expect, it } from 'vitest'; + +import { defineDualSuite } from '../../helpers/dual.js'; + +defineDualSuite('model catalog', {}, ({ klient }) => { + it('catalog + auth read models respond', async () => { + const k = klient(); + + const models = await k.global.catalog.listModels(); + const providers = await k.global.catalog.listProviders(); + expect(Array.isArray(models)).toBe(true); + expect(Array.isArray(providers)).toBe(true); + + if (providers.length > 0) { + const provider = await k.global.catalog.getProvider(providers[0]!.id); + expect(provider.id).toBe(providers[0]!.id); + } + + expect(Array.isArray(await k.global.auth.summarize())).toBe(true); + + if (models.length > 0) { + const result = await k.global.catalog.setDefaultModel(models[0]!.model); + expect(result.default_model).toBe(models[0]!.model); + } + }, 60_000); +}); diff --git a/packages/klient/test/e2e/dual/07-children.test.ts b/packages/klient/test/e2e/dual/07-children.test.ts new file mode 100644 index 0000000000..cfd0b9b2f3 --- /dev/null +++ b/packages/klient/test/e2e/dual/07-children.test.ts @@ -0,0 +1,38 @@ +/** + * Dual-backend rewrite of scenario `07-session-children`: child/grandchild + * creation markers and direct-children listing semantics, via the session + * facade's `createChild` and the session index's `childOf` query. + */ +import { expect, it } from 'vitest'; + +import { defineDualSuite } from '../../helpers/dual.js'; + +defineDualSuite('session children', {}, ({ klient }) => { + it('child markers, listing semantics, and missing-parent handling', async () => { + const k = klient(); + const parent = await k.global.sessions.create({ + workDir: process.cwd(), + title: 'dual-parent', + }); + + const child = await k.session(parent.id).createChild({ title: 'dual-child' }); + expect(child.custom?.['parent_session_id']).toBe(parent.id); + expect(child.custom?.['child_session_kind']).toBe('child'); + + const grandchild = await k.session(child.id).createChild({ title: 'dual-grandchild' }); + + const parentChildren = await k.global.sessions.list({ childOf: parent.id }); + expect(parentChildren.items.some((s) => s.id === child.id)).toBe(true); + expect(parentChildren.items.some((s) => s.id === grandchild.id)).toBe(false); + + const childChildren = await k.global.sessions.list({ childOf: child.id }); + expect(childChildren.items.some((s) => s.id === grandchild.id)).toBe(true); + + const missing = await k.global.sessions.list({ childOf: 'sess_missing_dual_children' }); + expect(missing.items).toEqual([]); + + await k.session(grandchild.id).archive(); + await k.session(child.id).archive(); + await k.session(parent.id).archive(); + }, 30_000); +}); diff --git a/packages/klient/test/e2e/dual/08-pending.test.ts b/packages/klient/test/e2e/dual/08-pending.test.ts new file mode 100644 index 0000000000..7d295e854c --- /dev/null +++ b/packages/klient/test/e2e/dual/08-pending.test.ts @@ -0,0 +1,63 @@ +/** + * Dual-backend rewrite of scenario `08-pending-recovery`: pending approval + * and question interactions are listed and resolved through the session + * facade, letting the blocked prompts run to completion. + */ +import { expect, it } from 'vitest'; + +import { DUAL_MODEL_ID, defineDualSuite, onceEvent, waitFor } from '../../helpers/dual.js'; + +defineDualSuite('pending interaction recovery', { requiresModel: true }, ({ klient }) => { + it( + 'approval pending → approve → completes; question pending → answer → completes', + async () => { + const k = klient(); + const canary = `DUAL_PENDING_${process.pid}_${Date.now()}`; + const session = await k.global.sessions.create({ workDir: process.cwd() }); + const handle = k.session(session.id); + const agent = handle.agent('main'); + await agent.setModel(DUAL_MODEL_ID); + await agent.setPermission('manual'); + + // --- approval recovery --- + let completed = onceEvent(agent.events, 'prompt.completed', 240_000); + await agent.prompt({ + input: [ + { type: 'text', text: `Use the Bash tool to run \`echo ${canary}\` and report the output.` }, + ], + }); + await waitFor(() => handle.approvals.list().then((l) => l.length > 0), 180_000); + const approval = (await handle.approvals.list())[0]!; + await handle.approvals.decide(approval.id ?? approval.toolCallId ?? '', { + decision: 'approved', + }); + expect(await handle.approvals.list()).toHaveLength(0); + await completed; + + // --- question recovery --- + completed = onceEvent(agent.events, 'prompt.completed', 240_000); + await agent.prompt({ + input: [ + { + type: 'text', + text: + 'Call the AskUserQuestion tool exactly once with one question ("Pick one") ' + + 'and exactly two options ("Alpha", "Beta"), then stop and wait for the answer.', + }, + ], + }); + await waitFor(() => handle.questions.list().then((l) => l.length > 0), 180_000); + const question = (await handle.questions.list())[0]!; + const item = question.questions[0]!; + const firstLabel = item.options[0]!.label; + await handle.questions.answer(question.id ?? question.toolCallId ?? '', { + [item.question]: firstLabel, + }); + expect(await handle.questions.list()).toHaveLength(0); + await completed; + + await handle.archive(); + }, + 480_000, + ); +}); diff --git a/packages/server-e2e/src/client.ts b/packages/klient/test/e2e/harness/client.ts similarity index 100% rename from packages/server-e2e/src/client.ts rename to packages/klient/test/e2e/harness/client.ts diff --git a/packages/server-e2e/src/envelope.ts b/packages/klient/test/e2e/harness/envelope.ts similarity index 100% rename from packages/server-e2e/src/envelope.ts rename to packages/klient/test/e2e/harness/envelope.ts diff --git a/packages/server-e2e/src/http.ts b/packages/klient/test/e2e/harness/http.ts similarity index 100% rename from packages/server-e2e/src/http.ts rename to packages/klient/test/e2e/harness/http.ts diff --git a/packages/server-e2e/src/index.ts b/packages/klient/test/e2e/harness/index.ts similarity index 98% rename from packages/server-e2e/src/index.ts rename to packages/klient/test/e2e/harness/index.ts index 9db27316ae..30ddb91783 100644 --- a/packages/server-e2e/src/index.ts +++ b/packages/klient/test/e2e/harness/index.ts @@ -72,4 +72,3 @@ export { DEFAULT_FRAME_TIMEOUT_MS, waitForFrame, waitForSessionBusy } from './wa // A lark-style typed client for the `/api/v2` RPC + WS surface. Re-exported // here so consumers can `import { ServerClient } from '@moonshot-ai/server-e2e'` // alongside the legacy `DaemonClient`. Names are disjoint from the v1 surface. -export * from './v2/index.js'; diff --git a/packages/server-e2e/src/report.ts b/packages/klient/test/e2e/harness/report.ts similarity index 100% rename from packages/server-e2e/src/report.ts rename to packages/klient/test/e2e/harness/report.ts diff --git a/packages/server-e2e/src/reverse-rpc.ts b/packages/klient/test/e2e/harness/reverse-rpc.ts similarity index 100% rename from packages/server-e2e/src/reverse-rpc.ts rename to packages/klient/test/e2e/harness/reverse-rpc.ts diff --git a/packages/server-e2e/src/wait.ts b/packages/klient/test/e2e/harness/wait.ts similarity index 100% rename from packages/server-e2e/src/wait.ts rename to packages/klient/test/e2e/harness/wait.ts diff --git a/packages/server-e2e/src/ws.ts b/packages/klient/test/e2e/harness/ws.ts similarity index 100% rename from packages/server-e2e/src/ws.ts rename to packages/klient/test/e2e/harness/ws.ts diff --git a/packages/server-e2e/test/client.test.ts b/packages/klient/test/e2e/legacy/client.test.ts similarity index 99% rename from packages/server-e2e/test/client.test.ts rename to packages/klient/test/e2e/legacy/client.test.ts index d65dd447fe..7792d42bc8 100644 --- a/packages/server-e2e/test/client.test.ts +++ b/packages/klient/test/e2e/legacy/client.test.ts @@ -25,8 +25,8 @@ import { type SessionStatusResponse, } from '@moonshot-ai/protocol'; -import { DaemonClient, EnvelopeError } from '../src/index.js'; -import { fetchWithReport } from '../src/report.js'; +import { DaemonClient, EnvelopeError } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; import { createCaseLogger, errorForLog } from './log.js'; const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; diff --git a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts new file mode 100644 index 0000000000..71b8a2da2f --- /dev/null +++ b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts @@ -0,0 +1,147 @@ +/** + * Live-server invariant for uploaded image files in prompt content (v1 REST + * surface only — file upload has no `/api/v2` equivalent): + * - a missing prompt image `file_id` returns `FILE_NOT_FOUND`; + * - a non-image uploaded file used as image content returns `VALIDATION_FAILED`; + * - an uploaded PNG can be referenced by a prompt submission, and the + * prompt can be aborted (or was already terminal). + * + * Converted from the retired scenario `09-image-file-prompts.ts`. Skips when + * no server is reachable at `KIMI_SERVER_URL`. + */ +import { describe, expect, it } from 'vitest'; + +import { ErrorCode } from '@moonshot-ai/protocol'; + +import { DaemonClient, EnvelopeError } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const API_PREFIX = '/api/v1'; +const SHORT_TIMEOUT_MS = 15_000; + +const ONE_BY_ONE_PNG = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', + 'base64', +); + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +const reachable = await daemonReachable(); +const describeLive = reachable ? describe : describe.skip; + +describeLive('legacy: image file prompts', () => { + it('missing/non-image files rejected, PNG accepted, prompt abortable', async () => { + const log = createCaseLogger('legacy: image file prompts'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + const files: string[] = []; + let sid: string | undefined; + + async function expectEnvelopeCode( + action: () => Promise, + code: ErrorCode, + label: string, + ): Promise { + let caught: unknown; + try { + await action(); + } catch (error) { + caught = error; + } + expect(caught, label).toBeInstanceOf(EnvelopeError); + expect((caught as EnvelopeError).code, label).toBe(code); + log(label, { code: (caught as EnvelopeError).code }); + } + + try { + const session = await client.createSession({ + title: 'klient-e2e image file prompts', + metadata: { cwd: process.cwd(), scenario: 'image-file-prompts' }, + }); + sid = session.id; + log('session created', { session_id: sid }); + + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [{ type: 'image', source: { kind: 'file', file_id: 'file_missing_e2e' } }], + }), + ErrorCode.FILE_NOT_FOUND, + 'missing prompt image file_id', + ); + + const textFile = await client.uploadFile({ + name: 'not-an-image.txt', + data: 'not an image', + mediaType: 'text/plain', + }); + files.push(textFile.id); + await expectEnvelopeCode( + () => + client.submitPrompt(sid!, { + content: [{ type: 'image', source: { kind: 'file', file_id: textFile.id } }], + }), + ErrorCode.VALIDATION_FAILED, + 'non-image prompt file_id', + ); + + const png = await client.uploadFile({ + name: 'tiny.png', + data: ONE_BY_ONE_PNG, + mediaType: 'image/png', + }); + files.push(png.id); + expect(png.media_type).toBe('image/png'); + expect(png.size).toBe(ONE_BY_ONE_PNG.length); + + const submit = await client.submitPrompt(sid, { + content: [ + { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, + { type: 'image', source: { kind: 'file', file_id: png.id } }, + ], + }); + expect(submit.prompt_id.length).toBeGreaterThan(0); + log('prompt submitted', { file_id: png.id, prompt_id: submit.prompt_id }); + + try { + await client.abortPrompt(sid, submit.prompt_id); + log('prompt aborted', { prompt_id: submit.prompt_id }); + } catch (error) { + if ( + error instanceof EnvelopeError && + (error.code === ErrorCode.PROMPT_ALREADY_COMPLETED || + error.code === ErrorCode.PROMPT_NOT_FOUND) + ) { + log('prompt already terminal before abort', { prompt_id: submit.prompt_id }); + } else { + throw error; + } + } + await client.waitForSessionBusy(sid, false, { timeoutMs: SHORT_TIMEOUT_MS }); + } finally { + for (const fileId of files.toReversed()) { + try { + await client.deleteFile(fileId); + } catch { + // ignore + } + } + try { + if (sid) await client.archiveSession(sid); + } catch { + // ignore + } + await client.close(); + } + }, 120_000); +}); diff --git a/packages/server-e2e/test/log.ts b/packages/klient/test/e2e/legacy/log.ts similarity index 94% rename from packages/server-e2e/test/log.ts rename to packages/klient/test/e2e/legacy/log.ts index 6771042cd2..90cb236324 100644 --- a/packages/server-e2e/test/log.ts +++ b/packages/klient/test/e2e/legacy/log.ts @@ -1,6 +1,6 @@ import { onTestFailed, onTestFinished } from 'vitest'; -import { recordReportEvent, setActiveReportCase } from '../src/report.js'; +import { recordReportEvent, setActiveReportCase } from '../harness/report.js'; export function createCaseLogger(caseName: string): (label: string, value?: unknown) => void { setActiveReportCase(caseName); diff --git a/packages/server-e2e/test/prompt-queue-steer.test.ts b/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts similarity index 98% rename from packages/server-e2e/test/prompt-queue-steer.test.ts rename to packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts index 28455375ce..1ce57ab2be 100644 --- a/packages/server-e2e/test/prompt-queue-steer.test.ts +++ b/packages/klient/test/e2e/legacy/prompt-queue-steer.test.ts @@ -14,8 +14,8 @@ */ import { afterEach, describe, expect, it } from 'vitest'; -import { DaemonClient, type AnyFrame } from '../src/index.js'; -import { fetchWithReport } from '../src/report.js'; +import { DaemonClient, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; import { createCaseLogger } from './log.js'; const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; diff --git a/packages/server-e2e/test/refresh-replay.test.ts b/packages/klient/test/e2e/legacy/refresh-replay.test.ts similarity index 98% rename from packages/server-e2e/test/refresh-replay.test.ts rename to packages/klient/test/e2e/legacy/refresh-replay.test.ts index 6b906159dc..6d6feb4508 100644 --- a/packages/server-e2e/test/refresh-replay.test.ts +++ b/packages/klient/test/e2e/legacy/refresh-replay.test.ts @@ -29,8 +29,8 @@ import { afterEach, describe, expect, it } from 'vitest'; import { WebSocket as WsWebSocket } from 'ws'; -import { DaemonClient, WsClient, type AnyFrame } from '../src/index.js'; -import { fetchWithReport } from '../src/report.js'; +import { DaemonClient, WsClient, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; import { createCaseLogger } from './log.js'; const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; diff --git a/packages/server-e2e/test/report.test.ts b/packages/klient/test/e2e/legacy/report.test.ts similarity index 98% rename from packages/server-e2e/test/report.test.ts rename to packages/klient/test/e2e/legacy/report.test.ts index 8f83a36ebe..26d4a57642 100644 --- a/packages/server-e2e/test/report.test.ts +++ b/packages/klient/test/e2e/legacy/report.test.ts @@ -13,10 +13,10 @@ import { resetReportDir, setActiveReportCase, writeHtmlReport, -} from '../src/report'; -import { DaemonClient } from '../src/client'; -import { HttpClient } from '../src/http'; -import { WsClient } from '../src/ws'; +} from '../harness/report.js'; +import { DaemonClient } from '../harness/client.js'; +import { HttpClient } from '../harness/http.js'; +import { WsClient } from '../harness/ws.js'; import { createCaseLogger } from './log'; const tmpDirs: string[] = []; diff --git a/packages/server-e2e/test/report/vitest-reporter.ts b/packages/klient/test/e2e/legacy/report/vitest-reporter.ts similarity index 97% rename from packages/server-e2e/test/report/vitest-reporter.ts rename to packages/klient/test/e2e/legacy/report/vitest-reporter.ts index eeb9d793cf..5745a3a9eb 100644 --- a/packages/server-e2e/test/report/vitest-reporter.ts +++ b/packages/klient/test/e2e/legacy/report/vitest-reporter.ts @@ -6,7 +6,7 @@ import { recordReportEvent, resetReportDir, writeHtmlReport, -} from '../../src/report.js'; +} from '../../harness/report.js'; export default class DaemonE2eHtmlReporter implements Reporter { onTestRunStart(): void { diff --git a/packages/server-e2e/test/send-and-cancel.test.ts b/packages/klient/test/e2e/legacy/send-and-cancel.test.ts similarity index 99% rename from packages/server-e2e/test/send-and-cancel.test.ts rename to packages/klient/test/e2e/legacy/send-and-cancel.test.ts index bfac785362..1ac48f9e0a 100644 --- a/packages/server-e2e/test/send-and-cancel.test.ts +++ b/packages/klient/test/e2e/legacy/send-and-cancel.test.ts @@ -15,8 +15,8 @@ */ import { afterEach, describe, expect, it } from 'vitest'; -import { DaemonClient, EnvelopeError, type AnyFrame } from '../src/index.js'; -import { fetchWithReport } from '../src/report.js'; +import { DaemonClient, EnvelopeError, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; import { createCaseLogger, errorForLog } from './log.js'; const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; diff --git a/packages/server-e2e/test/session-resume.test.ts b/packages/klient/test/e2e/legacy/session-resume.test.ts similarity index 98% rename from packages/server-e2e/test/session-resume.test.ts rename to packages/klient/test/e2e/legacy/session-resume.test.ts index fb30f44689..459969177b 100644 --- a/packages/server-e2e/test/session-resume.test.ts +++ b/packages/klient/test/e2e/legacy/session-resume.test.ts @@ -18,8 +18,8 @@ */ import { afterEach, describe, expect, it } from 'vitest'; -import { DaemonClient } from '../src/index.js'; -import { fetchWithReport } from '../src/report.js'; +import { DaemonClient } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; import { createCaseLogger } from './log.js'; const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; diff --git a/packages/klient/test/e2e/legacy/terminal.test.ts b/packages/klient/test/e2e/legacy/terminal.test.ts new file mode 100644 index 0000000000..770ee714dd --- /dev/null +++ b/packages/klient/test/e2e/legacy/terminal.test.ts @@ -0,0 +1,172 @@ +/** + * Live-server invariant for the v1 terminal surface (REST + WS controls — no + * `/api/v2` equivalent): create/list/get a terminal, attach + input over WS, + * observe `terminal_output`, resize, close with `terminal_exit`. + * + * Converted from the retired scenario `11-terminal.ts`. Skips when no server + * is reachable at `KIMI_SERVER_URL`. + */ +import { describe, expect, it } from 'vitest'; + +import { DaemonClient, type AnyFrame } from '../harness/index.js'; +import { fetchWithReport } from '../harness/report.js'; +import { createCaseLogger } from './log.js'; + +const BASE_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; +const API_PREFIX = '/api/v1'; +const TERMINAL_SHELL = process.env['KIMI_SERVER_E2E_TERMINAL_SHELL'] ?? '/bin/sh'; +const OUTPUT_TIMEOUT_MS = 20_000; +const EXIT_TIMEOUT_MS = 5_000; +const CANARY = `KIMI_KLIENT_E2E_TERMINAL_${process.pid}_${Date.now()}`; + +async function daemonReachable(): Promise { + try { + const res = await fetchWithReport(`${BASE_URL}${API_PREFIX}/meta`, { + signal: AbortSignal.timeout(500), + }); + return res.ok; + } catch { + return false; + } +} + +const reachable = await daemonReachable(); +const describeLive = reachable ? describe : describe.skip; + +interface TerminalOutputPayload { + data: string; +} + +function isTerminalOutputFor(frame: AnyFrame, sid: string, terminalId: string): boolean { + const terminalFrame = frame as AnyFrame & { terminal_id?: string }; + return ( + frame.type === 'terminal_output' && + frame.session_id === sid && + terminalFrame.terminal_id === terminalId + ); +} + +function isTerminalExitFor(sid: string, terminalId: string): (frame: AnyFrame) => boolean { + return (frame) => { + const terminalFrame = frame as AnyFrame & { terminal_id?: string }; + return ( + frame.type === 'terminal_exit' && + frame.session_id === sid && + terminalFrame.terminal_id === terminalId + ); + }; +} + +function toShellOctalEscapes(value: string): string { + return Array.from(value) + .map((char) => `\\${char.codePointAt(0)!.toString(8).padStart(3, '0')}`) + .join(''); +} + +function payloadOf(frame: AnyFrame): T { + if (frame.payload === undefined) throw new Error(`${frame.type} frame should carry payload`); + return frame.payload as T; +} + +describeLive('legacy: terminal controls', () => { + it('terminal output, resize, and close round-trip', async () => { + const log = createCaseLogger('legacy: terminal controls'); + const client = new DaemonClient({ baseUrl: BASE_URL }); + + let sid: string | undefined; + let terminalId: string | undefined; + let terminalClosed = false; + + try { + const session = await client.createSession({ + title: 'klient-e2e terminal', + metadata: { cwd: process.cwd(), scenario: 'terminal' }, + }); + sid = session.id; + const sessionId = session.id; + log('session created', { session_id: sessionId }); + + await client.connect(); + await client.subscribe(sessionId); + + const terminal = await client.createTerminal(sessionId, { + shell: TERMINAL_SHELL, + cols: 80, + rows: 24, + }); + terminalId = terminal.id; + expect(terminal.session_id).toBe(sessionId); + expect(terminal.status).toBe('running'); + expect(terminal.cols).toBe(80); + expect(terminal.rows).toBe(24); + log('terminal created', { id: terminal.id, shell: terminal.shell }); + + const listed = await client.listTerminals(sessionId); + expect(listed.items.some((item) => item.id === terminal.id)).toBe(true); + + const observedOutput: string[] = []; + const unsubscribe = client.onFrame((frame) => { + if (!isTerminalOutputFor(frame, sessionId, terminal.id)) return; + observedOutput.push(payloadOf(frame).data); + }); + + try { + const attach = await client.attachTerminal(sessionId, terminal.id, { sinceSeq: 0 }); + expect(attach.attached).toBe(true); + expect(typeof attach.replayed).toBe('number'); + log('terminal attached', attach); + + const input = `printf '%b\\n' '${toShellOctalEscapes(CANARY)}'\n`; + const inputAck = await client.writeTerminalInput(sessionId, terminal.id, input); + expect(inputAck).toEqual({ accepted: true }); + + const deadline = Date.now() + OUTPUT_TIMEOUT_MS; + let text = ''; + while (Date.now() < deadline) { + text = observedOutput.join(''); + if (text.includes(CANARY)) break; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + expect(text).toContain(CANARY); + log('terminal output observed', { matched: CANARY }); + + const resizeAck = await client.resizeTerminal(sessionId, terminal.id, 100, 31); + expect(resizeAck).toEqual({ resized: true }); + const resized = await client.getTerminal(sessionId, terminal.id); + expect(resized.cols).toBe(100); + expect(resized.rows).toBe(31); + log('terminal resized', { cols: resized.cols, rows: resized.rows }); + + const exitFramePromise = client.waitForFrame(isTerminalExitFor(sessionId, terminal.id), { + timeoutMs: EXIT_TIMEOUT_MS, + }); + const closeAck = await client.closeTerminalControl(sessionId, terminal.id); + terminalClosed = true; + expect(closeAck).toEqual({ closed: true }); + await exitFramePromise; + log('terminal exit frame received', {}); + + const closed = await client.getTerminal(sessionId, terminal.id); + expect(closed.status).toBe('exited'); + } finally { + unsubscribe(); + } + } finally { + if (sid !== undefined && terminalId !== undefined && !terminalClosed) { + try { + await client.closeTerminal(sid, terminalId); + } catch { + // ignore + } + } + try { + if (sid) await client.archiveSession(sid); + } catch { + // ignore + } + await client.close(); + } + }, 60_000); +}); diff --git a/packages/server-e2e/test/v2/channelRegistry.test.ts b/packages/klient/test/e2e/v2/channelRegistry.test.ts similarity index 100% rename from packages/server-e2e/test/v2/channelRegistry.test.ts rename to packages/klient/test/e2e/v2/channelRegistry.test.ts diff --git a/packages/server-e2e/src/v2/token.ts b/packages/klient/test/e2e/v2/helpers/token.ts similarity index 96% rename from packages/server-e2e/src/v2/token.ts rename to packages/klient/test/e2e/v2/helpers/token.ts index 4d03de4b65..cd5599a8d7 100644 --- a/packages/server-e2e/src/v2/token.ts +++ b/packages/klient/test/e2e/v2/helpers/token.ts @@ -38,9 +38,9 @@ export async function resolveKimiHome(homeDir?: string): Promise { const envHome = typeof process !== 'undefined' ? process.env?.['KIMI_CODE_HOME'] : undefined; if (envHome !== undefined && envHome.length > 0) return envHome; - if (!isNode()) return `.${'kimi-code'}`; + if (!isNode()) return '.kimi-code'; const [{ homedir }, path] = await Promise.all([import('node:os'), import('node:path')]); - return path.join(homedir(), `.${'kimi-code'}`); + return path.join(homedir(), '.kimi-code'); } /** Absolute path of the persistent token file for a given home dir. */ @@ -66,8 +66,8 @@ export async function loadLocalServerToken(homeDir?: string): Promise( + baseUrl: string, + token: string, + path: string, + args: unknown[], +): Promise { + const res = await fetch(`${baseUrl}/api/v2${path}`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify(args), + }); + const envelope = (await res.json()) as { code: number; msg: string; data: T }; + if (envelope.code !== 0) { + throw new Error(`v2 ${path} failed: ${envelope.code} ${envelope.msg}`); + } + return envelope.data; +} + +/** Session creation goes through the legacy `/api/v1` REST surface. */ +async function v1CreateSession(baseUrl: string, token: string, cwd: string): Promise { + const res = await fetch(`${baseUrl}/api/v1/sessions`, { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ metadata: { cwd } }), + }); + const envelope = (await res.json()) as { code: number; msg: string; data: { id: string } }; + if (envelope.code !== 0) { + throw new Error(`v1 createSession failed: ${envelope.code} ${envelope.msg}`); + } + return envelope.data.id; +} + +describe('Klient (server-v2 smoke)', () => { + let server: RunningServer | undefined; + let home: string | undefined; + let klient: Klient | undefined; + let baseUrl: string; + let token: string; + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'kimi-sdk-smoke-')); + server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); + baseUrl = `http://127.0.0.1:${server.port}`; + token = server.authTokenService.getToken(); + klient = createKlient({ url: baseUrl, token }); + }); + + afterEach(async () => { + await klient?.close(); + klient = undefined; + if (server) { + await server.close(); + server = undefined; + } + if (home) { + await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); + home = undefined; + } + }); + + async function createSession(cwd: string): Promise { + return v1CreateSession(baseUrl, token, cwd); + } + + async function createMainAgent(sessionId: string): Promise { + const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); + if (session === undefined) throw new Error(`session ${sessionId} not found`); + await ensureMainAgent(session); + } + + it('lists sessions (global facade)', async () => { + await createSession(home as string); + const page = await klient!.global.sessions.list({ limit: 20 }); + expect(page.items.length).toBeGreaterThanOrEqual(1); + }); + + it('creates and reads a workspace (global facade)', async () => { + const workspaces = klient!.global.workspaces; + const created = await workspaces.createOrTouch({ root: home as string }); + expect(created.root).toBe(home); + + const got = await workspaces.get(created.id); + expect(got?.root).toBe(home); + }); + + it('reads, renames, and archives a session (session scope, direct wire)', async () => { + const sid = await createSession(home as string); + + const before = await v2Rpc<{ id: string; title?: string }>( + baseUrl, + token, + `/session/${sid}/sessionMetadata/read`, + [], + ); + expect(before.id).toBe(sid); + + await v2Rpc(baseUrl, token, `/session/${sid}/sessionMetadata/setTitle`, ['renamed']); + const after = await v2Rpc<{ title?: string }>( + baseUrl, + token, + `/session/${sid}/sessionMetadata/read`, + [], + ); + expect(after.title).toBe('renamed'); + + const pending = await v2Rpc( + baseUrl, + token, + `/session/${sid}/sessionInteractionService/listPending`, + [], + ); + expect(pending).toEqual([]); + + await v2Rpc(baseUrl, token, `/session/${sid}/sessionLifecycleService/archive`, [sid]); + }); + + it('submits a prompt and runs a shell command (agent scope, direct wire)', async () => { + const sid = await createSession(home as string); + await createMainAgent(sid); + + const submitted = await v2Rpc<{ turn_id?: number } | undefined>( + baseUrl, + token, + `/session/${sid}/agent/main/agentRPCService/prompt`, + [{ input: [{ type: 'text', text: 'hello' }] }], + ); + expect(typeof submitted?.turn_id).toBe('number'); + + const shell = await v2Rpc<{ stdout: string; stderr: string }>( + baseUrl, + token, + `/session/${sid}/agent/main/agentRPCService/runShellCommand`, + [{ command: 'printf hello' }], + ); + expect(shell.stdout).toBe('hello'); + expect(shell.stderr).toBe(''); + }); + + it('streams agent events over ws (raw frames)', async () => { + const sid = await createSession(home as string); + await createMainAgent(sid); + + const wsUrl = `${baseUrl.replace(/^http/, 'ws')}/api/v2/ws`; + const ws = new WebSocket(wsUrl, [`kimi-code.bearer.${token}`]); + const received: unknown[] = []; + ws.onmessage = (event) => { + const frame = JSON.parse(String(event.data)) as { type: string; data?: unknown }; + if (frame.type === 'event') received.push(frame.data); + }; + await new Promise((resolve, reject) => { + ws.onopen = () => { + resolve(); + }; + ws.onerror = () => { + reject(new Error('ws connect failed')); + }; + }); + ws.send(JSON.stringify({ type: 'hello', token })); + ws.send( + JSON.stringify({ + type: 'listen', + id: 'l1', + scope: 'agent', + sessionId: sid, + agentId: 'main', + event: 'events', + }), + ); + + await v2Rpc(baseUrl, token, `/session/${sid}/agent/main/agentRPCService/prompt`, [ + { input: [{ type: 'text', text: 'hi' }] }, + ]); + + const deadline = Date.now() + 10_000; + while (received.length === 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 50)); + } + ws.close(); + expect(received.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/server-e2e/test/v2/token.test.ts b/packages/klient/test/e2e/v2/token.test.ts similarity index 99% rename from packages/server-e2e/test/v2/token.test.ts rename to packages/klient/test/e2e/v2/token.test.ts index 4be56daa9e..b8b645160c 100644 --- a/packages/server-e2e/test/v2/token.test.ts +++ b/packages/klient/test/e2e/v2/token.test.ts @@ -17,7 +17,7 @@ import { loadLocalServerToken, resolveKimiHome, serverTokenPath, -} from '../../src/v2/token.js'; +} from './helpers/token.js'; describe('local token discovery', () => { let home: string | undefined; diff --git a/packages/klient/test/facade.test.ts b/packages/klient/test/facade.test.ts new file mode 100644 index 0000000000..2dc3559564 --- /dev/null +++ b/packages/klient/test/facade.test.ts @@ -0,0 +1,208 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { + EventSourceRef, + IDisposable, + KlientChannel, + ScopeRef, +} from '../src/core/channel.js'; +import { createKlientFromChannel } from '../src/core/klient.js'; +import { KlientValidationError } from '../src/core/validation.js'; + +const tick = (ms = 0): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +/** Records calls, replays scripted results, and captures listen subscriptions. */ +class FakeChannel implements KlientChannel { + readonly calls: Array<{ scope: ScopeRef; service: string; method: string; args: unknown[] }> = []; + readonly subscriptions: Array<{ source: EventSourceRef; dispose: ReturnType }> = + []; + result: unknown; + /** Keyed `${service}.${method}` result overrides. */ + readonly results = new Map(); + private readonly handlers = new Map void>(); + private nextSub = 0; + + call(scope: ScopeRef, service: string, method: string, args: unknown[]): Promise { + this.calls.push({ scope, service, method, args }); + const key = `${service}.${method}`; + return Promise.resolve(this.results.has(key) ? this.results.get(key) : this.result); + } + + listen(_scope: ScopeRef, source: EventSourceRef, handler: (data: unknown) => void): IDisposable { + const id = this.nextSub; + this.nextSub += 1; + this.handlers.set(id, handler); + const dispose = vi.fn(() => { + this.handlers.delete(id); + }); + this.subscriptions.push({ source, dispose }); + return { dispose }; + } + + /** Push a raw payload into the Nth subscription (0-based). */ + emit(index: number, data: unknown): void { + this.handlers.get(index)?.(data); + } + + close(): Promise { + return Promise.resolve(); + } +} + +const SUMMARY = { + id: 's1', + workspaceId: 'w1', + createdAt: 1, + updatedAt: 2, + archived: false, +}; + +describe('facade routing', () => { + it('reshapes single-object params into positional wire args', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + + channel.result = { id: 'w1', root: '/x', name: 'n', createdAt: 1, lastOpenedAt: 2 }; + await klient.global.workspaces.createOrTouch({ root: '/x', name: 'n' }); + expect(channel.calls[0]).toMatchObject({ + service: 'workspaceRegistry', + method: 'createOrTouch', + args: ['/x', 'n'], + }); + + channel.result = undefined; // void output + await klient.global.plugins.setMcpServerEnabled({ id: 'p', server: 's', enabled: true }); + expect(channel.calls[1]).toMatchObject({ + service: 'pluginService', + method: 'setPluginMcpServerEnabled', + args: [{ id: 'p', server: 's', enabled: true }], + }); + + channel.results.set('oauthService.status', { loggedIn: false }); + await klient.global.auth.status(); + expect(channel.calls[2]).toMatchObject({ + service: 'oauthService', + method: 'status', + args: [undefined], + }); + }); + + it('env() fans out property reads and merges them', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + channel.result = 'v'; + const env = await klient.global.env(); + expect(env.platform).toBe('v'); + expect(env.logsDir).toBe('v'); + expect(channel.calls).toHaveLength(12); + expect(channel.calls.every((call) => call.service === 'bootstrapService')).toBe(true); + }); + + it('env() resolves once and serves repeats from the cache', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + channel.result = 'v'; + await klient.global.env(); + expect(channel.calls).toHaveLength(12); + + const again = await klient.global.env(); + expect(again.platform).toBe('v'); + expect(channel.calls).toHaveLength(12); + }); +}); + +describe('contract validation', () => { + it('rejects invalid input before the call leaves the client', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + await expect( + klient.global.sessions.list({ limit: '20' as unknown as number }), + ).rejects.toBeInstanceOf(KlientValidationError); + expect(channel.calls).toHaveLength(0); + }); + + it('rejects drifted output payloads', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + channel.result = { id: 's1' }; // missing required SessionSummary fields + await expect(klient.global.sessions.get('s1')).rejects.toBeInstanceOf(KlientValidationError); + }); + + it('passes valid payloads through and returns parsed output', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + channel.result = SUMMARY; + await expect(klient.global.sessions.get('s1')).resolves.toEqual(SUMMARY); + }); + + it('validate:false skips both directions', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel, { validate: false }); + channel.result = { anything: true }; + await expect( + klient.global.sessions.list({ limit: '20' as unknown as number }), + ).resolves.toEqual({ anything: true }); + }); +}); + +describe('event hub', () => { + it('maps public names to emitter sources and validates payloads', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const seen: unknown[] = []; + const errors: Error[] = []; + klient.events.onError((error) => { + errors.push(error); + }); + + klient.events.on('providers.changed', (event) => seen.push(event)); + expect(channel.subscriptions[0]?.source).toEqual({ + kind: 'emitter', + service: 'providerService', + event: 'onDidChangeProviders', + }); + + channel.emit(0, { added: ['p1'], removed: [], changed: [] }); + channel.emit(0, { added: 'not-an-array' }); + await tick(); + expect(seen).toEqual([{ added: ['p1'], removed: [], changed: [] }]); + expect(errors).toHaveLength(1); + expect(errors[0]).toBeInstanceOf(KlientValidationError); + }); + + it('shares one bus subscription across bus-derived events and filters by type', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const archived: unknown[] = []; + const catalog: unknown[] = []; + + const subA = klient.events.on('session.archived', (event) => archived.push(event)); + const subB = klient.events.on('catalog.changed', (event) => catalog.push(event)); + expect(channel.subscriptions).toHaveLength(1); + expect(channel.subscriptions[0]?.source).toEqual({ kind: 'stream', name: 'events' }); + + channel.emit(0, { type: 'event.session.archived', payload: { sessionId: 's1' } }); + channel.emit(0, { type: 'event.model_catalog.changed', payload: { changed: [], unchanged: [], failed: [] } }); + channel.emit(0, { type: 'unrelated.type', payload: {} }); + await tick(); + expect(archived).toEqual([{ sessionId: 's1' }]); + expect(catalog).toEqual([{ changed: [], unchanged: [], failed: [] }]); + + subA.dispose(); + expect(channel.subscriptions[0]?.dispose).not.toHaveBeenCalled(); + subB.dispose(); + expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1); + }); + + it('disposes the emitter subscription when the last listener detaches', async () => { + const channel = new FakeChannel(); + const klient = createKlientFromChannel(channel); + const a = klient.events.on('config.changed', () => undefined); + const b = klient.events.on('config.changed', () => undefined); + expect(channel.subscriptions).toHaveLength(1); + a.dispose(); + expect(channel.subscriptions[0]?.dispose).not.toHaveBeenCalled(); + b.dispose(); + expect(channel.subscriptions[0]?.dispose).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/klient/test/helpers/conformance.ts b/packages/klient/test/helpers/conformance.ts new file mode 100644 index 0000000000..a2d8d8d737 --- /dev/null +++ b/packages/klient/test/helpers/conformance.ts @@ -0,0 +1,171 @@ +/** + * Shared conformance suite — the guarantee that the http, ipc, and memory + * transports are interchangeable. Every transport test file runs the exact + * same assertions against a real backend (in-process engine for memory/ipc, + * a booted kap-server for http); only the `before` setup differs per file. + */ + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import type { Klient } from '../../src/index.js'; + +export interface KlientConformanceTarget { + readonly klient: Klient; + cleanup(): Promise; +} + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate()) return; + await new Promise((resolve) => { + setTimeout(resolve, 25); + }); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +} + +export function defineKlientConformance( + transport: string, + makeTarget: () => Promise, +): void { + describe(`klient conformance: ${transport}`, () => { + let target: KlientConformanceTarget; + + beforeAll(async () => { + target = await makeTarget(); + }); + + afterAll(async () => { + await target.cleanup(); + }); + + it('env() aggregates the host snapshot', async () => { + const env = await target.klient.global.env(); + expect(env.platform).toBe(process.platform); + expect(env.homeDir.length).toBeGreaterThan(0); + expect(env.clientVersion.length).toBeGreaterThan(0); + }); + + it('workspaces round-trip through create/get/update/list/delete', async () => { + const workspaces = target.klient.global.workspaces; + const created = await workspaces.createOrTouch({ root: process.cwd(), name: 'conformance' }); + expect(created.id.length).toBeGreaterThan(0); + + const fetched = await workspaces.get(created.id); + expect(fetched?.name).toBe('conformance'); + + const updated = await workspaces.update({ id: created.id, patch: { name: 'conformance-2' } }); + expect(updated?.name).toBe('conformance-2'); + + const list = await workspaces.list(); + expect(list.some((w) => w.id === created.id)).toBe(true); + + await workspaces.delete(created.id); + expect(await workspaces.get(created.id)).toBeUndefined(); + }); + + it('sessions index responds with a page shape', async () => { + const page = await target.klient.global.sessions.list({}); + expect(Array.isArray(page.items)).toBe(true); + const count = await target.klient.global.sessions.countActive('no-such-workspace'); + expect(typeof count).toBe('number'); + }); + + it('providers.set/get/delete works and emits providers.changed', async () => { + const events: Array<{ + added: readonly string[]; + removed: readonly string[]; + changed: readonly string[]; + }> = []; + const errors: Error[] = []; + target.klient.events.onError((error) => { + errors.push(error); + }); + const sub = target.klient.events.on('providers.changed', (event) => { + events.push(event); + }); + // Give the subscription a wire round-trip (memory is synchronous; ipc + // and http's lazy WS need a frame exchange). + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + + const name = '__klient_conformance__'; + try { + await target.klient.global.providers.set({ name, config: { apiKey: 'conf-key' } }); + const got = await target.klient.global.providers.get(name); + expect(got?.apiKey).toBe('conf-key'); + + await waitFor( + () => events.some((event) => [...event.added, ...event.changed].includes(name)), + 5_000, + ); + } finally { + await target.klient.global.providers.delete(name); + sub.dispose(); + } + expect(errors).toEqual([]); + }); + + it('config reads respond', async () => { + const all = await target.klient.global.config.getAll(); + expect(typeof all).toBe('object'); + expect(Array.isArray(await target.klient.global.config.diagnostics())).toBe(true); + }); + + it('hostFs.home() returns the host home and recent roots', async () => { + const home = await target.klient.global.hostFs.home(); + expect(home.home.length).toBeGreaterThan(0); + expect(Array.isArray(home.recent_roots)).toBe(true); + + const browse = await target.klient.global.hostFs.browse(home.home); + expect(browse.path).toBe(home.home); + expect(Array.isArray(browse.entries)).toBe(true); + }); + + it('catalog lists models/providers and models registry round-trips', async () => { + const catalog = target.klient.global.catalog; + expect(Array.isArray(await catalog.listModels())).toBe(true); + expect(Array.isArray(await catalog.listProviders())).toBe(true); + + const models = target.klient.global.models; + const events: Array<{ + added: readonly string[]; + removed: readonly string[]; + changed: readonly string[]; + }> = []; + const sub = target.klient.events.on('models.changed', (event) => { + events.push(event); + }); + // See providers.changed above — give the subscription a wire round-trip. + await new Promise((resolve) => { + setTimeout(resolve, 300); + }); + + const id = '__klient_conformance__'; + try { + await models.set({ id, config: { name: 'conf-model', model: 'conf-model' } }); + const got = await models.get(id); + expect(got?.name).toBe('conf-model'); + + await waitFor( + () => events.some((event) => [...event.added, ...event.changed].includes(id)), + 5_000, + ); + } finally { + await models.delete(id); + sub.dispose(); + } + }); + + it('flags / plugins / auth read models respond', async () => { + expect(Array.isArray(await target.klient.global.flags.list())).toBe(true); + expect(Array.isArray(await target.klient.global.flags.enabledIds())).toBe(true); + expect(typeof await target.klient.global.flags.snapshot()).toBe('object'); + expect(Array.isArray(await target.klient.global.plugins.list())).toBe(true); + const status = await target.klient.global.auth.status(); + expect(typeof status.loggedIn).toBe('boolean'); + }); + }); +} diff --git a/packages/klient/test/helpers/dual.ts b/packages/klient/test/helpers/dual.ts new file mode 100644 index 0000000000..f4cf8dbb14 --- /dev/null +++ b/packages/klient/test/helpers/dual.ts @@ -0,0 +1,164 @@ +/** + * Dual-backend session/agent suite helper — runs the exact same test body + * against an in-memory engine and an in-process kap-server, one nested + * `describe` per backend. Model-requiring suites are skipped unless the + * model env is present (self-contained: the model is seeded into each + * backend's temp home through the klient facade itself). + * + * Env: + * KIMI_E2E_MODEL — model id the gateway serves (e.g. `kimi-k2`) + * KIMI_E2E_API_KEY — API key for the gateway + * KIMI_E2E_BASE_URL — optional base URL (OpenAI-compatible endpoint) + * KIMI_E2E_PROTOCOL — optional wire protocol (default `openai`) + */ + +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterAll, beforeAll, describe } from 'vitest'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; +import { startServer, type RunningServer } from '@moonshot-ai/kap-server'; + +import type { Klient } from '../../src/index.js'; +import type { KlientEvents } from '../../src/core/events/hub.js'; +import type { ModelConfig } from '@moonshot-ai/agent-core-v2/app/model/model'; +import { createKlient as createMemoryKlient } from '../../src/transports/memory/index.js'; +import { createKlient as createHttpKlient } from '../../src/transports/http/index.js'; + +export type DualBackend = 'memory' | 'http'; + +export interface DualTarget { + readonly klient: Klient; + cleanup(): Promise; +} + +export interface ModelEnv { + readonly model: string; + readonly apiKey: string; + readonly baseUrl?: string; + readonly protocol?: ModelConfig['protocol']; +} + +export function modelEnv(): ModelEnv | undefined { + const model = process.env['KIMI_E2E_MODEL']; + const apiKey = process.env['KIMI_E2E_API_KEY']; + if (model === undefined || apiKey === undefined) return undefined; + return { + model, + apiKey, + baseUrl: process.env['KIMI_E2E_BASE_URL'], + protocol: process.env['KIMI_E2E_PROTOCOL'] as ModelConfig['protocol'], + }; +} + +/** Model id registered in the backend for the suite; agents `setModel` to it. */ +export const DUAL_MODEL_ID = 'e2e-dual-model'; + +async function makeTarget(backend: DualBackend, model: ModelEnv | undefined): Promise { + const homeDir = await mkdtemp(join(tmpdir(), `klient-dual-${backend}-`)); + let app: ReturnType['app'] | undefined; + let server: RunningServer | undefined; + let klient: Klient; + if (backend === 'memory') { + ({ app } = bootstrap({ homeDir }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ])); + klient = createMemoryKlient({ scope: app }); + } else { + server = await startServer({ host: '127.0.0.1', port: 0, homeDir, logLevel: 'silent' }); + klient = createHttpKlient({ + url: `http://127.0.0.1:${server.port}`, + token: server.authTokenService.getToken(), + }); + } + if (model !== undefined) { + await klient.global.models.set({ + id: DUAL_MODEL_ID, + config: { + model: model.model, + apiKey: model.apiKey, + baseUrl: model.baseUrl, + protocol: model.protocol ?? 'openai', + maxContextSize: 262_144, + }, + }); + } + return { + klient, + cleanup: async () => { + await klient.close(); + if (server !== undefined) await server.close(); + app?.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }, + }; +} + +export interface DualSuiteOptions { + /** Suite needs a working model; skipped when the env above is absent. */ + readonly requiresModel?: boolean; +} + +/** Poll an async predicate until it holds (or throw). */ +export async function waitFor( + predicate: () => Promise | boolean, + timeoutMs: number, + intervalMs = 100, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (await predicate()) return; + await new Promise((resolve) => { + setTimeout(resolve, intervalMs); + }); + } + throw new Error(`waitFor timed out after ${timeoutMs}ms`); +} + +/** Resolve with the first payload of `name` (or reject on timeout). */ +export function onceEvent( + events: KlientEvents, + name: E, + timeoutMs = 60_000, +): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + sub.dispose(); + reject(new Error(`timed out waiting for event ${name}`)); + }, timeoutMs); + const sub = events.on(name, (payload) => { + clearTimeout(timer); + sub.dispose(); + resolve(payload); + }); + }); +} + +/** + * `body` registers `it`s for BOTH backends; it receives a `klient` accessor + * valid inside tests (setup happens in `beforeAll`). + */ +export function defineDualSuite( + name: string, + options: DualSuiteOptions, + body: (ctx: { readonly backend: DualBackend; klient: () => Klient }) => void, +): void { + const model = modelEnv(); + const skip = options.requiresModel === true && model === undefined; + describe.skipIf(skip)(`dual: ${name}`, () => { + for (const backend of ['memory', 'http'] as const) { + describe(backend, () => { + let target: DualTarget; + beforeAll(async () => { + target = await makeTarget(backend, model); + }); + afterAll(async () => { + await target.cleanup(); + }); + body({ backend, klient: () => target.klient }); + }); + } + }); +} diff --git a/packages/klient/test/helpers/engine.ts b/packages/klient/test/helpers/engine.ts new file mode 100644 index 0000000000..bdb2904a4c --- /dev/null +++ b/packages/klient/test/helpers/engine.ts @@ -0,0 +1,26 @@ +/** + * Shared engine bootstrap for klient integration tests. Mirrors what + * kap-server does: `bootstrap()` plus the `ILogOptions` seed the + * Session-scoped log writer needs (bare `bootstrap({ homeDir })` leaves + * `logOptions` unregistered and any eager service depending on `ILogService` + * fails to instantiate). + */ + +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { bootstrap, logSeed, resolveLoggingConfig } from '@moonshot-ai/agent-core-v2'; + +export interface TestEngine { + readonly homeDir: string; + readonly app: ReturnType['app']; +} + +export async function makeEngine(prefix = 'klient-test-engine-'): Promise { + const homeDir = await mkdtemp(join(tmpdir(), prefix)); + const { app } = bootstrap({ homeDir }, [ + ...logSeed(resolveLoggingConfig({ homeDir, env: process.env })), + ]); + return { homeDir, app }; +} diff --git a/packages/klient/test/helpers/typeAssert.ts b/packages/klient/test/helpers/typeAssert.ts new file mode 100644 index 0000000000..f734368b1c --- /dev/null +++ b/packages/klient/test/helpers/typeAssert.ts @@ -0,0 +1,15 @@ +import type { z } from 'zod'; + +export type MutableDeep = T extends readonly (infer U)[] + ? MutableDeep[] + : T extends object + ? { -readonly [K in keyof T]: MutableDeep } + : T; + +/** Both-ways assignability between a zod schema's infer and an engine type. */ +export type AssertWire = ([ + z.infer, +] extends [MutableDeep] + ? true + : never) & + ([MutableDeep] extends [z.infer] ? true : never); diff --git a/packages/klient/test/http-conformance.test.ts b/packages/klient/test/http-conformance.test.ts new file mode 100644 index 0000000000..285eb82994 --- /dev/null +++ b/packages/klient/test/http-conformance.test.ts @@ -0,0 +1,36 @@ +/** + * The http leg of the transport conformance suite — boots a real kap-server + * in-process (port 0) and runs the shared `defineKlientConformance` + * assertions over the http transport (events riding the lazy WS bridge). + * The memory/ipc legs live in `memory.test.ts` / `ipc.test.ts`. + */ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { startServer } from '@moonshot-ai/kap-server'; + +import { createKlient } from '../src/transports/http/index.js'; +import { defineKlientConformance } from './helpers/conformance.js'; + +defineKlientConformance('http', async () => { + const homeDir = await mkdtemp(join(tmpdir(), 'klient-conformance-http-')); + const server = await startServer({ + host: '127.0.0.1', + port: 0, + homeDir, + logLevel: 'silent', + }); + const klient = createKlient({ + url: `http://127.0.0.1:${server.port}`, + token: server.authTokenService.getToken(), + }); + return { + klient, + cleanup: async () => { + await klient.close(); + await server.close(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }, + }; +}); diff --git a/packages/klient/test/http.test.ts b/packages/klient/test/http.test.ts new file mode 100644 index 0000000000..57b95a1d9e --- /dev/null +++ b/packages/klient/test/http.test.ts @@ -0,0 +1,209 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createKlient } from '../src/transports/http/index.js'; +import type { WsLike, WsLikeCtor } from '../src/transports/ws/wsSocket.js'; +import { KlientValidationError } from '../src/core/validation.js'; + +const tick = (ms = 0): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +type Listener = (event: never) => void; + +/** Minimal fake WS endpoint: records frames and lets the test push events. */ +class FakeWsServer { + readonly frames: Record[] = []; + lastUrl = ''; + lastProtocols: string[] | undefined; + private socket: FakeSocket | undefined; + + attach(socket: FakeSocket): void { + this.socket = socket; + queueMicrotask(() => { + socket.readyState = FakeSocket.OPEN; + socket.fire('open'); + this.send({ type: 'ready' }); + }); + } + + receive(raw: string): void { + const frame = JSON.parse(raw) as Record; + this.frames.push(frame); + if (frame['type'] === 'listen') { + this.send({ type: 'listen_result', id: frame['id'] }); + } + } + + pushEvent(data: unknown): void { + const listen = this.frames.find((frame) => frame['type'] === 'listen')!; + this.send({ type: 'event', id: listen['id'], data }); + } + + private send(frame: Record): void { + this.socket?.deliver(frame); + } +} + +class FakeSocket implements WsLike { + static readonly OPEN = 1; + readyState = 0; + private readonly handlers = new Map>(); + + constructor( + private readonly server: FakeWsServer, + url: string, + protocols?: string | string[], + ) { + server.lastUrl = url; + server.lastProtocols = Array.isArray(protocols) ? protocols : protocols ? [protocols] : undefined; + server.attach(this); + } + + addEventListener(type: string, listener: Listener): void { + const set = this.handlers.get(type) ?? new Set(); + set.add(listener); + this.handlers.set(type, set); + } + + send(data: string): void { + this.server.receive(data); + } + + close(): void { + this.readyState = 3; + this.fire('close'); + } + + fire(type: string): void { + for (const handler of this.handlers.get(type) ?? []) handler(undefined as never); + } + + deliver(frame: Record): void { + queueMicrotask(() => { + for (const handler of this.handlers.get('message') ?? []) { + handler({ data: JSON.stringify(frame) } as never); + } + }); + } +} + +function fakeCtor(server: FakeWsServer): WsLikeCtor { + class BoundFakeSocket extends FakeSocket { + constructor(url: string, protocols?: string | string[]) { + super(server, url, protocols); + } + } + return BoundFakeSocket as unknown as WsLikeCtor; +} + +function jsonResponse(envelope: Record): Response { + return { json: () => Promise.resolve(envelope) } as unknown as Response; +} + +function okEnvelope(data: unknown): Record { + return { code: 0, msg: '', data, request_id: 'r1' }; +} + +describe('http transport', () => { + it('POSTs the args tuple to the scope service/method URL with the bearer header', async () => { + const fetchMock = vi.fn(() => + Promise.resolve( + jsonResponse( + okEnvelope({ + id: 's1', + workspaceId: 'w1', + createdAt: 1, + updatedAt: 2, + archived: false, + }), + ), + ), + ); + const klient = createKlient({ + url: 'http://127.0.0.1:58627/', + token: 'tok', + fetch: fetchMock as unknown as typeof fetch, + }); + + const summary = await klient.global.sessions.get('s1'); + expect(summary?.id).toBe('s1'); + expect(fetchMock).toHaveBeenCalledTimes(1); + const [url, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(url).toBe('http://127.0.0.1:58627/api/v2/sessionIndex/get'); + expect(init.method).toBe('POST'); + expect((init.headers as Record)['authorization']).toBe('Bearer tok'); + expect(JSON.parse(init.body as string)).toEqual(['s1']); + await klient.close(); + }); + + it('omits the body for zero-arg calls', async () => { + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse(okEnvelope({})))); + const klient = createKlient({ + url: 'http://127.0.0.1:58627', + fetch: fetchMock as unknown as typeof fetch, + }); + await klient.global.config.getAll(); + const [, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit]; + expect(init.body).toBeUndefined(); + await klient.close(); + }); + + it('unwraps a non-zero envelope code into RPCError', async () => { + const fetchMock = vi.fn(() => + Promise.resolve(jsonResponse({ code: 40001, msg: 'workspace not found', data: null, request_id: 'r' })), + ); + const klient = createKlient({ + url: 'http://127.0.0.1:58627', + fetch: fetchMock as unknown as typeof fetch, + }); + await expect(klient.global.workspaces.get('nope')).rejects.toMatchObject({ + name: 'RPCError', + code: 40001, + }); + await klient.close(); + }); + + it('rejects drifted output with KlientValidationError', async () => { + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse(okEnvelope({ id: 42 })))); + const klient = createKlient({ + url: 'http://127.0.0.1:58627', + fetch: fetchMock as unknown as typeof fetch, + }); + await expect(klient.global.sessions.get('s1')).rejects.toBeInstanceOf(KlientValidationError); + await klient.close(); + }); + + it('lazily opens one WS for events, sends emitter listen frames, and validates payloads', async () => { + const server = new FakeWsServer(); + const fetchMock = vi.fn(() => Promise.resolve(jsonResponse(okEnvelope({})))); + const klient = createKlient({ + url: 'http://127.0.0.1:58627', + token: 'tok', + fetch: fetchMock as unknown as typeof fetch, + WebSocketImpl: fakeCtor(server), + }); + + const seen: unknown[] = []; + const errors: Error[] = []; + klient.events.onError((error) => { + errors.push(error); + }); + klient.events.on('providers.changed', (event) => seen.push(event)); + await tick(10); + + expect(server.lastUrl).toBe('ws://127.0.0.1:58627/api/v2/ws'); + expect(server.lastProtocols).toEqual(['kimi-code.bearer.tok']); + const listen = server.frames.find((frame) => frame['type'] === 'listen')!; + expect(listen).toMatchObject({ + scope: 'core', + service: 'providerService', + event: 'onDidChangeProviders', + }); + + server.pushEvent({ added: ['p1'], removed: [], changed: [] }); + server.pushEvent({ added: 1 }); + await tick(10); + expect(seen).toEqual([{ added: ['p1'], removed: [], changed: [] }]); + expect(errors).toHaveLength(1); + + await klient.close(); + }); +}); diff --git a/packages/klient/test/ipc.test.ts b/packages/klient/test/ipc.test.ts new file mode 100644 index 0000000000..0019acc77c --- /dev/null +++ b/packages/klient/test/ipc.test.ts @@ -0,0 +1,74 @@ +import { rm } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { describe, expect, it } from 'vitest'; + +import { defineKlientConformance } from './helpers/conformance.js'; +import { createKlient, serveKlientIpc, type KlientIpcHost } from '../src/transports/ipc/index.js'; +import { makeEngine, type TestEngine } from './helpers/engine.js'; + +defineKlientConformance('ipc', async () => { + const { homeDir, app } = await makeEngine(); + const socketPath = join(homeDir, 'klient.sock'); + const host = await serveKlientIpc({ scope: app, socketPath }); + const klient = createKlient({ socketPath }); + return { + klient, + cleanup: async () => { + await klient.close(); + await host.close(); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }, + }; +}); + +describe('ipc transport specifics', () => { + let homeDir: string; + let app: TestEngine['app']; + let host: KlientIpcHost | undefined; + + async function setup(opts: { token?: string } = {}): Promise { + ({ homeDir, app } = await makeEngine()); + const socketPath = join(homeDir, 'klient.sock'); + host = await serveKlientIpc({ scope: app, socketPath, token: opts.token }); + return socketPath; + } + + async function teardown(): Promise { + await host?.close(); + host = undefined; + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + } + + it('rejects calls when the socket path does not exist', async () => { + const klient = createKlient({ socketPath: join(tmpdir(), 'klient-no-such.sock') }); + await expect(klient.global.env()).rejects.toThrow(); + await klient.close(); + }); + + it('rejects calls made after close', async () => { + const socketPath = await setup(); + const klient = createKlient({ socketPath }); + await klient.global.env(); + await klient.close(); + // env() is served from its frozen-snapshot cache after the first call, so + // probe the closed channel with an uncached method instead. + await expect(klient.global.workspaces.list()).rejects.toThrow('ipc closed'); + await teardown(); + }); + + it('drops clients whose hello token mismatches', async () => { + const socketPath = await setup({ token: 'right' }); + const klient = createKlient({ socketPath, token: 'wrong' }); + await expect(klient.global.env()).rejects.toThrow(); + await klient.close(); + + const ok = createKlient({ socketPath, token: 'right' }); + await expect(ok.global.env()).resolves.toMatchObject({ platform: process.platform }); + await ok.close(); + await teardown(); + }); +}); diff --git a/packages/klient/test/memory.test.ts b/packages/klient/test/memory.test.ts new file mode 100644 index 0000000000..074d5e865e --- /dev/null +++ b/packages/klient/test/memory.test.ts @@ -0,0 +1,71 @@ +import { rm } from 'node:fs/promises'; + +import { describe, expect, it } from 'vitest'; + +import { defineKlientConformance } from './helpers/conformance.js'; +import { createKlient } from '../src/transports/memory/index.js'; +import { createMemoryDispatcher } from '../src/transports/memory/dispatcher.js'; +import { RPCError } from '../src/core/errors.js'; +import { makeEngine } from './helpers/engine.js'; + +defineKlientConformance('memory', async () => { + const { homeDir, app } = await makeEngine(); + const klient = createKlient({ scope: app }); + return { + klient, + cleanup: async () => { + await klient.close(); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }, + }; +}); + +describe('memory dispatcher specifics', () => { + it('rejects unknown services and methods with RPCError(40001)', async () => { + const { homeDir, app } = await makeEngine(); + const dispatcher = createMemoryDispatcher(app); + await expect(dispatcher.call({}, 'noSuchService', 'get', [])).rejects.toMatchObject({ + name: 'RPCError', + code: 40001, + }); + await expect(dispatcher.call({}, 'sessionIndex', 'noSuchMethod', [])).rejects.toMatchObject({ + name: 'RPCError', + code: 40001, + }); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }); + + it('reads non-function members as properties', async () => { + const { homeDir, app } = await makeEngine(); + const dispatcher = createMemoryDispatcher(app); + await expect(dispatcher.call({}, 'bootstrapService', 'platform', [])).resolves.toBe( + process.platform, + ); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }); + + it('rejects session/agent scopes for now', async () => { + const { homeDir, app } = await makeEngine(); + const dispatcher = createMemoryDispatcher(app); + await expect( + dispatcher.call({ sessionId: 's1' }, 'sessionIndex', 'list', [{}]), + ).rejects.toBeInstanceOf(RPCError); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }); + + it('delivers wire-cloned payloads (no live object identity)', async () => { + const { homeDir, app } = await makeEngine(); + const klient = createKlient({ scope: app }); + const list = await klient.global.workspaces.list(); + // Mutating the result must not affect what a second call returns. + (list as unknown[]).push({ id: 'polluted' }); + const again = await klient.global.workspaces.list(); + expect(again.some((w) => w.id === 'polluted')).toBe(false); + app.dispose(); + await rm(homeDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 }); + }); +}); diff --git a/packages/klient/test/ws.test.ts b/packages/klient/test/wsSocket.test.ts similarity index 64% rename from packages/klient/test/ws.test.ts rename to packages/klient/test/wsSocket.test.ts index 6da4ebd639..3de5f980a6 100644 --- a/packages/klient/test/ws.test.ts +++ b/packages/klient/test/wsSocket.test.ts @@ -1,11 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { ISessionIndex } from '@moonshot-ai/agent-core-v2/app/sessionIndex/sessionIndex'; -import { ISessionMetadata } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; - -import { Klient } from '../src/client.js'; -import { WsKlient } from '../src/wsKlient.js'; -import type { WsLike, WsLikeCtor, WsSocketState } from '../src/wsSocket.js'; +import type { WsLike, WsLikeCtor, WsSocketState } from '../src/transports/ws/wsSocket.js'; +import { WsSocket } from '../src/transports/ws/wsSocket.js'; const tick = (ms = 0): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -154,55 +150,55 @@ function fakeCtor(server: FakeServer): WsLikeCtor { return BoundFakeSocket as unknown as WsLikeCtor; } -async function openKlient(server: FakeServer, opts: { token?: string } = {}): Promise { - const ws = new WsKlient({ +async function openSocket(server: FakeServer, opts: { token?: string } = {}): Promise { + const socket = new WsSocket({ url: 'http://127.0.0.1:58627', token: opts.token, WebSocketImpl: fakeCtor(server), reconnectDelayMs: 10, }); await tick(5); - return ws; + return socket; } -describe('WsKlient', () => { +describe('WsSocket', () => { it('routes calls by scope / service / method with scope ids', async () => { const server = new FakeServer(); - const ws = await openKlient(server); + const socket = await openSocket(server); - const core = await ws.core(ISessionIndex).list({ workspaceId: 'w1' }); - const session = await ws.session('s1').service(ISessionMetadata).read(); - const agent = await ws.session('s1').agent('a1').service(ISessionMetadata).read(); + const core = await socket.call('core', 'sessionIndex', 'list', [{ workspaceId: 'w1' }]); + const agent = await socket.call('agent', 'sessionMetadata', 'read', undefined, { + sessionId: 's1', + agentId: 'a1', + }); expect(core).toMatchObject({ scope: 'core', service: 'sessionIndex', method: 'list' }); - expect(session).toMatchObject({ scope: 'session', service: 'sessionMetadata', sessionId: 's1' }); expect(agent).toMatchObject({ scope: 'agent', service: 'sessionMetadata', sessionId: 's1', agentId: 'a1', }); - ws.close(); + socket.close(); }); it('rejects calls with RPCError on error frames', async () => { const server = new FakeServer(); - const ws = await openKlient(server); - const meta = ws.session('s1').service(ISessionMetadata) as unknown as { - boom(): Promise; - }; - await expect(meta.boom()).rejects.toMatchObject({ + const socket = await openSocket(server); + await expect(socket.call('core', 'sessionMetadata', 'boom')).rejects.toMatchObject({ name: 'RPCError', code: 40001, }); - ws.close(); + socket.close(); }); it('delivers events to listen handlers and sends unlisten on dispose', async () => { const server = new FakeServer(); - const ws = await openKlient(server); + const socket = await openSocket(server); const seen: unknown[] = []; - const sub = ws.session('s1').listen('interactions', (data) => seen.push(data)); + const sub = socket.listen('session', 'interactions', { sessionId: 's1' }, (data) => + seen.push(data), + ); await tick(5); const listenId = [...server.listens][0]!; server.pushEvent(listenId, [{ id: 'a1' }]); @@ -211,20 +207,22 @@ describe('WsKlient', () => { sub.dispose(); expect(server.listens.size).toBe(0); - ws.close(); + socket.close(); }); - it('proxies Service events with one remote subscription for first/last listeners', async () => { + it('subscribes service emitters with the service name on the frame', async () => { const server = new FakeServer(); - const ws = await openKlient(server); - const service = ws.session('s1').service(ISessionMetadata); - const first: unknown[] = []; - const second: unknown[] = []; - const a = service.onDidChangeMetadata((event) => first.push(event)); - const b = service.onDidChangeMetadata((event) => second.push(event)); + const socket = await openSocket(server); + const seen: unknown[] = []; + const sub = socket.listen( + 'session', + 'onDidChangeMetadata', + { sessionId: 's1' }, + (data) => seen.push(data), + 'sessionMetadata', + ); await tick(5); - expect(server.listens.size).toBe(1); const listen = server.frames.find((frame) => frame['type'] === 'listen')!; expect(listen).toMatchObject({ scope: 'session', @@ -234,41 +232,31 @@ describe('WsKlient', () => { }); server.pushEvent(listen['id'] as string, { title: 'updated' }); await tick(5); - expect(first).toEqual([{ title: 'updated' }]); - expect(second).toEqual([{ title: 'updated' }]); - - a.dispose(); - expect(server.listens.size).toBe(1); - b.dispose(); - expect(server.listens.size).toBe(0); - ws.close(); + expect(seen).toEqual([{ title: 'updated' }]); + sub.dispose(); + socket.close(); }); it('reports asynchronous subscription errors and terminates the subscription', async () => { const server = new FakeServer(); - const ws = await openKlient(server); + const socket = await openSocket(server); const errors: string[] = []; - ws.onDidListenError((event) => errors.push(event.error.message)); - const service = ws.session('s1').service(ISessionMetadata); - service.onDidChangeMetadata(() => undefined); + socket.onDidListenError((event) => errors.push(event.error.message)); + socket.listen('session', 'onDidChangeMetadata', { sessionId: 's1' }, () => undefined, 'sessionMetadata'); await tick(5); const id = [...server.listens][0]!; server.pushError(id, 'payload is not serializable'); await tick(5); expect(errors).toEqual(['payload is not serializable']); - ws.close(); + socket.close(); }); it('waits for onWill listener work and aborts it on server cancel', async () => { const server = new FakeServer(); - const ws = await openKlient(server); - const service = ws.session('s1').service(ISessionMetadata) as unknown as { - onWillSave(listener: (event: { signal: AbortSignal; waitUntil(p: Promise): void }) => void): { - dispose(): void; - }; - }; + const socket = await openSocket(server); let aborted = false; - service.onWillSave((event) => { + socket.listen('session', 'onWillSave', { sessionId: 's1' }, (raw) => { + const event = raw as { signal: AbortSignal; waitUntil(p: Promise): void }; event.signal.addEventListener('abort', () => { aborted = true; }); @@ -283,34 +271,34 @@ describe('WsKlient', () => { await tick(5); expect(aborted).toBe(true); expect(server.frames.some((frame) => frame['type'] === 'event_result')).toBe(false); - ws.close(); + socket.close(); }); it('answers heartbeat pings with pong', async () => { const server = new FakeServer(); - const ws = await openKlient(server); + const socket = await openSocket(server); server.ping(); await tick(5); expect(server.pongs).toBe(1); - ws.close(); + socket.close(); }); it('reconnects after a drop: calls reject, listens re-subscribe, state is observable', async () => { const server = new FakeServer(); - const ws = await openKlient(server); + const socket = await openSocket(server); const states: WsSocketState[] = []; - ws.onDidChangeState((s) => states.push(s)); + socket.onDidChangeState((s) => states.push(s)); const seen: unknown[] = []; - ws.session('s1').agent('a1').listen('events', (data) => seen.push(data)); + socket.listen('agent', 'events', { sessionId: 's1', agentId: 'a1' }, (data) => seen.push(data)); await tick(5); - const inFlight = ws.core(ISessionIndex).countActive('w1'); + const inFlight = socket.call('core', 'sessionIndex', 'countActive', ['w1']); server.drop(); await expect(inFlight).rejects.toThrow('ws closed'); - expect(ws.state).toBe('connecting'); + expect(socket.currentState).toBe('connecting'); await tick(50); // backoff 10ms → reconnect - expect(ws.state).toBe('open'); + expect(socket.currentState).toBe('open'); expect(server.helloCount).toBe(2); expect(server.listens.size).toBe(1); @@ -319,52 +307,26 @@ describe('WsKlient', () => { await tick(5); expect(seen).toEqual([{ type: 'turn.started' }]); - const data = await ws.core(ISessionIndex).countActive('w1'); + const data = await socket.call('core', 'sessionIndex', 'countActive', ['w1']); expect(data).toMatchObject({ method: 'countActive' }); expect(states).toContain('connecting'); - ws.close(); - expect(ws.state).toBe('closed'); + socket.close(); + expect(socket.currentState).toBe('closed'); }); - it('Klient.ws() is a lazy singleton deriving the ws URL and bearer subprotocol', async () => { + it('derives the ws URL and presents the bearer subprotocol', async () => { const server = new FakeServer(); - const client = new Klient({ - url: 'http://127.0.0.1:58627', - token: 'tok', - WebSocketImpl: fakeCtor(server), - }); - const ws = client.ws(); - expect(client.ws()).toBe(ws); + const socket = await openSocket(server, { token: 'tok' }); await tick(5); expect(server.lastUrl).toBe('ws://127.0.0.1:58627/api/v2/ws'); expect(server.lastProtocols).toEqual(['kimi-code.bearer.tok']); - const data = await ws.core(ISessionIndex).list({}); - expect(data).toMatchObject({ service: 'sessionIndex' }); - ws.close(); + socket.close(); }); it('rejects calls made after close', async () => { const server = new FakeServer(); - const ws = await openKlient(server); - ws.close(); - await expect(ws.core(ISessionIndex).list({})).rejects.toThrow('ws closed'); - }); - - it('Klient.ws() recreates a fresh socket after the previous one was closed', async () => { - const server = new FakeServer(); - const client = new Klient({ - url: 'http://127.0.0.1:58627', - WebSocketImpl: fakeCtor(server), - }); - const first = client.ws(); - await tick(5); - first.close(); - const second = client.ws(); - expect(second).not.toBe(first); - await tick(5); - expect(second.state).toBe('open'); - const data = await second.core(ISessionIndex).list({}); - expect(data).toMatchObject({ service: 'sessionIndex' }); - second.close(); + const socket = await openSocket(server); + socket.close(); + await expect(socket.call('core', 'sessionIndex', 'list', [{}])).rejects.toThrow('ws closed'); }); }); diff --git a/packages/klient/tsconfig.json b/packages/klient/tsconfig.json index ef502e89c8..dca1267d55 100644 --- a/packages/klient/tsconfig.json +++ b/packages/klient/tsconfig.json @@ -1,4 +1,4 @@ { "extends": "../../tsconfig.json", - "include": ["src", "test"] + "include": ["src", "test", "scenarios", "scripts", "../agent-core-v2/src/env.d.ts"] } diff --git a/packages/klient/tsdown.config.ts b/packages/klient/tsdown.config.ts index b27e00afd7..2388b63e58 100644 --- a/packages/klient/tsdown.config.ts +++ b/packages/klient/tsdown.config.ts @@ -1,7 +1,12 @@ import { defineConfig } from 'tsdown'; export default defineConfig({ - entry: ['./src/index.ts'], + entry: [ + './src/index.ts', + './src/transports/http/index.ts', + './src/transports/ipc/index.ts', + './src/transports/memory/index.ts', + ], format: ['esm'], dts: false, outDir: 'dist', diff --git a/packages/klient/vitest.config.ts b/packages/klient/vitest.config.ts index 02890dfb7c..b5a71f285d 100644 --- a/packages/klient/vitest.config.ts +++ b/packages/klient/vitest.config.ts @@ -4,5 +4,6 @@ export default defineConfig({ test: { name: 'klient', include: ['test/**/*.test.ts'], + reporters: ['default', './test/e2e/legacy/report/vitest-reporter.ts'], }, }); diff --git a/packages/server-e2e/.gitignore b/packages/server-e2e/.gitignore deleted file mode 100644 index a9a1bd38ab..0000000000 --- a/packages/server-e2e/.gitignore +++ /dev/null @@ -1 +0,0 @@ -reports/ diff --git a/packages/server-e2e/AGENTS.md b/packages/server-e2e/AGENTS.md deleted file mode 100644 index 91274482e5..0000000000 --- a/packages/server-e2e/AGENTS.md +++ /dev/null @@ -1,44 +0,0 @@ -# server-e2e Agent Guide - -This file contains package-local rules for `packages/server-e2e`. - -## Package layout - -This package ships two clients: - -- **Legacy `/api/v1` client** — `DaemonClient` (`src/client.ts`), `HttpClient` - (`src/http.ts`), `WsClient` (`src/ws.ts`) and the helpers under `src/` - (`wait.ts`, `reverse-rpc.ts`, `report.ts`, `envelope.ts`). This is the - original wire-level test client; the existing `test/*.test.ts` cases and - `scenarios/*.ts` scripts are built on it and **must keep running unchanged**. - Do not break or rewrite those tests when extending the package. -- **server-v2 SDK** — `ServerClient` under `src/v2/`. A lark-style typed client - for the `server-v2` `/api/v2` RPC + WS surface. `src/v2/resources/manifest.ts` - mirrors `server-v2/src/transport/actionMap.ts`; `test/v2/actionMap.test.ts` - is a drift test that fails when the server surface and the manifest diverge. - The legacy REST surface is reachable via `ServerClient#v1`. - -## Testing Principle - -- Keep observability inside each server-e2e case. Do not add a separate "observable" test or scenario as a substitute for making the existing cases explain what they drove and what the server returned. -- Every live server case should print structured, case-scoped details for the flow it exercises: key REST requests, response envelopes or unwrapped responses, WebSocket handshakes / acks / replay summaries, prompt terminal frames, and error envelopes. -- Prefer a shared logging helper over ad hoc `console.log` formatting. Logs must be visible for passing Vitest cases, so write through stdout when Vitest would otherwise capture console output. -- Keep logs factual and diagnostic. Print enough detail to debug the wire contract, but avoid unrelated narration. - -## Workflow - -- When adding or changing a server-e2e case, update that case's observability at the same time. -- Do not add a new scenario solely to print data that an existing scenario or Vitest case should already expose. -- Run the relevant server-e2e tests against `KIMI_SERVER_URL=http://127.0.0.1:58627` when a server is available, and confirm the output includes the case-scoped diagnostic blocks. -- Run Docker e2e with `pnpm --filter @moonshot-ai/server-e2e docker:e2e`; each run must derive its Docker runner name/namespace from the current workspace to avoid cross-workspace conflicts. - -## Command Reference - -- Start a local server from the repo root before validating live cases: `pnpm dev:server`. -- Run only the undo helper/live e2e coverage: `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/server-e2e test -- test/client.test.ts -t undoSession`. -- Run the full server client Vitest file: `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/server-e2e test -- test/client.test.ts`. -- Run all server-e2e Vitest tests: `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/server-e2e test`. -- Run all executable scenarios against the local server: `KIMI_SERVER_URL=http://127.0.0.1:58627 pnpm --filter @moonshot-ai/server-e2e test:scenarios`. -- Run the server-v2 SDK drift test (no server needed): `pnpm --filter @moonshot-ai/server-e2e exec vitest run test/v2/actionMap.test.ts`. -- Run the server-v2 SDK smoke test (boots server-v2 in-process): `pnpm --filter @moonshot-ai/server-e2e exec vitest run test/v2/smoke.test.ts`. -- Run type checking for this package: `pnpm --filter @moonshot-ai/server-e2e typecheck`. diff --git a/packages/server-e2e/README.md b/packages/server-e2e/README.md deleted file mode 100644 index fbf662e72d..0000000000 --- a/packages/server-e2e/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# @moonshot-ai/server-e2e - -Wire-level test client for the kimi-code server (HTTP + WS). This package is -**private** — it ships scenario scripts that double as smoke tests and a small -typed `DaemonClient` you can reuse in vitest e2e files. - -## When to use this - -- You want to drive a real, running server process from a Node script and - observe HTTP + WS behavior end to end. -- You're writing a vitest e2e that covers server REST + WS lifecycle as a - whole — not a single in-process unit (those belong in `packages/kap-server/test/`). -- You need a reference for the wire shape of approval / question / events. - -## When NOT to use this - -- You're testing the WS gateway in isolation — keep using - `packages/kap-server/test/` in-process `startServer` boots (faster, and they - assert on the server's internal services directly). -- You want a typed in-process facade over the server for user-facing code — - use `@moonshot-ai/node-sdk` instead (`KimiHarness`, `Session`). - -## Quick start (server-v2 — recommended) - -`ServerClient` is a lark-style typed client for the `server-v2` `/api/v2` RPC -+ WebSocket surface. The resource tree mirrors the server's `actionMap`; a drift -test (`test/v2/actionMap.test.ts`) keeps it in lockstep. - -```ts -import { ServerClient } from '@moonshot-ai/server-e2e'; - -const sdk = new ServerClient({ baseUrl: 'http://127.0.0.1:58627', token }); - -// Core scope — /api/v2/: -const { items } = await sdk.sessions.list({ page_size: 20 }); -await sdk.workspaces.createOrTouch(process.cwd()); - -// Session scope — the `session` resource is flattened onto the handle -const s = sdk.session(items[0].id); -await s.setTitle('renamed'); -await s.status(); -await s.approvals.decide(approvalId, { decision: 'approved' }); - -// Agent scope -const { turn_id } = await s.agent('main').prompts.submit({ - input: [{ type: 'text', text: 'hello' }], -}); -await s.agent('main').shell.run({ command: 'ls' }); - -// Events over /api/v2/ws -const events = await sdk.connect(); -const off = events.onAgentEvents(items[0].id, 'main', (e) => { - console.log('agent event', e); -}); -// ... -off(); -await sdk.close(); -``` - -Anything not (yet) in the typed tree is reachable via the escape hatches -`sdk.core(resource)`, `sdk.session(sid).service(resource)`, and -`sdk.session(sid).agent(aid).service(resource)`. The legacy `/api/v1` REST -surface is reachable via `sdk.v1` (the unchanged `HttpClient`). - -## Quick start (legacy `/api/v1` client) - -```ts -import { DaemonClient } from '@moonshot-ai/server-e2e'; - -const client = new DaemonClient(); // http://127.0.0.1:58627 by default - -const session = await client.createSession({ metadata: { cwd: process.cwd() } }); -await client.connect(); // server_hello + client_hello ack -await client.subscribe(session.id); - -client.onApprovalRequested(() => ({ decision: 'approved' })); - -const { prompt_id, finalFrame } = await client.submitAndWait(session.id, { - content: [{ type: 'text', text: 'Echo hello' }], -}); - -await client.close(); -await client.archiveSession(session.id); -``` - -> The exported facade is still spelled `DaemonClient` to keep the diff small; -> conceptually it is the **server** client. - -## Scripts - -```sh -pnpm --filter @moonshot-ai/server-e2e typecheck -pnpm --filter @moonshot-ai/server-e2e test # vitest self-tests -pnpm --filter @moonshot-ai/server-e2e test:scenarios # run every scenarios/*.ts -pnpm --filter @moonshot-ai/server-e2e docker:e2e # run server + scenarios in docker -``` - -Both `test` and `test:scenarios` require a running server (set `KIMI_SERVER_URL` -to override the default `http://127.0.0.1:58627`). The vitest suite skips its -live-dependent cases when no server is reachable so CI stays green. Scenarios -are run via `tsx` because they execute TypeScript directly. - -Both commands write a browser-readable report to -`packages/server-e2e/reports/latest/index.html` (override with -`KIMI_SERVER_E2E_REPORT_DIR`). The report groups events by case and shows a compact -timeline of case logs, HTTP request / response envelopes, WebSocket frames, and -test results. JSON payloads are kept in collapsed detail blocks so the terminal -can stay concise while the full wire trace remains available. - -`docker:e2e` builds `kimi-server:dev` from the root `Dockerfile`, layers -`packages/server-e2e/Dockerfile` on top, then runs a one-shot Docker container. -The container starts the server on container-local `127.0.0.1:58627` and runs -`pnpm test:scenarios` in the same container. The launcher intentionally does -not pass `-p` / `--publish`, so it does not expose a server port on the host and -can coexist with the `docker-compose.yml` server that publishes host port 58627. -Reports are written under -`~/.kimi-code-server-dev/server-e2e-reports/docker//latest/index.html`; -the server log is written beside them as `server.log`. - -The Docker workflow uses an isolated KIMI home at -`~/.kimi-code-server-dev/docker-e2e//kimi-code-home` to avoid sharing -server locks with Compose. `` is deterministic by default: -`-`, so different worktrees do not collide. -On first run it seeds `config.toml` and `credentials/` from -`~/.kimi-code-server-dev/kimi-home/kimi-code-home` when those files exist. -Override the namespace with `KIMI_SERVER_E2E_RUN_ID`, or override paths with -`KIMI_SERVER_E2E_STATE_ROOT`, `KIMI_SERVER_E2E_KIMI_HOME_HOST`, -`KIMI_SERVER_E2E_SEED_KIMI_HOME_HOST`, or `KIMI_SERVER_E2E_REPORT_DIR_HOST`. - -## Public API summary - -| Symbol | Purpose | -|---|---| -| `ServerClient` | **server-v2** lark-style client — typed `/api/v2` resource tree (HTTP) + events (WS), with `sdk.v1` for the legacy REST surface. | -| `DaemonClient` | Main facade — HTTP + WS, handshake plumbing, reverse-RPC handlers. | -| `HttpClient` | REST helpers only (no WS). Useful when you don't need event observation. | -| `WsClient` | Raw WS wrapper — queue, waiters, ack correlation. | -| `EnvelopeError` | Thrown by `unwrap()` / HTTP helpers when `envelope.code !== 0`. | -| `fetchWithReport` / `writeHtmlReport` | Capture direct fetch calls and render the JSONL trace as a single HTML report. | -| `installReverseRpcHandler` | Uniform helper powering `onApprovalRequested` / `onQuestionAsked`. | -| `waitForFrame` / `waitForSessionBusy` | Standalone wait helpers reused by scenarios. | - -See `scenarios/README.md` for the executable script catalog and conventions. - -## Scope notes - -- **No in-process server bootstrap** — point at an already-running server. - An in-process `startServer(port:0)` helper is intentionally out of scope. -- **No auto-discovery** — the WS endpoint is hard-coded to `${apiPrefix}/ws`. - Override via `apiPrefix` only. -- **Not published** — `private: true`. Internal tooling only. diff --git a/packages/server-e2e/package.json b/packages/server-e2e/package.json deleted file mode 100644 index a551cfa179..0000000000 --- a/packages/server-e2e/package.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "name": "@moonshot-ai/server-e2e", - "version": "0.1.0", - "private": true, - "description": "Wire-level E2E test client for the kimi-code server (HTTP + WS). Powers scenario scripts and self-tests against a real server process.", - "license": "MIT", - "author": "Moonshot AI", - "repository": { - "type": "git", - "url": "git+https://github.com/MoonshotAI/kimi-code.git", - "directory": "packages/server-e2e" - }, - "bugs": { - "url": "https://github.com/MoonshotAI/kimi-code/issues" - }, - "type": "module", - "imports": { - "#/*": "./src/*.ts" - }, - "exports": { - ".": { - "types": "./src/index.ts", - "default": "./src/index.ts" - } - }, - "scripts": { - "build": "tsdown", - "typecheck": "tsc -p tsconfig.json --noEmit", - "test": "vitest run", - "test:scenarios": "tsx scripts/run-scenarios.ts", - "docker:e2e": "bash scripts/run-docker-e2e.sh", - "clean": "rm -rf dist" - }, - "dependencies": { - "@moonshot-ai/protocol": "workspace:^", - "ulid": "^3.0.1", - "ws": "^8.18.0", - "zod": "catalog:" - }, - "devDependencies": { - "@moonshot-ai/agent-core-v2": "workspace:^", - "@moonshot-ai/kap-server": "workspace:^", - "@moonshot-ai/klient": "workspace:^", - "@types/ws": "^8.18.0" - } -} diff --git a/packages/server-e2e/scenarios/01-create-and-send.ts b/packages/server-e2e/scenarios/01-create-and-send.ts deleted file mode 100644 index a20519683e..0000000000 --- a/packages/server-e2e/scenarios/01-create-and-send.ts +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 01 — create a session, submit a prompt asking for a 1-word reply, - * poll messages, assert the assistant text contains the expected token. - * - * Usage: - * KIMI_SERVER_URL=http://127.0.0.1:58627 npx tsx scenarios/01-create-and-send.ts - * - * Exit codes: - * 0 — pass - * 1 — assertion failure or server error - */ -import assert from 'node:assert/strict'; - -import { DaemonClient } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const EXPECTED_TOKEN = 'OK'; - -async function main() { - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - - let sid: string | undefined; - try { - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - sid = session.id; - console.log(`▶ session ${sid} created`); - - await client.connect(); - await client.subscribe(sid); - - const { prompt_id, finalFrame } = await client.submitAndWait( - sid, - { - content: [ - { - type: 'text', - text: `Reply with the single word "${EXPECTED_TOKEN}" and nothing else.`, - }, - ], - }, - { waitFor: 'prompt.completed', timeoutMs: 60_000 }, - ); - console.log(`▶ prompt ${prompt_id} → finalFrame=${finalFrame.type}`); - - const { items } = await client.listMessages(sid, { page_size: 100 }); - // `prompt_id` on Message is optional (the adapter from agent-core doesn't - // always populate it). Fall back to the latest assistant message, which - // is unambiguous in a single-prompt scenario. - const assistant = - items.find((m) => m.role === 'assistant' && m.prompt_id === prompt_id) ?? - [...items].reverse().find((m) => m.role === 'assistant'); - assert.ok(assistant, 'expected at least one assistant message'); - - const text = assistant.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join(''); - assert.ok( - text.toUpperCase().includes(EXPECTED_TOKEN), - `expected assistant text to contain "${EXPECTED_TOKEN}", got: ${JSON.stringify(text)}`, - ); - - console.log(`✓ 01-create-and-send: assistant replied "${text.trim()}"`); - } finally { - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -main().catch((err) => { - console.error('✗ 01-create-and-send failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/02-tool-call-with-approval.ts b/packages/server-e2e/scenarios/02-tool-call-with-approval.ts deleted file mode 100644 index 9f1bc4c238..0000000000 --- a/packages/server-e2e/scenarios/02-tool-call-with-approval.ts +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 02 — tool call with approval. - * - * Asks the agent to run Bash and echo a canary token. The Bash tool triggers - * an approval prompt; the framework's built-in `onApprovalRequested` handler - * auto-approves it. We wait for `prompt.completed`, then assert: - * - * - The tool emitted a `tool_result` whose output contains the canary. - * - The assistant message also surfaced the canary. - * - * Usage: - * KIMI_SERVER_URL=http://127.0.0.1:58627 npx tsx scenarios/02-tool-call-with-approval.ts - * - * Exit codes: - * 0 — pass - * 1 — assertion failure, timeout, or server error - */ -import assert from 'node:assert/strict'; - -import { DaemonClient } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const CANARY = `HELLO_FROM_AUDIT_${process.pid}`; - -async function main() { - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - - let sid: string | undefined; - let approvalCount = 0; - try { - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - sid = session.id; - console.log(`▶ session ${sid} created`); - - await client.connect(); - await client.subscribe(sid); - - client.onApprovalRequested((req) => { - approvalCount++; - console.log(`▶ approval ${req.approval_id} requested for tool=${req.tool_name}`); - return { decision: 'approved' }; - }); - - const { prompt_id } = await client.submitAndWait( - sid, - { - content: [ - { - type: 'text', - text: `Use the Bash tool to run \`echo ${CANARY}\` and then tell me the exact output you observed.`, - }, - ], - }, - { waitFor: 'prompt.completed', timeoutMs: 120_000 }, - ); - console.log(`▶ prompt ${prompt_id} completed; approvals=${approvalCount}`); - - assert.ok(approvalCount >= 1, 'expected at least one approval request'); - - const { items } = await client.listMessages(sid, { page_size: 100 }); - // `prompt_id` on Message is optional (see scenario 01) — match by role - // and accept any tool / assistant messages emitted in this session. - const toolMessages = items.filter( - (m) => m.role === 'tool' && (m.prompt_id === prompt_id || m.prompt_id === undefined), - ); - const sawCanaryInTool = toolMessages.some((m) => - m.content.some( - (part) => - part.type === 'tool_result' && - typeof part.output === 'string' && - part.output.includes(CANARY), - ), - ); - assert.ok(sawCanaryInTool, `expected canary "${CANARY}" in a tool_result`); - - const assistantText = items - .filter( - (m) => m.role === 'assistant' && (m.prompt_id === prompt_id || m.prompt_id === undefined), - ) - .flatMap((m) => m.content) - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join('\n'); - assert.ok( - assistantText.includes(CANARY), - `expected canary "${CANARY}" in assistant text, got: ${JSON.stringify(assistantText.slice(0, 300))}`, - ); - - // Session should be quiescent after prompt.completed. - const final = await client.waitForSessionBusy(sid, false, { timeoutMs: 10_000 }); - assert.equal(final.busy, false); - - console.log(`✓ 02-tool-call-with-approval: canary round-tripped end-to-end`); - } finally { - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -main().catch((err) => { - console.error('✗ 02-tool-call-with-approval failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/03-refresh-replay.ts b/packages/server-e2e/scenarios/03-refresh-replay.ts deleted file mode 100644 index 222670cce3..0000000000 --- a/packages/server-e2e/scenarios/03-refresh-replay.ts +++ /dev/null @@ -1,323 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 03 — "user refreshes the browser" / "user clicks an existing session - * from the history list" wire-level walkthrough. - * - * This is the worst-case page-load sequence a web client runs while the - * server is already up. The phases (REST.md §3, WS.md §3) are: - * - * Phase 0 environment probes GET /healthz, /meta, /auth - * Phase 1 open WS BEFORE history server_hello → client_hello(cursors) → ack - * Phase 2 pull persisted snapshot GET /sessions/{sid}, /messages, /tasks - * Phase 5 steady state POST /prompts → observe events on WS - * - * Phase ordering matters: if Phase 2 ran before Phase 1, turn events emitted - * in the gap would have no subscriber on this connection — they'd still land - * in the ring buffer, but REST `/messages` only reflects flushed-to-store - * content, so the "in-flight" delta would be invisible to the UI. - * - * What this scenario exercises end-to-end against a running server: - * - All three Phase 0 endpoints respond and `meta.server_id` is non-empty. - * - A first WS session completes one prompt; we record the current ring-buffer - * seq for the session. - * - We close the WS, open a fresh one, and on `client_hello` pass - * `cursors: { [sid]: { seq: currentSeq } }` — the server should ack - * with `accepted_subscriptions: [sid]`, `resync_required: []`, and NOT - * replay any events (we are caught up). - * - We then open a THIRD connection, this time with - * `cursors: { [sid]: { seq: 0 } }` — the server should replay every - * durable event (seq 1..N) BEFORE the ack lands. - * - Phase 2 REST snapshot reflects the user + assistant messages persisted - * during the first run. - * - Phase 5: a new prompt over the third connection delivers events on WS. - * - * Usage: - * KIMI_SERVER_URL=http://127.0.0.1:58627 npx tsx scenarios/03-refresh-replay.ts - * - * Exit codes: - * 0 — pass - * 1 — assertion failure, timeout, or server error - */ -import assert from 'node:assert/strict'; - -import { DaemonClient, WsClient, type AnyFrame } from '../src/index'; -import { fetchWithReport } from '../src/report'; -import { WebSocket as WsWebSocket } from 'ws'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const API_PREFIX = '/api/v1'; -const HANDSHAKE_TIMEOUT_MS = 5_000; -const PROMPT_TIMEOUT_MS = 60_000; - -interface Envelope { - code: number; - msg?: string; - data: T; -} - -interface MetaResponse { - server_id: string; - server_version: string; - started_at: string; -} - -interface ClientHelloPayload extends Record { - client_id: string; - subscriptions: string[]; - cursors?: Record; -} - -interface AckPayload { - accepted_subscriptions?: string[]; - resync_required?: string[]; -} - -interface OpenSocketResult { - ws: WsClient; - serverHello: unknown; - ack: AnyFrame; - replayed: AnyFrame[]; - log: AnyFrame[]; -} - -interface PromptCompletedPayload { - prompt_id?: string; - promptId?: string; -} - -async function fetchEnvelope(url: string): Promise { - const res = await fetchWithReport(url, { headers: { accept: 'application/json' } }); - const body = (await res.json()) as Envelope; - assert.equal(typeof body.code, 'number', `${url}: missing envelope.code`); - assert.equal(body.code, 0, `${url}: code=${body.code} msg=${body.msg ?? ''}`); - return body.data; -} - -async function openSocketWithHello({ - sid, - lastSeq, -}: { - sid: string; - lastSeq?: number; -}): Promise { - const wsUrl = `${KIMI_SERVER_URL.replace(/^http/, 'ws')}${API_PREFIX}/ws`; - const ws = new WsClient({ url: wsUrl, wsImpl: WsWebSocket, logger: () => {} }); - await ws.open(); - - // Capture every frame on its arrival timeline (queue order). Used to - // distinguish replay events (arrive BEFORE ack) from steady-state events. - const log: AnyFrame[] = []; - ws.onFrame((f) => log.push(f)); - - // 1) server_hello - const helloFrame = await ws.waitForFrame((f) => f.type === 'server_hello', HANDSHAKE_TIMEOUT_MS); - - // 2) client_hello - const helloId = `hello-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; - const payload: ClientHelloPayload = { - client_id: `scenario-03-${process.pid}`, - subscriptions: [sid], - }; - if (lastSeq !== undefined) payload.cursors = { [sid]: { seq: lastSeq } }; - ws.send({ type: 'client_hello', id: helloId, payload }); - - // 3) wait for the matching ack - const ack = await ws.waitForFrame( - (f) => f.type === 'ack' && f.id === helloId, - HANDSHAKE_TIMEOUT_MS, - ); - - // Replay events arrived before the ack — slice them out of the log by - // position. (Anything appended to `log` AFTER this point is steady-state.) - // The server emits agent-core event types without an `event.` prefix - // (`turn.started`, `assistant.delta`, …), so we filter on the structural - // shape (has `seq`, has `session_id`, isn't a system frame) instead of a - // type prefix. - const replayed = log.filter( - (f) => - f.type !== 'server_hello' && - f.type !== 'ack' && - f.type !== 'ping' && - f.type !== 'resync_required' && - f.type !== 'error' && - typeof f.seq === 'number' && - f.session_id === sid && - (lastSeq === undefined || f.seq > lastSeq), - ); - - return { ws, serverHello: helloFrame.payload, ack, replayed, log }; -} - -async function main() { - console.log(`▶ server at ${KIMI_SERVER_URL}`); - - // ── Phase 0 ───────────────────────────────────────────────────────────── - const health = await fetchEnvelope<{ ok: boolean }>(`${KIMI_SERVER_URL}${API_PREFIX}/healthz`); - assert.equal(health.ok, true, 'healthz did not return ok=true'); - - const meta = await fetchEnvelope(`${KIMI_SERVER_URL}${API_PREFIX}/meta`); - assert.ok(typeof meta.server_id === 'string' && meta.server_id.length > 0, 'missing server_id'); - assert.ok(typeof meta.started_at === 'string', 'missing started_at'); - assert.ok(typeof meta.server_version === 'string', 'missing server_version'); - const firstServerId = meta.server_id; - console.log(`▶ phase 0: server_id=${firstServerId} version=${meta.server_version}`); - - const auth = await fetchEnvelope<{ ready: boolean; providers_count: number }>( - `${KIMI_SERVER_URL}${API_PREFIX}/auth`, - ); - assert.equal(typeof auth.ready, 'boolean', 'auth.ready missing'); - assert.equal(typeof auth.providers_count, 'number', 'auth.providers_count missing'); - console.log(`▶ phase 0: auth.ready=${auth.ready}`); - - // ── Initial flow: create + drive a prompt to populate the ring buffer ─── - const initial = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - let sid: string | undefined; - try { - const session = await initial.createSession({ metadata: { cwd: process.cwd() } }); - sid = session.id; - console.log(`▶ session ${sid} created`); - - await initial.connect(); - await initial.subscribe(sid); - - // Track every event we see on this connection so we know the max seq. - let maxSeq = 0; - initial.onFrame((f) => { - if (typeof f.seq === 'number' && f.session_id === sid && f.seq > maxSeq) { - maxSeq = f.seq; - } - }); - - const { prompt_id, finalFrame } = await initial.submitAndWait( - sid, - { - content: [{ type: 'text', text: 'Reply with the single word "REFRESH" and nothing else.' }], - }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - if (typeof finalFrame.seq === 'number' && finalFrame.seq > maxSeq) maxSeq = finalFrame.seq; - assert.ok(maxSeq > 0, `expected at least one event before reconnect, maxSeq=${maxSeq}`); - console.log(`▶ prompt ${prompt_id} completed; maxSeq=${maxSeq}`); - - await initial.close(); - - // ── Phase 0 again — simulate a browser refresh (cheap re-probe) ────── - const meta2 = await fetchEnvelope(`${KIMI_SERVER_URL}${API_PREFIX}/meta`); - assert.equal(meta2.server_id, firstServerId, 'server_id changed mid-scenario — server restarted?'); - - // ── Phase 1 (refresh #1): caught-up reconnect — no replay expected ─── - const caughtUp = await openSocketWithHello({ sid, lastSeq: maxSeq }); - assert.equal(caughtUp.ack.code, 0, `caught-up client_hello rejected: ${caughtUp.ack.msg}`); - const ackPayloadA = (caughtUp.ack.payload ?? {}) as AckPayload; - assert.deepEqual( - ackPayloadA.accepted_subscriptions ?? [], - [sid], - 'expected accepted_subscriptions=[sid] for caught-up reconnect', - ); - assert.deepEqual( - ackPayloadA.resync_required ?? [], - [], - 'expected resync_required=[] for caught-up reconnect', - ); - assert.equal( - caughtUp.replayed.length, - 0, - `expected 0 replay events when caught up, got ${caughtUp.replayed.length}`, - ); - console.log(`▶ refresh #1: caught-up; accepted=[${sid}], replayed=0`); - await caughtUp.ws.close(); - - // ── Phase 1 (refresh #2): seq=0 — server replays the whole buffer ──── - const replay = await openSocketWithHello({ sid, lastSeq: 0 }); - assert.equal(replay.ack.code, 0, `replay client_hello rejected: ${replay.ack.msg}`); - const ackPayloadB = (replay.ack.payload ?? {}) as AckPayload; - assert.deepEqual( - ackPayloadB.accepted_subscriptions ?? [], - [sid], - 'expected accepted_subscriptions=[sid] for replay reconnect', - ); - assert.deepEqual( - ackPayloadB.resync_required ?? [], - [], - 'expected resync_required=[] when buffer covers seq=1..maxSeq', - ); - assert.ok( - replay.replayed.length > 0, - `expected server to replay buffered events on last_seq=0, got 0`, - ); - const seqs = replay.replayed - .map((f) => f.seq) - .filter((n): n is number => typeof n === 'number'); - assert.equal( - Math.min(...seqs), - 1, - `expected replay to start at seq=1, got ${Math.min(...seqs)}`, - ); - assert.equal( - Math.max(...seqs), - maxSeq, - `expected replay to end at seq=maxSeq (${maxSeq}), got ${Math.max(...seqs)}`, - ); - console.log(`▶ refresh #2: replay seq=1..${maxSeq} (${replay.replayed.length} events)`); - - // ── Phase 2: persisted REST snapshot reflects the first prompt ─────── - const fetched = await initial.http.getSession(sid); - assert.equal(fetched.id, sid, 'getSession returned wrong session id'); - - const { items: messages } = await initial.http.listMessages(sid, { page_size: 100 }); - assert.ok( - messages.some((m) => m.role === 'user'), - 'expected at least one user message in /messages snapshot', - ); - assert.ok( - messages.some((m) => m.role === 'assistant'), - 'expected at least one assistant message in /messages snapshot', - ); - - // No `listTasks` helper on HttpClient — call the endpoint directly to - // verify it responds with the documented `{items: []}` envelope. - const tasks = await fetchEnvelope<{ items: unknown[] }>( - `${KIMI_SERVER_URL}${API_PREFIX}/sessions/${encodeURIComponent(sid)}/tasks`, - ); - assert.ok(Array.isArray(tasks.items), 'GET /tasks must return items[]'); - console.log(`▶ phase 2: messages=${messages.length} tasks=${tasks.items.length}`); - - // ── Phase 5: steady state — issue a new prompt over the live socket ── - // The third socket (`replay.ws`) is still open and subscribed; we drive - // a follow-up prompt via REST and assert events arrive on this WS. - const followUp = await initial.submitPrompt(sid, { - content: [{ type: 'text', text: 'Reply with the single word "DONE" and nothing else.' }], - }); - const completed = await replay.ws.waitForFrame( - (f) => { - if (f.type !== 'prompt.completed') return false; - const p = (f.payload ?? {}) as PromptCompletedPayload; - const pid = p.prompt_id ?? p.promptId; - return pid === followUp.prompt_id; - }, - PROMPT_TIMEOUT_MS, - ); - assert.ok(typeof completed.seq === 'number' && completed.seq > maxSeq, 'follow-up seq did not advance'); - console.log(`▶ phase 5: follow-up prompt completed at seq=${completed.seq}`); - - await replay.ws.close(); - - console.log('✓ 03-refresh-replay: refresh round-trip preserves subscription + replay semantics'); - } finally { - try { - if (sid) await initial.http.archiveSession(sid); - } catch { - // ignore - } - try { - await initial.close(); - } catch { - // ignore - } - } -} - -main().catch((err) => { - console.error('✗ 03-refresh-replay failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/05-workspace.ts b/packages/server-e2e/scenarios/05-workspace.ts deleted file mode 100644 index 5da0045fe2..0000000000 --- a/packages/server-e2e/scenarios/05-workspace.ts +++ /dev/null @@ -1,162 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 05 — workspace registry + folder picker happy path. - * - * Flow: - * 1. `GET /fs:home` — picker landing payload (home + recent roots) - * 2. `GET /fs:browse` — list child dirs of $HOME (sanity check the wire) - * 3. `POST /workspaces { root }` — register a workspace on a fresh tmpdir - * 4. `POST /sessions { workspace_id }` — server resolves cwd from workspace - * 5. `GET /sessions?workspace_id=` — fast-path filter returns just our session - * 6. Round-trip a real prompt through that session (depends on DAEMON_AUTH) - * 7. `DELETE /workspaces/{id}` — unregister (does NOT remove the session) - * - * Usage: - * KIMI_SERVER_URL=http://127.0.0.1:58627 npx tsx scenarios/05-workspace.ts - * - * Exit codes: - * 0 — pass - * 1 — assertion failure or server error - */ -import assert from 'node:assert/strict'; -import { mkdtempSync, realpathSync, rmSync } from 'node:fs'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { DaemonClient } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const EXPECTED_TOKEN = 'OK'; - -async function main() { - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - - // Use a fresh tmpdir as the workspace root so the scenario doesn't pollute - // the server's workspace registry with persistent entries. realpathSync() - // matches what the picker / server registry surface (avoids macOS - // `/var` ↔ `/private/var` mismatch with the session cwd). - const wsRoot = realpathSync(mkdtempSync(join(tmpdir(), 'kimi-e2e-workspace-'))); - let workspaceId: string | undefined; - let sid: string | undefined; - - try { - // 1. fs:home — sanity check the landing payload shape. - const home = await client.fsHome(); - assert.ok(typeof home.home === 'string' && home.home.startsWith('/'), 'home.home is an absolute path'); - assert.ok(Array.isArray(home.recent_roots), 'home.recent_roots is an array'); - console.log(`▶ fs:home returned home=${home.home}`); - - // 2. fs:browse — list immediate subdirs of $HOME. We only check the shape - // of the response; the exact directory tree depends on the host. - const browseHome = await client.fsBrowse(home.home); - assert.ok(typeof browseHome.path === 'string', 'fs:browse path is set'); - assert.ok(Array.isArray(browseHome.entries), 'fs:browse entries is an array'); - for (const e of browseHome.entries) { - assert.equal(e.is_dir, true, 'fs:browse entries are directories only'); - } - console.log(`▶ fs:browse $HOME returned ${browseHome.entries.length} subdir(s)`); - - // 3. Register the workspace. Idempotent + returns derived id. - const workspace = await client.createWorkspace({ root: wsRoot, name: 'e2e-workspace' }); - workspaceId = workspace.id; - assert.match(workspace.id, /^wd_[a-z0-9._-]+_[0-9a-f]{12}$/, 'workspace.id matches wd-key shape'); - assert.equal(workspace.name, 'e2e-workspace'); - assert.equal(workspace.session_count, 0, 'no sessions in fresh workspace'); - console.log(`▶ POST /workspaces → ${workspaceId} (root=${workspace.root})`); - - // 4. Create a session BY workspace_id (server resolves cwd from the - // registered root). The Session response carries workspace_id verbatim. - const session = await client.createSession({ workspace_id: workspaceId }); - sid = session.id; - assert.equal(session.workspace_id, workspaceId, 'session.workspace_id mirrors POST input'); - assert.equal(session.metadata.cwd, workspace.root, 'session.metadata.cwd resolved from workspace.root'); - console.log(`▶ POST /sessions { workspace_id } → ${sid}`); - - // 5. List by workspace_id (fast path via listSessions({ workDir })). - const filtered = await client.listSessions({ workspace_id: workspaceId }); - assert.equal(filtered.items.length, 1, 'workspace filter returned exactly the new session'); - const filteredSession = filtered.items[0]; - assert.ok(filteredSession, 'workspace filter returned a session'); - assert.equal(filteredSession.id, sid, 'session id round-trips through the filter'); - - // 6. Round-trip a real prompt. Skipped when DAEMON_AUTH isn't wired (the - // server answers 401xx without an authenticated provider, which surfaces - // in `submitAndWait` as a HTTP error — keep the scenario useful even - // when run without provider creds). - if (process.env['DAEMON_AUTH'] !== 'skip') { - await client.connect(); - await client.subscribe(sid); - try { - const { prompt_id, finalFrame } = await client.submitAndWait( - sid, - { - content: [ - { - type: 'text', - text: `Reply with the single word "${EXPECTED_TOKEN}" and nothing else.`, - }, - ], - }, - { waitFor: 'prompt.completed', timeoutMs: 60_000 }, - ); - console.log(`▶ prompt ${prompt_id} finalFrame=${finalFrame.type}`); - - const { items } = await client.listMessages(sid, { page_size: 100 }); - const assistant = - items.find((m) => m.role === 'assistant' && m.prompt_id === prompt_id) ?? - [...items].reverse().find((m) => m.role === 'assistant'); - assert.ok(assistant, 'expected at least one assistant message'); - const text = assistant.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join(''); - assert.ok( - text.toUpperCase().includes(EXPECTED_TOKEN), - `expected assistant text to contain "${EXPECTED_TOKEN}", got: ${JSON.stringify(text)}`, - ); - } catch (error) { - console.log(`▶ prompt round-trip skipped (likely no DAEMON_AUTH): ${String(error)}`); - } - } else { - console.log('▶ prompt round-trip skipped via DAEMON_AUTH=skip'); - } - - // 7. Unregister. Workspace count drops; session is unaffected - // (the registry entry is removed but the session subdir stays). - await client.deleteWorkspace(workspaceId); - workspaceId = undefined; - const after = await client.listWorkspaces(); - assert.equal( - after.items.find((w) => w.id === workspace.id), - undefined, - 'workspace removed from registry', - ); - // The session can still be fetched directly (delete workspace ≠ delete sessions). - const stillThere = await client.getSession(sid); - assert.equal(stillThere.id, sid, 'session survives workspace delete'); - - console.log('✓ 05-workspace: end-to-end registry + picker + session round-trip'); - } finally { - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - try { - if (workspaceId) await client.deleteWorkspace(workspaceId); - } catch { - // ignore - } - await client.close(); - try { - rmSync(wsRoot, { recursive: true, force: true }); - } catch { - // ignore - } - } -} - -main().catch((err) => { - console.error('✗ 05-workspace failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/06-model-catalog.ts b/packages/server-e2e/scenarios/06-model-catalog.ts deleted file mode 100644 index 92bfd0e914..0000000000 --- a/packages/server-e2e/scenarios/06-model-catalog.ts +++ /dev/null @@ -1,60 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 06 — model and provider catalog APIs. - * - * Exercises: - * - GET /auth - * - GET /models - * - GET /providers - * - GET /providers/{id} - * - POST /models/{id}:set_default when the current default appears in /models - */ -import assert from 'node:assert/strict'; - -import { DaemonClient } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; - -async function main() { - console.log(`▶ server at ${KIMI_SERVER_URL}`); - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - - try { - const auth = await client.getAuth(); - const models = await client.listModels(); - const providers = await client.listProviders(); - assert.ok(Array.isArray(models.items), 'GET /models returns items[]'); - assert.ok(Array.isArray(providers.items), 'GET /providers returns items[]'); - console.log( - `▶ catalog: models=${models.items.length} providers=${providers.items.length} default=${auth.default_model ?? ''}`, - ); - - const firstProvider = providers.items[0]; - if (firstProvider !== undefined) { - const provider = await client.getProvider(firstProvider.id); - assert.equal(provider.id, firstProvider.id, 'GET /providers/{id} returns the requested provider'); - console.log(`▶ catalog: provider ${provider.id} status=${provider.status}`); - } - - const defaultModel = auth.default_model; - if (defaultModel === null || !models.items.some((item) => item.model === defaultModel)) { - console.log('▶ catalog: set_default skipped because current default is not in /models'); - console.log('✓ 06-model-catalog: catalog reads round-tripped'); - return; - } - - const setDefault = await client.setDefaultModel(defaultModel); - assert.equal(setDefault.default_model, defaultModel); - assert.equal(setDefault.model.model, defaultModel); - console.log(`▶ catalog: POST /models/${defaultModel}:set_default returned current default`); - - console.log('✓ 06-model-catalog: model/provider catalog round-tripped'); - } finally { - await client.close(); - } -} - -main().catch((err) => { - console.error('✗ 06-model-catalog failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/07-session-children.ts b/packages/server-e2e/scenarios/07-session-children.ts deleted file mode 100644 index f0f8734ecf..0000000000 --- a/packages/server-e2e/scenarios/07-session-children.ts +++ /dev/null @@ -1,152 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 07 — direct child sessions. - * - * Exercises: - * - POST /sessions/{id}/children - * - GET /sessions/{id}/children - * - parent sessions can run prompts before child creation - * - child sessions can run prompts - * - direct-child listing omits grandchildren - * - missing parent returns 40401 - */ -import assert from 'node:assert/strict'; - -import { ErrorCode } from '@moonshot-ai/protocol'; - -import { DaemonClient, EnvelopeError } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const PROMPT_TIMEOUT_MS = 120_000; -const PARENT_PROMPT_TOKEN = 'PARENT_SESSION_OK'; -const CHILD_PROMPT_TOKEN = 'CHILD_SESSION_OK'; - -async function main() { - console.log(`▶ server at ${KIMI_SERVER_URL}`); - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - const sessions: string[] = []; - - try { - const parent = await client.createSession({ - title: 'server-e2e session children', - metadata: { cwd: process.cwd(), scenario: '07-session-children' }, - }); - sessions.push(parent.id); - console.log(`▶ children: parent session ${parent.id} created`); - - await client.connect(); - await client.subscribe(parent.id); - await submitPromptAndAssertToken(client, parent.id, PARENT_PROMPT_TOKEN, 'parent'); - - const child = await client.createChild(parent.id, { - title: 'server-e2e child', - metadata: { branch: 'direct-child' }, - }); - sessions.push(child.id); - assert.equal(child.metadata['parent_session_id'], parent.id); - assert.equal(child.metadata['child_session_kind'], 'child'); - console.log(`▶ children: child session ${child.id} created`); - - await client.subscribe(child.id); - await submitPromptAndAssertToken(client, child.id, CHILD_PROMPT_TOKEN, 'child'); - - const grandchild = await client.createChild(child.id, { - title: 'server-e2e grandchild', - metadata: { branch: 'grandchild' }, - }); - sessions.push(grandchild.id); - console.log(`▶ children: grandchild session ${grandchild.id} created`); - - const parentChildren = await client.listChildren(parent.id, { page_size: 10 }); - assert.ok( - parentChildren.items.some((item) => item.id === child.id), - 'parent children list includes the direct child', - ); - assert.equal( - parentChildren.items.some((item) => item.id === grandchild.id), - false, - 'parent children list omits grandchildren', - ); - - const childChildren = await client.listChildren(child.id, { page_size: 10 }); - assert.ok( - childChildren.items.some((item) => item.id === grandchild.id), - 'child children list includes the grandchild', - ); - - await expectEnvelopeCode( - () => client.listChildren(`sess_missing_children_${process.pid}`, { page_size: 10 }), - ErrorCode.SESSION_NOT_FOUND, - 'GET /sessions/{id}/children missing parent', - ); - - console.log('✓ 07-session-children: child session creation, execution, and listing round-tripped'); - } finally { - for (const sid of sessions.toReversed()) { - try { - await client.archiveSession(sid); - } catch { - // ignore - } - } - await client.close(); - } -} - -async function submitPromptAndAssertToken( - client: DaemonClient, - sid: string, - token: string, - label: 'parent' | 'child', -): Promise { - const prompt = await client.submitAndWait( - sid, - { - content: [ - { - type: 'text', - text: `Reply with the exact token ${token} and nothing else.`, - }, - ], - }, - { waitFor: 'prompt.completed', timeoutMs: PROMPT_TIMEOUT_MS }, - ); - - const messages = await client.listMessages(sid, { page_size: 100 }); - const assistant = - messages.items.find( - (message) => message.role === 'assistant' && message.prompt_id === prompt.prompt_id, - ) ?? - messages.items.find((message) => message.role === 'assistant'); - assert.ok(assistant, `${label} prompt should produce an assistant message`); - const assistantText = assistant.content - .filter((part) => part.type === 'text') - .map((part) => part.text) - .join(''); - assert.ok( - assistantText.includes(token), - `${label} assistant text should contain ${token}, got ${JSON.stringify(assistantText)}`, - ); - console.log(`▶ children: ${label} prompt ${prompt.prompt_id} completed via ${prompt.finalFrame.type}`); -} - -async function expectEnvelopeCode( - action: () => Promise, - code: ErrorCode, - label: string, -): Promise { - let caught: unknown; - try { - await action(); - } catch (error) { - caught = error; - } - assert.ok(caught instanceof EnvelopeError, `${label}: expected EnvelopeError`); - assert.equal(caught.code, code, `${label}: expected code ${code}, got ${caught.code}`); - console.log(`▶ children: ${label} returned code=${caught.code}`); -} - -main().catch((err) => { - console.error('✗ 07-session-children failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/08-pending-recovery.ts b/packages/server-e2e/scenarios/08-pending-recovery.ts deleted file mode 100644 index f69a860cc7..0000000000 --- a/packages/server-e2e/scenarios/08-pending-recovery.ts +++ /dev/null @@ -1,204 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 08 — pending reverse-RPC recovery APIs. - * - * Exercises: - * - GET /sessions/{id}/approvals?status=pending - * - POST /sessions/{id}/approvals/{approval_id} - * - GET /sessions/{id}/questions?status=pending - * - POST /sessions/{id}/questions/{question_id} - */ -import assert from 'node:assert/strict'; - -import type { QuestionAnswer } from '@moonshot-ai/protocol'; - -import { DaemonClient, type AnyFrame } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const PROMPT_TIMEOUT_MS = 120_000; -const CANARY = `KIMI_SERVER_E2E_PENDING_${process.pid}`; - -interface ApprovalRequestedPayload { - approval_id: string; - session_id: string; - tool_call_id: string; - tool_name: string; - action: string; - created_at: string; - expires_at: string; -} - -interface QuestionRequestedPayload { - question_id: string; - session_id: string; - questions: Array<{ - id: string; - question: string; - options: Array<{ id: string; label: string }>; - }>; - created_at: string; -} - -async function main() { - console.log(`▶ server at ${KIMI_SERVER_URL}`); - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - let sid: string | undefined; - - try { - const session = await client.createSession({ - title: 'server-e2e pending recovery', - metadata: { cwd: process.cwd(), scenario: '08-pending-recovery' }, - }); - sid = session.id; - console.log(`▶ pending: session ${sid} created`); - - await client.connect(); - await client.subscribe(sid); - - await exerciseApprovalRecovery(client, sid); - await exerciseQuestionRecovery(client, sid); - - console.log('✓ 08-pending-recovery: pending approvals and questions round-tripped'); - } finally { - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -async function exerciseApprovalRecovery(client: DaemonClient, sid: string): Promise { - const approvalFramePromise = client.waitForFrame(isApprovalRequestedFor(sid), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - const submit = await client.submitPrompt(sid, { - content: [ - { - type: 'text', - text: `Use the Bash tool to run \`echo ${CANARY}\`, then tell me the exact output you observed.`, - }, - ], - }); - console.log(`▶ approval: prompt ${submit.prompt_id} submitted`); - - const frame = await approvalFramePromise; - const approval = payloadOf(frame); - assert.equal(approval.session_id, sid); - - const pending = await client.listPendingApprovals(sid); - const listed = pending.items.find((item) => item.approval_id === approval.approval_id); - assert.ok(listed, 'pending approvals list includes the requested approval'); - assert.equal(listed.tool_call_id, approval.tool_call_id); - assert.equal(listed.tool_name, approval.tool_name); - console.log(`▶ approval: pending approval ${approval.approval_id} tool=${approval.tool_name}`); - - const resolved = await client.resolveApproval(sid, approval.approval_id, { - decision: 'approved', - }); - assert.equal(resolved.resolved, true); - - const after = await client.listPendingApprovals(sid); - assert.equal( - after.items.some((item) => item.approval_id === approval.approval_id), - false, - 'resolved approval is removed from pending list', - ); - - const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - console.log(`▶ approval: prompt completed via ${finalFrame.type}`); -} - -async function exerciseQuestionRecovery(client: DaemonClient, sid: string): Promise { - const questionFramePromise = client.waitForFrame(isQuestionRequestedFor(sid), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - const submit = await client.submitPrompt(sid, { - content: [ - { - type: 'text', - text: [ - 'Use the AskUserQuestion tool now.', - 'Ask exactly one question: "Which server e2e recovery option should continue?"', - 'Use header "E2E".', - 'Use two options: "Continue (Recommended)" and "Stop".', - 'After I answer, reply with the selected option label.', - ].join(' '), - }, - ], - }); - console.log(`▶ question: prompt ${submit.prompt_id} submitted`); - - const frame = await questionFramePromise; - const question = payloadOf(frame); - assert.equal(question.session_id, sid); - - const pending = await client.listPendingQuestions(sid); - const listed = pending.items.find((item) => item.question_id === question.question_id); - assert.ok(listed, 'pending questions list includes the requested question'); - assert.ok(listed.questions.length > 0, 'pending question carries questions[]'); - console.log(`▶ question: pending question ${question.question_id} items=${listed.questions.length}`); - - const answers: Record = {}; - for (const item of listed.questions) { - const firstOption = item.options[0]; - assert.ok(firstOption, `question ${item.id} should have at least one option`); - answers[item.id] = { kind: 'single', option_id: firstOption.id }; - } - - const resolved = await client.resolveQuestion(sid, question.question_id, { - answers, - method: 'click', - }); - assert.equal(resolved.resolved, true); - - const after = await client.listPendingQuestions(sid); - assert.equal( - after.items.some((item) => item.question_id === question.question_id), - false, - 'resolved question is removed from pending list', - ); - - const finalFrame = await client.waitForFrame(isPromptCompleted(submit.prompt_id), { - timeoutMs: PROMPT_TIMEOUT_MS, - }); - console.log(`▶ question: prompt completed via ${finalFrame.type}`); -} - -function isApprovalRequestedFor(sid: string): (frame: AnyFrame) => boolean { - return (frame) => - frame.type === 'event.approval.requested' && - payloadSessionId(frame) === sid; -} - -function isQuestionRequestedFor(sid: string): (frame: AnyFrame) => boolean { - return (frame) => - frame.type === 'event.question.requested' && - payloadSessionId(frame) === sid; -} - -function isPromptCompleted(promptId: string): (frame: AnyFrame) => boolean { - return (frame) => { - if (frame.type !== 'prompt.completed') return false; - const payload = (frame.payload ?? {}) as { prompt_id?: string; promptId?: string }; - return (payload.prompt_id ?? payload.promptId) === promptId; - }; -} - -function payloadSessionId(frame: AnyFrame): string | undefined { - const payload = frame.payload as { session_id?: string } | undefined; - return payload?.session_id; -} - -function payloadOf(frame: AnyFrame): T { - assert.ok(frame.payload !== undefined, `${frame.type} frame should carry payload`); - return frame.payload as T; -} - -main().catch((err) => { - console.error('✗ 08-pending-recovery failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/09-image-file-prompts.ts b/packages/server-e2e/scenarios/09-image-file-prompts.ts deleted file mode 100644 index e8e4ff1384..0000000000 --- a/packages/server-e2e/scenarios/09-image-file-prompts.ts +++ /dev/null @@ -1,143 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 09 — uploaded image files in prompt content. - * - * Exercises: - * - missing prompt image file_id returns FILE_NOT_FOUND - * - non-image uploaded file used as image content returns VALIDATION_FAILED - * - uploaded PNG can be referenced by a prompt submission - */ -import assert from 'node:assert/strict'; - -import { ErrorCode } from '@moonshot-ai/protocol'; - -import { DaemonClient, EnvelopeError } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const SHORT_TIMEOUT_MS = 15_000; - -const ONE_BY_ONE_PNG = Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/p9sAAAAASUVORK5CYII=', - 'base64', -); - -async function main() { - console.log(`▶ server at ${KIMI_SERVER_URL}`); - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - const files: string[] = []; - let sid: string | undefined; - - try { - const session = await client.createSession({ - title: 'server-e2e image file prompts', - metadata: { cwd: process.cwd(), scenario: '09-image-file-prompts' }, - }); - sid = session.id; - console.log(`▶ image-file: session ${sid} created`); - - await expectEnvelopeCode( - () => - client.submitPrompt(sid!, { - content: [ - { - type: 'image', - source: { kind: 'file', file_id: 'file_missing_daemon_e2e' }, - }, - ], - }), - ErrorCode.FILE_NOT_FOUND, - 'missing prompt image file_id', - ); - - const textFile = await client.uploadFile({ - name: 'not-an-image.txt', - data: 'not an image', - mediaType: 'text/plain', - }); - files.push(textFile.id); - await expectEnvelopeCode( - () => - client.submitPrompt(sid!, { - content: [ - { - type: 'image', - source: { kind: 'file', file_id: textFile.id }, - }, - ], - }), - ErrorCode.VALIDATION_FAILED, - 'non-image prompt file_id', - ); - - const png = await client.uploadFile({ - name: 'tiny.png', - data: ONE_BY_ONE_PNG, - mediaType: 'image/png', - }); - files.push(png.id); - assert.equal(png.media_type, 'image/png'); - assert.equal(png.size, ONE_BY_ONE_PNG.length); - - const submit = await client.submitPrompt(sid, { - content: [ - { type: 'text', text: 'Reply with the single word "OK" after reading this image.' }, - { type: 'image', source: { kind: 'file', file_id: png.id } }, - ], - }); - assert.ok(submit.prompt_id.length > 0, 'image file prompt returns a prompt_id'); - console.log(`▶ image-file: uploaded ${png.id} and submitted prompt ${submit.prompt_id}`); - - try { - await client.abortPrompt(sid, submit.prompt_id); - console.log(`▶ image-file: prompt ${submit.prompt_id} aborted after submit`); - } catch (error) { - if ( - error instanceof EnvelopeError && - (error.code === ErrorCode.PROMPT_ALREADY_COMPLETED || - error.code === ErrorCode.PROMPT_NOT_FOUND) - ) { - console.log(`▶ image-file: prompt ${submit.prompt_id} was already terminal before abort`); - } else { - throw error; - } - } - await client.waitForSessionBusy(sid, false, { timeoutMs: SHORT_TIMEOUT_MS }); - - console.log('✓ 09-image-file-prompts: image file prompt references round-tripped'); - } finally { - for (const fileId of files.toReversed()) { - try { - await client.deleteFile(fileId); - } catch { - // ignore - } - } - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -async function expectEnvelopeCode( - action: () => Promise, - code: ErrorCode, - label: string, -): Promise { - let caught: unknown; - try { - await action(); - } catch (error) { - caught = error; - } - assert.ok(caught instanceof EnvelopeError, `${label}: expected EnvelopeError`); - assert.equal(caught.code, code, `${label}: expected code ${code}, got ${caught.code}`); - console.log(`▶ image-file: ${label} returned code=${caught.code}`); -} - -main().catch((err) => { - console.error('✗ 09-image-file-prompts failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scenarios/11-terminal.ts b/packages/server-e2e/scenarios/11-terminal.ts deleted file mode 100644 index 3cb67f94bf..0000000000 --- a/packages/server-e2e/scenarios/11-terminal.ts +++ /dev/null @@ -1,266 +0,0 @@ -#!/usr/bin/env node -/** - * Scenario 11 — server terminal REST + WS controls. - * - * Exercises: - * - POST /sessions/{id}/terminals - * - GET /sessions/{id}/terminals - * - GET /sessions/{id}/terminals/{terminal_id} - * - WS terminal_attach / terminal_input / terminal_resize / terminal_close - * - terminal_output and terminal_exit frames - */ -import assert from 'node:assert/strict'; - -import { DaemonClient, recordReportEvent, type AnyFrame } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; -const TERMINAL_SHELL = process.env['KIMI_SERVER_E2E_TERMINAL_SHELL'] ?? '/bin/sh'; -const OUTPUT_TIMEOUT_MS = 20_000; -const EXIT_TIMEOUT_MS = 5_000; -const CANARY = `KIMI_SERVER_E2E_TERMINAL_${process.pid}_${Date.now()}`; - -async function main() { - log('server', { base_url: KIMI_SERVER_URL, shell: TERMINAL_SHELL }); - const client = new DaemonClient({ baseUrl: KIMI_SERVER_URL }); - - let sid: string | undefined; - let terminalId: string | undefined; - let terminalClosed = false; - - try { - const session = await client.createSession({ - title: 'server-e2e terminal', - metadata: { cwd: process.cwd(), scenario: '11-terminal' }, - }); - sid = session.id; - const sessionId = session.id; - log('session created', { session_id: sessionId, cwd: session.metadata.cwd }); - - await client.connect(); - await client.subscribe(sessionId); - log('ws subscribed', { session_id: sessionId }); - - const terminal = await client.createTerminal(sessionId, { - shell: TERMINAL_SHELL, - cols: 80, - rows: 24, - }); - terminalId = terminal.id; - assert.equal(terminal.session_id, sessionId); - assert.equal(terminal.status, 'running'); - assert.equal(terminal.cols, 80); - assert.equal(terminal.rows, 24); - log('terminal created', terminalForLog(terminal)); - - const listed = await client.listTerminals(sessionId); - assert.ok( - listed.items.some((item) => item.id === terminal.id), - 'terminal list should include the created terminal', - ); - log('terminal list', { - count: listed.items.length, - ids: listed.items.map((item) => item.id), - }); - - const observedOutput: string[] = []; - const unsubscribe = client.onFrame((frame) => { - if (!isTerminalOutputFor(frame, sessionId, terminal.id)) return; - observedOutput.push(payloadOf(frame).data); - }); - - try { - const attach = await client.attachTerminal(sessionId, terminal.id, { sinceSeq: 0 }); - assert.equal(attach.attached, true); - assert.equal(typeof attach.replayed, 'number'); - log('terminal attached', attach); - - const input = `printf '%b\\n' '${toShellOctalEscapes(CANARY)}'\n`; - const inputAck = await client.writeTerminalInput(sessionId, terminal.id, input); - assert.deepEqual(inputAck, { accepted: true }); - log('terminal input accepted', { - input_bytes: input.length, - expected_output: CANARY, - }); - - const output = await waitForTerminalText(observedOutput, CANARY, OUTPUT_TIMEOUT_MS); - log('terminal output observed', { - matched: CANARY, - output_tail: printableTail(output), - }); - - const resizeAck = await client.resizeTerminal(sessionId, terminal.id, 100, 31); - assert.deepEqual(resizeAck, { resized: true }); - const resized = await client.getTerminal(sessionId, terminal.id); - assert.equal(resized.cols, 100); - assert.equal(resized.rows, 31); - log('terminal resized', terminalForLog(resized)); - - const exitFramePromise = client.waitForFrame(isTerminalExitFor(sessionId, terminal.id), { - timeoutMs: EXIT_TIMEOUT_MS, - }); - const closeAck = await client.closeTerminalControl(sessionId, terminal.id); - terminalClosed = true; - assert.deepEqual(closeAck, { closed: true }); - log('terminal close ack', closeAck); - - const exitFrame = await exitFramePromise; - log('terminal exit frame', frameForLog(exitFrame)); - - const closed = await client.getTerminal(sessionId, terminal.id); - assert.equal(closed.status, 'exited'); - log('terminal final state', terminalForLog(closed)); - } finally { - unsubscribe(); - } - - writeLine(`✓ 11-terminal: terminal ${terminal.id} output, resize, and close round-tripped`); - } finally { - if (sid !== undefined && terminalId !== undefined && !terminalClosed) { - try { - await client.closeTerminal(sid, terminalId); - } catch { - // ignore - } - } - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -interface TerminalOutputPayload { - data: string; -} - -interface TerminalForLog { - id: string; - session_id: string; - cwd: string; - shell: string; - cols: number; - rows: number; - status: string; - exit_code?: number | null; -} - -function isTerminalOutputFor( - frame: AnyFrame, - sid: string, - terminalId: string, -): boolean { - const terminalFrame = frame as AnyFrame & { terminal_id?: string }; - return ( - frame.type === 'terminal_output' && - frame.session_id === sid && - terminalFrame.terminal_id === terminalId - ); -} - -function isTerminalExitFor( - sid: string, - terminalId: string, -): (frame: AnyFrame) => boolean { - return (frame) => { - const terminalFrame = frame as AnyFrame & { terminal_id?: string }; - return ( - frame.type === 'terminal_exit' && - frame.session_id === sid && - terminalFrame.terminal_id === terminalId - ); - }; -} - -async function waitForTerminalText( - chunks: readonly string[], - expected: string, - timeoutMs: number, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const text = chunks.join(''); - if (text.includes(expected)) return text; - await sleep(25); - } - throw new Error( - `terminal output did not include ${JSON.stringify(expected)} within ${timeoutMs}ms; ` + - `output tail=${JSON.stringify(printableTail(chunks.join('')))}`, - ); -} - -function toShellOctalEscapes(value: string): string { - return Array.from(value) - .map((char) => `\\${char.codePointAt(0)!.toString(8).padStart(3, '0')}`) - .join(''); -} - -function payloadOf(frame: AnyFrame): T { - assert.ok(frame.payload !== undefined, `${frame.type} frame should carry payload`); - return frame.payload as T; -} - -function terminalForLog(terminal: TerminalForLog): Record { - return { - id: terminal.id, - session_id: terminal.session_id, - cwd: terminal.cwd, - shell: terminal.shell, - cols: terminal.cols, - rows: terminal.rows, - status: terminal.status, - exit_code: terminal.exit_code, - }; -} - -function frameForLog(frame: AnyFrame): Record { - const terminalFrame = frame as AnyFrame & { terminal_id?: string }; - return { - type: frame.type, - session_id: frame.session_id, - terminal_id: terminalFrame.terminal_id, - seq: frame.seq, - payload: frame.payload, - }; -} - -function printableTail(text: string): string { - return text - .slice(-1000) - .replaceAll('\r', '\\r') - .replaceAll('\u001B', '\\x1B'); -} - -function log(label: string, value?: unknown): void { - recordReportEvent({ kind: 'log', label: `terminal: ${label}`, value }); - if (value === undefined) { - writeLine(`▶ terminal: ${label}`); - return; - } - writeLine(`▶ terminal: ${label} ${JSON.stringify(value)}`); -} - -function sleep(ms: number): Promise { - return new Promise((resolve) => { - setTimeout(resolve, ms); - }); -} - -function writeLine(line: string): void { - process.stdout.write(`${line}\n`); -} - -try { - await main(); -} catch (error) { - process.stderr.write(`✗ 11-terminal failed: ${formatError(error)}\n`); - process.exit(1); -} - -function formatError(error: unknown): string { - if (error instanceof Error) { - return `${error.stack ?? error.message}`; - } - return String(error); -} diff --git a/packages/server-e2e/scenarios/README.md b/packages/server-e2e/scenarios/README.md deleted file mode 100644 index 85c293a96e..0000000000 --- a/packages/server-e2e/scenarios/README.md +++ /dev/null @@ -1,57 +0,0 @@ -# Scenarios - -Each `.ts` file under this directory is an executable wire-level test of a -single user-facing flow against a running server. - -## Running - -Default base URL is `http://127.0.0.1:58627`. Start a server (`pnpm dev:server` -from the repo root) before invoking scenarios. - -Scenarios import directly from the package's `src/*.ts` so they need a TS -loader. `tsx` (a workspace devDependency) works out of the box: - -```sh -# Single scenario -npx tsx packages/server-e2e/scenarios/01-create-and-send.ts - -# All scenarios (sequential; first failure exits non-zero) -pnpm --filter @moonshot-ai/server-e2e test:scenarios - -# Custom server URL -KIMI_SERVER_URL=http://127.0.0.1:8080 npx tsx packages/server-e2e/scenarios/02-tool-call-with-approval.ts -``` - -`test:scenarios` writes `reports/latest/index.html` with the scenario timeline, -including stdout/stderr milestones, HTTP request / response envelopes, and -WebSocket frames. Set `KIMI_SERVER_E2E_REPORT_DIR=/tmp/server-e2e-report` to write it -somewhere else. - -## Catalog - -| File | What it does | -|---|---| -| `_template.ts` | Copy-paste starting point. No assertions; smoke-tests the lifecycle. | -| `01-create-and-send.ts` | Happy path: create session → submit prompt → assert assistant replied with the expected token. | -| `02-tool-call-with-approval.ts` | Drives a Bash tool call; built-in approval handler auto-approves; asserts canary round-trips through `tool_result` AND assistant text; asserts session ends in `idle`. | -| `03-refresh-replay.ts` | "User refreshes the browser" worst case: Phase 0 probes (`/healthz`, `/meta`, `/auth`) → WS handshake → prompt to populate the ring buffer → fresh WS with `last_seq_by_session` (caught-up first, then `0` for full replay) → REST snapshot → steady-state follow-up prompt. Asserts replay ordering (seq 1..N) and that no `resync_required` fires while the buffer covers the gap. | -| `05-workspace.ts` | Workspace registry + folder picker happy path: `fs:home` → `fs:browse $HOME` → `POST /workspaces { root }` → `POST /sessions { workspace_id }` → `GET /sessions?workspace_id=` → prompt round-trip (skipped without provider auth) → `DELETE /workspaces/{id}` (verifies the session survives). | -| `06-model-catalog.ts` | Model/provider catalog reads and current-default `:set_default` round-trip. | -| `07-session-children.ts` | Direct child session creation, child prompt execution, direct-child listing, and missing-parent `40401`. | -| `08-pending-recovery.ts` | Pending approval and pending question recovery APIs, including resolve and pending-list cleanup. | -| `09-image-file-prompts.ts` | Uploaded file prompt references: missing file, non-image validation, and PNG prompt submission. | -| `11-terminal.ts` | Terminal flow: create/list/get a session terminal, attach over WS, write input, observe output, resize, close, and assert terminal exit/final state. | - -## Writing a new scenario - -Copy `_template.ts` and fill in the TODO block. - -Conventions: -- Exit `0` on pass; non-zero on any assertion failure or unhandled rejection. -- Always `try { ... } finally { close + delete session }`. -- Print `▶` for milestones and `✓ / ✗` for the final outcome — `test:scenarios` - greps for those prefixes when surfacing CI logs. -- Default timeouts to 60s; tool-call scenarios may want 120s. -- Use `client.onApprovalRequested` / `client.onQuestionAsked` to auto-resolve - reverse-RPC requests — bypassing them risks 60s server-side timeouts that - look like flaky scenarios. diff --git a/packages/server-e2e/scenarios/_template.ts b/packages/server-e2e/scenarios/_template.ts deleted file mode 100644 index 67aa8ea214..0000000000 --- a/packages/server-e2e/scenarios/_template.ts +++ /dev/null @@ -1,54 +0,0 @@ -#!/usr/bin/env node -/** - * Template scenario — copy-paste starting point. - * - * Usage: - * KIMI_SERVER_URL=http://127.0.0.1:58627 npx tsx scenarios/_template.ts - * - * (`tsx` is a workspace devDependency; it handles the `.ts` imports below. - * Plain `node` won't resolve them.) - * - * Each scenario: - * 1. Constructs a `DaemonClient` pointed at the live server. - * 2. Opens an HTTP session and a WS connection. - * 3. Subscribes to the session, drives some flow, asserts on the result. - * 4. Cleans up — close the WS, delete the session. - */ -import { DaemonClient } from '../src/index'; - -const KIMI_SERVER_URL = process.env['KIMI_SERVER_URL'] ?? 'http://127.0.0.1:58627'; - -async function main() { - const client = new DaemonClient({ - baseUrl: KIMI_SERVER_URL, - logger: (level, msg, meta) => console.log(`[${level}] ${msg}`, meta ?? ''), - }); - - let sid: string | undefined; - try { - const session = await client.createSession({ metadata: { cwd: process.cwd() } }); - sid = session.id; - - await client.connect(); - await client.subscribe(sid); - - // TODO: drive your scenario here. Examples: - // - await client.submitAndWait(sid, { content: [{ type: 'text', text: '...' }] }); - // - client.onApprovalRequested((req) => ({ decision: 'approved' })); - // - const final = await client.waitForFrame(f => f.type === 'turn.ended'); - - console.log('✓ scenario template ran (no assertions)'); - } finally { - try { - if (sid) await client.archiveSession(sid); - } catch { - // ignore - } - await client.close(); - } -} - -main().catch((err) => { - console.error('✗ scenario failed:', err); - process.exit(1); -}); diff --git a/packages/server-e2e/scripts/run-scenarios.ts b/packages/server-e2e/scripts/run-scenarios.ts deleted file mode 100644 index ffed8b33a5..0000000000 --- a/packages/server-e2e/scripts/run-scenarios.ts +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env node -import { createInterface } from 'node:readline'; -import { readdirSync } from 'node:fs'; -import { join, resolve } from 'node:path'; -import { spawn } from 'node:child_process'; - -import { - defaultReportDir, - recordReportEvent, - resetReportDir, - writeHtmlReport, -} from '../src/report.js'; - -const packageRoot = resolve(import.meta.dirname, '..'); -const scenariosDir = join(packageRoot, 'scenarios'); -const tsxBin = process.platform === 'win32' ? 'tsx.cmd' : 'tsx'; - -async function main(): Promise { - const reportDir = defaultReportDir(); - resetReportDir(reportDir); - - const scenarios = readdirSync(scenariosDir) - .filter((file) => file.endsWith('.ts')) - .toSorted(); - - let failed = false; - for (const file of scenarios) { - const caseName = file.slice(0, -'.ts'.length); - const scenarioPath = join('scenarios', file); - process.stdout.write(`▶ ${scenarioPath}\n`); - recordReportEvent( - { kind: 'log', caseName, label: 'scenario started', value: { file: scenarioPath } }, - { reportDir }, - ); - - const result = await runScenario(file, caseName, reportDir); - recordReportEvent( - { - kind: 'test-result', - caseName, - state: result.exitCode === 0 ? 'passed' : 'failed', - error: result.exitCode === 0 ? undefined : { exitCode: result.exitCode }, - }, - { reportDir }, - ); - if (result.exitCode !== 0) { - failed = true; - break; - } - } - - const htmlPath = writeHtmlReport({ - reportDir, - title: `server-e2e scenarios (${failed ? 'failed' : 'passed'})`, - }); - process.stdout.write(`[server-e2e] HTML report: ${htmlPath}\n`); - if (failed) process.exit(1); -} - -async function runScenario( - file: string, - caseName: string, - reportDir: string, -): Promise<{ exitCode: number }> { - const child = spawn(tsxBin, [join('scenarios', file)], { - cwd: packageRoot, - env: { - ...process.env, - KIMI_SERVER_E2E_CASE_NAME: caseName, - KIMI_SERVER_E2E_REPORT_DIR: reportDir, - }, - stdio: ['ignore', 'pipe', 'pipe'], - }); - - captureLines(child.stdout, caseName, 'stdout', reportDir); - captureLines(child.stderr, caseName, 'stderr', reportDir); - - return new Promise((resolveScenario) => { - child.on('error', (error) => { - recordReportEvent( - { - kind: 'log', - caseName, - label: 'spawn error', - value: { name: error.name, message: error.message }, - }, - { reportDir }, - ); - resolveScenario({ exitCode: 1 }); - }); - child.on('close', (code) => { - resolveScenario({ exitCode: code ?? 1 }); - }); - }); -} - -function captureLines( - stream: NodeJS.ReadableStream, - caseName: string, - label: 'stdout' | 'stderr', - reportDir: string, -): void { - const lines = createInterface({ input: stream }); - lines.on('line', (line) => { - const output = label === 'stderr' ? process.stderr : process.stdout; - output.write(`${line}\n`); - recordReportEvent({ kind: 'log', caseName, label, value: line }, { reportDir }); - }); -} - -try { - await main(); -} catch (error) { - recordReportEvent({ - kind: 'test-result', - caseName: 'scenario runner', - state: 'failed', - error: error instanceof Error ? { name: error.name, message: error.message } : error, - }); - const htmlPath = writeHtmlReport({ - title: 'server-e2e scenarios (failed)', - }); - process.stderr.write(`✗ scenario runner failed: ${String(error)}\n`); - process.stderr.write(`[server-e2e] HTML report: ${htmlPath}\n`); - process.exit(1); -} diff --git a/packages/server-e2e/src/v2/client.ts b/packages/server-e2e/src/v2/client.ts deleted file mode 100644 index e3eef8903c..0000000000 --- a/packages/server-e2e/src/v2/client.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** - * `ServerClient` — the lark-style client SDK for `server-v2` (`/api/v2`). - * - * Resource tree mirrors the server's `actionMap`: - * sdk.sessions.list({ page_size: 20 }) // core - * sdk.workspaces.createOrTouch('/path') // core - * sdk.session(sid).setTitle('renamed') // session (flattened) - * sdk.session(sid).approvals.decide(aid, body) // session resource - * sdk.session(sid).agent('main').prompts.submit({…}) // agent resource - * - * RPC runs over HTTP (`POST :`); events run over a single - * `/api/v2/ws` socket opened by `connect()`. The legacy `/api/v1` REST surface - * is still reachable via `sdk.v1` (the unchanged v1 `HttpClient`). - */ -import { HttpClient } from '../http.js'; - -import { createCoreResources, type CoreResources } from './resources/core.js'; -import { EventsClient } from './resources/events.js'; -import { SessionScope } from './resources/session.js'; -import { HttpRpc, type ScopeKind, type ScopeParams } from './transport/http.js'; -import { - type AnyMethod, - type DynamicResource, - makeDynamicResource, -} from './transport/rpcProxy.js'; -import { V2Socket, type V2SocketOptions } from './transport/ws.js'; - -export interface ServerClientOptions { - /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ - readonly baseUrl: string; - /** Default `/api/v2`. */ - readonly apiPrefix?: string; - /** Optional bearer token (sent on HTTP + WS upgrade). */ - readonly token?: string; - /** Override `fetch` (testing). */ - readonly fetchImpl?: typeof fetch; - /** Override the WebSocket implementation (testing / browser). */ - readonly wsImpl?: V2SocketOptions['wsImpl']; - /** Default 30s. Per-RPC-call deadline on the WS transport. */ - readonly callTimeoutMs?: number; - /** Directory for v1 report capture (passed through to the v1 `HttpClient`). */ - readonly reportDir?: string; -} - -export class ServerClient implements CoreResources { - readonly baseUrl: string; - readonly apiPrefix: string; - - /** Legacy `/api/v1` REST client (unchanged). */ - readonly v1: HttpClient; - - readonly sessions: CoreResources['sessions']; - readonly workspaces: CoreResources['workspaces']; - readonly config: CoreResources['config']; - readonly providers: CoreResources['providers']; - readonly oauth: CoreResources['oauth']; - readonly auth: CoreResources['auth']; - readonly flags: CoreResources['flags']; - readonly plugins: CoreResources['plugins']; - readonly fs: CoreResources['fs']; - readonly meta: CoreResources['meta']; - - private readonly rpc: HttpRpc; - private readonly token: string | undefined; - private readonly wsImpl: ServerClientOptions['wsImpl']; - private readonly callTimeoutMs: number | undefined; - - private socket: V2Socket | null = null; - private eventsClient: EventsClient | null = null; - - constructor(opts: ServerClientOptions) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); - this.apiPrefix = opts.apiPrefix ?? '/api/v2'; - this.token = opts.token; - this.wsImpl = opts.wsImpl; - this.callTimeoutMs = opts.callTimeoutMs; - - this.rpc = new HttpRpc({ - baseUrl: this.baseUrl, - apiPrefix: this.apiPrefix, - token: opts.token, - fetchImpl: opts.fetchImpl, - }); - - const core = createCoreResources(this.rpc); - this.sessions = core.sessions; - this.workspaces = core.workspaces; - this.config = core.config; - this.providers = core.providers; - this.oauth = core.oauth; - this.auth = core.auth; - this.flags = core.flags; - this.plugins = core.plugins; - this.fs = core.fs; - this.meta = core.meta; - - this.v1 = new HttpClient({ - baseUrl: this.baseUrl, - apiPrefix: '/api/v1', - fetchImpl: opts.fetchImpl ?? fetch, - reportDir: opts.reportDir, - token: opts.token, - }); - } - - /** Enter the session scope for `sessionId`. */ - session(sessionId: string): SessionScope { - return new SessionScope(this.rpc, sessionId); - } - - /** Escape hatch for a core resource not (yet) in the manifest. */ - core = DynamicResource>(resource: string): T { - return makeDynamicResource(this.rpc, 'core', {}, resource) as T; - } - - /** Raw RPC — call any `:` in any scope. */ - call(scope: ScopeKind, params: ScopeParams, sa: string, arg?: unknown): Promise { - return this.rpc.call(scope, params, sa, arg); - } - - /** Open the `/api/v2/ws` socket and return the events client. Idempotent. */ - async connect(): Promise { - if (this.eventsClient) return this.eventsClient; - const socket = new V2Socket({ - baseUrl: this.baseUrl, - apiPrefix: this.apiPrefix, - token: this.token, - wsImpl: this.wsImpl, - callTimeoutMs: this.callTimeoutMs, - }); - await socket.connect(); - this.socket = socket; - this.eventsClient = new EventsClient(socket); - return this.eventsClient; - } - - /** The events client. Throws if `connect()` has not been called. */ - get events(): EventsClient { - if (!this.eventsClient) { - throw new Error('events not connected — call `await client.connect()` first'); - } - return this.eventsClient; - } - - /** Close the WS socket (if open). HTTP RPC is stateless and needs no close. */ - async close(): Promise { - if (this.socket) { - await this.socket.close(); - this.socket = null; - this.eventsClient = null; - } - } -} diff --git a/packages/server-e2e/src/v2/errors.ts b/packages/server-e2e/src/v2/errors.ts deleted file mode 100644 index 5b7fa19fde..0000000000 --- a/packages/server-e2e/src/v2/errors.ts +++ /dev/null @@ -1,37 +0,0 @@ -/** - * v2 RPC error + unwrap helpers. - * - * The `/api/v2` surface uses the same `{ code, msg, data, request_id }` - * envelope as v1, but unlike v1 it legitimately returns `code: 0` with - * `data: null` for "no body" actions (e.g. `session:setTitle`). The v1 - * `unwrap()` in `envelope.ts` treats `data === null` as a hard error, so v2 - * needs its own unwrap that only rejects on `code !== 0`. - */ -import type { Envelope } from '@moonshot-ai/protocol'; - -/** Thrown when an `/api/v2` call lands with a non-zero `code`. */ -export class RpcError extends Error { - readonly code: number; - readonly requestId: string | undefined; - readonly details: unknown; - - constructor(envelope: Envelope) { - super(`server-v2 returned code=${envelope.code}: ${envelope.msg}`); - this.name = 'RpcError'; - this.code = envelope.code; - this.requestId = envelope.request_id; - this.details = (envelope as { details?: unknown }).details; - } -} - -/** - * Unwrap a v2 envelope. Returns `data` (which may be `null`) on `code === 0`; - * throws {@link RpcError} otherwise. - */ -export function unwrapData(envelope: Envelope): T { - if (envelope.code !== 0) throw new RpcError(envelope as Envelope); - // The protocol envelope types `data` as `T | null`; v2 actions that return a - // body are typed `T` (non-null) by the caller, and `code: 0` + null data is a - // legitimate "no body" success for actions typed `T = null`. Cast through. - return envelope.data as T; -} diff --git a/packages/server-e2e/src/v2/index.ts b/packages/server-e2e/src/v2/index.ts deleted file mode 100644 index 90ac4baab1..0000000000 --- a/packages/server-e2e/src/v2/index.ts +++ /dev/null @@ -1,91 +0,0 @@ -/** - * `@moonshot-ai/server-e2e` v2 SDK — a lark-style, typed client for the - * `server-v2` `/api/v2` RPC + WS surface. - * - * import { ServerClient } from '@moonshot-ai/server-e2e'; - * - * const sdk = new ServerClient({ baseUrl: 'http://127.0.0.1:58627', token }); - * const { items } = await sdk.sessions.list({ page_size: 20 }); - * await sdk.session(items[0].id).setTitle('renamed'); - * await sdk.session(items[0].id).agent('main').prompts.submit({ input: [...] }); - * - * const events = await sdk.connect(); - * const off = events.onAgentEvents(sid, 'main', (e) => { ... }); - * - * The legacy `/api/v1` REST surface is reachable via `sdk.v1`. - */ -export { ServerClient } from './client.js'; -export type { ServerClientOptions } from './client.js'; - -export { SessionScope } from './resources/session.js'; -export { AgentScope } from './resources/agent.js'; -export { EventsClient } from './resources/events.js'; -export type { Unlisten } from './resources/events.js'; - -export type { - CoreResources, - SessionsResource, - WorkspacesResource, - ConfigResource, - ProvidersResource, - OAuthResource, - AuthResource, - FlagsResource, - PluginsResource, - CoreFsResource, - MetaResource, -} from './resources/core.js'; -export type { - SessionResource, - ApprovalsResource, - QuestionsResource, - InteractionsResource, - SessionWorkspaceResource, - SessionFsResource, -} from './resources/session.js'; -export type { - GoalResource, - PlanResource, - TasksResource, - UsageResource, - ContextResource, - SwarmResource, - PermissionResource, - PermissionRulesResource, - ProfileResource, - MessagesResource, - McpResource, - ToolsResource, - PromptsResource, - ShellResource, - AgentPluginsResource, -} from './resources/agent.js'; - -export type { - SessionMeta, - WorkspaceInfo, - ListResult, - PromptSubmitArg, - PromptInputPart, - PromptSubmitResult, - ShellRunArg, - ShellRunResult, -} from './resources/types.js'; - -export { CORE, SESSION, AGENT, flattenManifest } from './resources/manifest.js'; -export type { CoreManifest, SessionManifest, AgentManifest } from './resources/manifest.js'; - -export { HttpRpc } from './transport/http.js'; -export type { HttpRpcOptions, ScopeKind, ScopeParams } from './transport/http.js'; -export { - makeResource, - makeDynamicResource, - type ActionMeta, - type AnyMethod, - type DynamicResource, - type ResourceShape, -} from './transport/rpcProxy.js'; -export { V2Socket } from './transport/ws.js'; -export type { V2SocketOptions } from './transport/ws.js'; - -export { RpcError, unwrapData } from './errors.js'; diff --git a/packages/server-e2e/src/v2/resources/agent.ts b/packages/server-e2e/src/v2/resources/agent.ts deleted file mode 100644 index b90dfd2748..0000000000 --- a/packages/server-e2e/src/v2/resources/agent.ts +++ /dev/null @@ -1,109 +0,0 @@ -/** - * Agent-scope resources — `/api/v2/session//agent//:`. - * - * Exposes one typed resource per entry in the `AGENT` manifest, plus a - * `service(resource)` escape hatch for actions not (yet) in the manifest. - */ -import type { HttpRpc } from '../transport/http.js'; -import { - type AnyMethod, - type DynamicResource, - makeDynamicResource, - makeResource, - type ResourceShape, -} from '../transport/rpcProxy.js'; - -import { AGENT, type AgentManifest } from './manifest.js'; -import type { ProfilePrecise, PromptsPrecise, ShellPrecise } from './types.js'; - -export type GoalResource = ResourceShape; -export type PlanResource = ResourceShape; -export type TasksResource = ResourceShape; -export type UsageResource = ResourceShape; -export type ContextResource = ResourceShape; -export type SwarmResource = ResourceShape; -export type PermissionResource = ResourceShape; -export type PermissionRulesResource = ResourceShape; -export type ProfileResource = ResourceShape; -export type MessagesResource = ResourceShape; -export type McpResource = ResourceShape; -export type ToolsResource = ResourceShape; -export type PromptsResource = ResourceShape; -export type ShellResource = ResourceShape; -export type AgentPluginsResource = ResourceShape; - -/** Agent scope handle — obtained via `session.agent(agentId)`. */ -export class AgentScope { - readonly goal: GoalResource; - readonly plan: PlanResource; - readonly tasks: TasksResource; - readonly usage: UsageResource; - readonly context: ContextResource; - readonly swarm: SwarmResource; - readonly permission: PermissionResource; - readonly permissionRules: PermissionRulesResource; - readonly profile: ProfileResource; - readonly messages: MessagesResource; - readonly mcp: McpResource; - readonly tools: ToolsResource; - readonly prompts: PromptsResource; - readonly shell: ShellResource; - readonly plugins: AgentPluginsResource; - - constructor( - private readonly rpc: HttpRpc, - readonly sessionId: string, - readonly agentId: string, - ) { - const params = { sessionId, agentId }; - this.goal = makeResource(rpc, 'agent', params, 'goal', AGENT.goal); - this.plan = makeResource(rpc, 'agent', params, 'plan', AGENT.plan); - this.tasks = makeResource(rpc, 'agent', params, 'tasks', AGENT.tasks); - this.usage = makeResource(rpc, 'agent', params, 'usage', AGENT.usage); - this.context = makeResource(rpc, 'agent', params, 'context', AGENT.context); - this.swarm = makeResource(rpc, 'agent', params, 'swarm', AGENT.swarm); - this.permission = makeResource(rpc, 'agent', params, 'permission', AGENT.permission); - this.permissionRules = makeResource( - rpc, - 'agent', - params, - 'permissionRules', - AGENT.permissionRules, - ); - this.profile = makeResource( - rpc, - 'agent', - params, - 'profile', - AGENT.profile, - ); - this.messages = makeResource(rpc, 'agent', params, 'messages', AGENT.messages); - this.mcp = makeResource(rpc, 'agent', params, 'mcp', AGENT.mcp); - this.tools = makeResource(rpc, 'agent', params, 'tools', AGENT.tools); - this.prompts = makeResource( - rpc, - 'agent', - params, - 'prompts', - AGENT.prompts, - ); - this.shell = makeResource( - rpc, - 'agent', - params, - 'shell', - AGENT.shell, - ); - this.plugins = makeResource(rpc, 'agent', params, 'plugins', AGENT.plugins); - } - - /** Escape hatch for an agent resource not (yet) in the manifest. */ - service = DynamicResource>(resource: string): T { - return makeDynamicResource( - this.rpc, - 'agent', - { sessionId: this.sessionId, agentId: this.agentId }, - resource, - ) as T; - } -} diff --git a/packages/server-e2e/src/v2/resources/core.ts b/packages/server-e2e/src/v2/resources/core.ts deleted file mode 100644 index a1bce109a5..0000000000 --- a/packages/server-e2e/src/v2/resources/core.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Core-scope resources — `/api/v2/:`. - * - * Exposes one typed resource per entry in the `CORE` manifest, plus the - * resource-shape types for reuse by the client. - */ -import type { HttpRpc } from '../transport/http.js'; -import { makeResource, type ResourceShape } from '../transport/rpcProxy.js'; - -import { CORE, type CoreManifest } from './manifest.js'; -import type { SessionsPrecise, WorkspacesPrecise } from './types.js'; - -export type SessionsResource = ResourceShape; -export type WorkspacesResource = ResourceShape; -export type ConfigResource = ResourceShape; -export type ProvidersResource = ResourceShape; -export type OAuthResource = ResourceShape; -export type AuthResource = ResourceShape; -export type FlagsResource = ResourceShape; -export type PluginsResource = ResourceShape; -export type CoreFsResource = ResourceShape; -export type MetaResource = ResourceShape; - -/** The core-scope resource tree exposed on `ServerClient`. */ -export interface CoreResources { - readonly sessions: SessionsResource; - readonly workspaces: WorkspacesResource; - readonly config: ConfigResource; - readonly providers: ProvidersResource; - readonly oauth: OAuthResource; - readonly auth: AuthResource; - readonly flags: FlagsResource; - readonly plugins: PluginsResource; - readonly fs: CoreFsResource; - readonly meta: MetaResource; -} - -export function createCoreResources(rpc: HttpRpc): CoreResources { - return { - sessions: makeResource( - rpc, - 'core', - {}, - 'sessions', - CORE.sessions, - ), - workspaces: makeResource( - rpc, - 'core', - {}, - 'workspaces', - CORE.workspaces, - ), - config: makeResource(rpc, 'core', {}, 'config', CORE.config), - providers: makeResource(rpc, 'core', {}, 'providers', CORE.providers), - oauth: makeResource(rpc, 'core', {}, 'oauth', CORE.oauth), - auth: makeResource(rpc, 'core', {}, 'auth', CORE.auth), - flags: makeResource(rpc, 'core', {}, 'flags', CORE.flags), - plugins: makeResource(rpc, 'core', {}, 'plugins', CORE.plugins), - fs: makeResource(rpc, 'core', {}, 'fs', CORE.fs), - meta: makeResource(rpc, 'core', {}, 'meta', CORE.meta), - }; -} diff --git a/packages/server-e2e/src/v2/resources/events.ts b/packages/server-e2e/src/v2/resources/events.ts deleted file mode 100644 index dd02f195fe..0000000000 --- a/packages/server-e2e/src/v2/resources/events.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Typed `/api/v2/ws` event subscriptions. - * - * Wraps a {@link V2Socket} with one method per event the server exposes - * (see `server-v2/src/transport/ws/eventMap.ts`): - * core `events` — process-wide domain event bus - * session `interactions` — pending human-in-the-loop requests - * session `interactions:resolved` — request resolutions - * agent `events` — per-agent event stream - * - * Payloads are typed as `unknown` by default (the concrete unions live in - * `agent-core-v2`, which this wire client does not depend on); pass a type - * parameter to narrow. - */ -import type { RpcError } from '../errors.js'; -import type { ScopeKind, ScopeParams } from '../transport/http.js'; -import type { V2Socket } from '../transport/ws.js'; - -/** An `unlisten` handle — call it to stop receiving events. */ -export type Unlisten = () => void; - -export class EventsClient { - constructor(private readonly socket: V2Socket) {} - - /** Generic listen — prefer the typed helpers below. */ - listen( - scope: ScopeKind, - params: ScopeParams, - event: string, - handler: (data: T) => void, - onError?: (err: RpcError) => void, - ): Unlisten { - return this.socket.listen( - scope, - params, - event, - (data) => handler(data as T), - onError, - ); - } - - /** Subscribe to the core (process-wide) domain event bus. */ - onCoreEvents(handler: (data: T) => void): Unlisten { - return this.listen('core', {}, 'events', handler); - } - - /** Subscribe to a session's pending human-in-the-loop interactions. */ - onSessionInteractions(sessionId: string, handler: (data: T) => void): Unlisten { - return this.listen('session', { sessionId }, 'interactions', handler); - } - - /** Subscribe to a session's interaction resolutions. */ - onSessionInteractionsResolved( - sessionId: string, - handler: (data: T) => void, - ): Unlisten { - return this.listen('session', { sessionId }, 'interactions:resolved', handler); - } - - /** Subscribe to an agent's event stream. */ - onAgentEvents( - sessionId: string, - agentId: string, - handler: (data: T) => void, - ): Unlisten { - return this.listen('agent', { sessionId, agentId }, 'events', handler); - } -} diff --git a/packages/server-e2e/src/v2/resources/manifest.ts b/packages/server-e2e/src/v2/resources/manifest.ts deleted file mode 100644 index 4e271a0db6..0000000000 --- a/packages/server-e2e/src/v2/resources/manifest.ts +++ /dev/null @@ -1,113 +0,0 @@ -/** - * `/api/v2` resource manifest — the SDK's view of the server surface. - * - * Mirrors `server-v2/src/transport/actionMap.ts` exactly (same - * `resource:action` set, same `readonly` flags). The runtime proxies and the - * derived TypeScript shapes are both built from these tables, so the resource - * tree and its types can never disagree. - * - * A drift test (`test/v2/actionMap.test.ts`) cross-checks these manifests - * against the server's `actionMap`; a server change that adds/removes an - * action fails the test until the manifest (and types) catch up. - */ -import type { ActionMeta } from '../transport/rpcProxy.js'; - -type Manifest = Record>; - -const RO: ActionMeta = { readonly: true }; -const RW: ActionMeta = {}; - -/** Core scope — `/api/v2/:`. */ -export const CORE = { - sessions: { list: RO, get: RO, countActive: RO }, - workspaces: { list: RO, get: RO, createOrTouch: RW, update: RW, delete: RW }, - config: { - get: RO, - getAll: RO, - inspect: RO, - diagnostics: RO, - set: RW, - replace: RW, - reload: RW, - }, - providers: { list: RO, get: RO, set: RW, delete: RW }, - oauth: { startLogin: RW, getFlow: RO, cancelLogin: RW, logout: RW, status: RO }, - auth: { summarize: RO, ensureReady: RW }, - flags: { snapshot: RO, enabled: RO, enabledIds: RO, explain: RO, explainAll: RO }, - plugins: { - list: RO, - install: RW, - setEnabled: RW, - setMcpServerEnabled: RW, - remove: RW, - reload: RW, - getInfo: RO, - listCommands: RO, - checkUpdates: RO, - }, - fs: { browse: RO, home: RO }, - meta: { getEnv: RO, detect: RO }, -} as const satisfies Manifest; - -/** Session scope — `/api/v2/session//:`. */ -export const SESSION = { - session: { - read: RO, - update: RW, - setTitle: RW, - setArchived: RW, - archive: RW, - }, - approvals: { listPending: RO, request: RW, decide: RW }, - questions: { listPending: RO, ask: RW, answer: RW }, - interactions: { listPending: RO, request: RW, respond: RW }, - workspace: { - resolve: RO, - isWithin: RO, - setWorkDir: RW, - addAdditionalDir: RW, - removeAdditionalDir: RW, - }, - fs: { search: RO, grep: RO, gitStatus: RO, diff: RO }, -} as const satisfies Manifest; - -/** Agent scope — `/api/v2/session//agent//:`. */ -export const AGENT = { - goal: { get: RO, create: RW, pause: RW, resume: RW, cancel: RW }, - plan: { status: RO, enter: RW, exit: RW, cancel: RW, clear: RW }, - tasks: { list: RO, get: RO, readOutput: RO, stop: RW, detach: RW }, - usage: { status: RO }, - context: { status: RO }, - swarm: { isActive: RO, enter: RW, exit: RW }, - permission: { getMode: RO, setMode: RW }, - permissionRules: { list: RO, addRules: RW }, - profile: { - get: RO, - getModel: RO, - getSystemPrompt: RO, - getActiveToolNames: RO, - setModel: RW, - setThinking: RW, - }, - messages: { list: RO, splice: RW }, - mcp: { list: RO, reconnect: RW }, - tools: { list: RO }, - prompts: { submit: RW, steer: RW, undo: RW, clear: RW, cancel: RW }, - shell: { run: RW, cancel: RW }, - plugins: { activateCommand: RW }, -} as const satisfies Manifest; - -export type CoreManifest = typeof CORE; -export type SessionManifest = typeof SESSION; -export type AgentManifest = typeof AGENT; - -/** Flatten a manifest to a sorted list of `resource:action` strings. */ -export function flattenManifest(manifest: Manifest): string[] { - const out: string[] = []; - for (const resource of Object.keys(manifest)) { - for (const action of Object.keys(manifest[resource]!)) { - out.push(`${resource}:${action}`); - } - } - return out.sort(); -} diff --git a/packages/server-e2e/src/v2/resources/session.ts b/packages/server-e2e/src/v2/resources/session.ts deleted file mode 100644 index 180c2c0530..0000000000 --- a/packages/server-e2e/src/v2/resources/session.ts +++ /dev/null @@ -1,86 +0,0 @@ -/** - * Session-scope resources — `/api/v2/session//:`. - * - * The `session` resource (read/update/setTitle/setArchived/archive) is - * flattened onto the {@link SessionScope} handle itself, since it - * is the primary thing you do with a session; every other resource - * (`approvals`, `questions`, `interactions`, `workspace`, `fs`) is - * a sub-namespace. `agent(agentId)` enters the agent scope, and - * `service(resource)` is the escape hatch for actions not in the manifest. - */ -import type { HttpRpc } from '../transport/http.js'; -import { - type AnyMethod, - type DynamicResource, - makeDynamicResource, - makeResource, - type ResourceShape, -} from '../transport/rpcProxy.js'; - -import { AgentScope } from './agent.js'; -import { SESSION, type SessionManifest } from './manifest.js'; -import type { SessionMeta, SessionResourcePrecise } from './types.js'; - -export type SessionResource = ResourceShape; -export type ApprovalsResource = ResourceShape; -export type QuestionsResource = ResourceShape; -export type InteractionsResource = ResourceShape; -export type SessionWorkspaceResource = ResourceShape; -export type SessionFsResource = ResourceShape; - -/** Session scope handle — obtained via `client.session(sessionId)`. */ -export class SessionScope { - readonly approvals: ApprovalsResource; - readonly questions: QuestionsResource; - readonly interactions: InteractionsResource; - readonly workspace: SessionWorkspaceResource; - readonly fs: SessionFsResource; - - private readonly sessionResource: SessionResource; - - constructor( - private readonly rpc: HttpRpc, - readonly sessionId: string, - ) { - const params = { sessionId }; - this.sessionResource = makeResource( - rpc, - 'session', - params, - 'session', - SESSION.session, - ); - this.approvals = makeResource(rpc, 'session', params, 'approvals', SESSION.approvals); - this.questions = makeResource(rpc, 'session', params, 'questions', SESSION.questions); - this.interactions = makeResource(rpc, 'session', params, 'interactions', SESSION.interactions); - this.workspace = makeResource(rpc, 'session', params, 'workspace', SESSION.workspace); - this.fs = makeResource(rpc, 'session', params, 'fs', SESSION.fs); - } - - // ── Flattened `session` resource ───────────────────────────────────────── - read(arg?: unknown): Promise { - return this.sessionResource.read(arg); - } - update(arg?: unknown): Promise { - return this.sessionResource.update(arg); - } - setTitle(arg?: string): Promise { - return this.sessionResource.setTitle(arg); - } - setArchived(arg?: boolean): Promise { - return this.sessionResource.setArchived(arg); - } - archive(arg?: unknown): Promise { - return this.sessionResource.archive(arg ?? this.sessionId); - } - - /** Enter the agent scope for `agentId`. */ - agent(agentId: string): AgentScope { - return new AgentScope(this.rpc, this.sessionId, agentId); - } - - /** Escape hatch for a session resource not (yet) in the manifest. */ - service = DynamicResource>(resource: string): T { - return makeDynamicResource(this.rpc, 'session', { sessionId: this.sessionId }, resource) as T; - } -} diff --git a/packages/server-e2e/src/v2/resources/types.ts b/packages/server-e2e/src/v2/resources/types.ts deleted file mode 100644 index 39d0b382c9..0000000000 --- a/packages/server-e2e/src/v2/resources/types.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Precise wire types for the high-value `/api/v2` actions. - * - * Only the actions the SDK wants strong typing for are listed here; everything - * else falls back to `(arg?: unknown) => Promise` via `ResourceShape`. - * Shapes are derived from the server's own `server-v2/test/rpc.test.ts` wire - * examples and are intentionally loose on unknown fields (`[k: string]: unknown` - * or optional keys) so the server can evolve without breaking the client. - * - * Every override uses an optional arg to match the wire, where every action body - * is optional. - */ - -// ── Shared wire shapes ───────────────────────────────────────────────────── - -/** Session metadata as returned by `sessions:get` / `session:read`. */ -export interface SessionMeta { - readonly id: string; - readonly title?: string; - readonly createdAt?: number; - readonly updatedAt?: number; - readonly archived?: boolean; - readonly [k: string]: unknown; -} - -/** Workspace record as returned by `workspaces:*`. */ -export interface WorkspaceInfo { - readonly id: string; - readonly root: string; - readonly name?: string; - readonly [k: string]: unknown; -} - -/** Generic paginated list envelope used by list actions. */ -export interface ListResult { - readonly items: readonly T[]; - readonly has_more?: boolean; -} - -/** `prompts:submit` argument. `input` is the only required field. */ -export interface PromptSubmitArg { - readonly input: readonly PromptInputPart[]; - readonly [k: string]: unknown; -} - -export type PromptInputPart = { readonly type: 'text'; readonly text: string } | { - readonly type: string; - readonly [k: string]: unknown; -}; - -/** `prompts:submit` result. */ -export interface PromptSubmitResult { - readonly turn_id: number; - readonly [k: string]: unknown; -} - -/** `shell:run` argument. */ -export interface ShellRunArg { - readonly command: string; - readonly [k: string]: unknown; -} - -/** `shell:run` result. */ -export interface ShellRunResult { - readonly stdout: string; - readonly stderr: string; - readonly isError?: boolean; - readonly [k: string]: unknown; -} - -// ── Precise override maps (keyed by action name) ─────────────────────────── - -export interface SessionsPrecise { - list(arg?: { page_size?: number; [k: string]: unknown }): Promise>; - get(arg?: string): Promise; - countActive(arg?: string): Promise; -} - -export interface WorkspacesPrecise { - list(arg?: unknown): Promise>; - get(arg?: string): Promise; - createOrTouch(arg?: string): Promise; - update(arg?: [string, { name?: string }]): Promise; - delete(arg?: string): Promise; -} - -/** The `session` resource at session scope (read/update/setTitle/…). */ -export interface SessionResourcePrecise { - read(arg?: unknown): Promise; - update(arg?: unknown): Promise; - setTitle(arg?: string): Promise; - setArchived(arg?: boolean): Promise; - archive(arg?: unknown): Promise; -} - -export interface PromptsPrecise { - submit(arg?: PromptSubmitArg): Promise; -} - -export interface ShellPrecise { - run(arg?: ShellRunArg): Promise; - cancel(arg?: unknown): Promise; -} - -export interface ProfilePrecise { - getModel(arg?: unknown): Promise; -} diff --git a/packages/server-e2e/src/v2/transport/http.ts b/packages/server-e2e/src/v2/transport/http.ts deleted file mode 100644 index 1908d76f99..0000000000 --- a/packages/server-e2e/src/v2/transport/http.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * `/api/v2` HTTP transport — `POST`s a `resource:action` to the scope URL and - * unwraps the envelope. One {@link HttpRpc} is shared by every resource proxy - * on a client. - * - * URL shapes (mirror `server-v2/src/transport/registerRpcRoutes.ts`): - * POST /api/v2/: core - * POST /api/v2/session//: session - * POST /api/v2/session//agent//: agent - * - * The server also accepts `GET ?arg=` for `readonly` actions; this - * transport always POSTs (write actions are POST-only, and POST works for - * reads too) to keep the client single-path. - */ -import type { Envelope } from '@moonshot-ai/protocol'; - -import { unwrapData } from '../errors.js'; - -/** Which scope a call resolves before dispatching. */ -export type ScopeKind = 'core' | 'session' | 'agent'; - -/** Scope-identifying path params. */ -export interface ScopeParams { - readonly sessionId?: string; - readonly agentId?: string; -} - -export interface HttpRpcOptions { - /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ - readonly baseUrl: string; - /** Default `/api/v2`. */ - readonly apiPrefix?: string; - /** Optional bearer token. */ - readonly token?: string; - /** Override `fetch` (testing). */ - readonly fetchImpl?: typeof fetch; -} - -export class HttpRpc { - private readonly baseUrl: string; - private readonly apiPrefix: string; - private readonly token: string | undefined; - private readonly fetchImpl: typeof fetch; - - constructor(opts: HttpRpcOptions) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); - this.apiPrefix = opts.apiPrefix ?? '/api/v2'; - this.token = opts.token; - this.fetchImpl = opts.fetchImpl ?? fetch; - } - - async call( - scope: ScopeKind, - params: ScopeParams, - sa: string, - arg?: unknown, - ): Promise { - const url = this.url(scope, params, sa); - const headers: Record = { accept: 'application/json' }; - 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 this.fetchImpl(url, { method: 'POST', headers, body }); - const text = await res.text(); - let envelope: Envelope; - try { - envelope = JSON.parse(text) as Envelope; - } catch (error) { - throw new Error( - `server-v2 POST ${sa} returned non-JSON (HTTP ${res.status}): ${text.slice(0, 200)}`, - { cause: error }, - ); - } - return unwrapData(envelope); - } - - private url(scope: ScopeKind, params: ScopeParams, sa: string): string { - switch (scope) { - case 'core': - return `${this.baseUrl}${this.apiPrefix}/${sa}`; - case 'session': - return `${this.baseUrl}${this.apiPrefix}/session/${encodeURIComponent(params.sessionId ?? '')}/${sa}`; - case 'agent': - return `${this.baseUrl}${this.apiPrefix}/session/${encodeURIComponent(params.sessionId ?? '')}/agent/${encodeURIComponent(params.agentId ?? '')}/${sa}`; - } - } -} diff --git a/packages/server-e2e/src/v2/transport/rpcProxy.ts b/packages/server-e2e/src/v2/transport/rpcProxy.ts deleted file mode 100644 index f1e51aa87e..0000000000 --- a/packages/server-e2e/src/v2/transport/rpcProxy.ts +++ /dev/null @@ -1,85 +0,0 @@ -/** - * Resource proxy factory for the `/api/v2` SDK. - * - * A resource (e.g. `sessions`, `prompts`) is a set of actions declared in a - * manifest. `makeResource` returns a Proxy that turns any declared action - * into a `POST :` call. The return type is derived from the - * manifest so every action is reachable with autocomplete; a `Precise` map - * can override individual actions with exact wire types. - * - * type Sessions = ResourceShape; - * const sessions = makeResource(rpc, 'core', {}, 'sessions', CORE.sessions); - * await sessions.list({ page_size: 20 }); // typed via SessionsPrecise.list - */ -import type { HttpRpc, ScopeKind, ScopeParams } from './http.js'; - -/** Per-action metadata in a resource manifest. `readonly` actions allow GET. */ -export interface ActionMeta { - readonly readonly?: boolean; -} - -/** A loose resource method — used when no precise override is provided. */ -export type AnyMethod = (arg?: unknown) => Promise; - -/** - * Derive the typed shape of a resource from its manifest + precise overrides. - * Every manifest action becomes a method; actions present in `Precise` keep - * their exact signature, the rest fall back to {@link AnyMethod}. - * - * `Precise` is keyed by action name (a typo in a key fails the constraint) but - * its values are unconstrained (`unknown`), so a precise method never has to be - * assignable to {@link AnyMethod}. Precise methods are rebuilt with an optional - * arg to match the wire (every action body is optional). - */ -export type ResourceShape< - Actions extends Record, - Precise extends Partial> = Record, -> = { - [A in keyof Actions]: A extends keyof Precise - ? Precise[A] extends (arg?: infer P) => Promise - ? (arg?: P) => Promise - : AnyMethod - : AnyMethod; -}; - -/** Build a resource proxy bound to a scope + resource name. */ -export function makeResource< - Actions extends Record, - Precise extends Partial> = Record, ->( - rpc: HttpRpc, - scope: ScopeKind, - params: ScopeParams, - resource: string, - actions: Actions, -): ResourceShape { - return new Proxy({} as ResourceShape, { - get(_target, prop) { - if (typeof prop !== 'string') return undefined; - if (!Object.prototype.hasOwnProperty.call(actions, prop)) return undefined; - return (arg?: unknown) => rpc.call(scope, params, `${resource}:${prop}`, arg); - }, - }); -} - -/** - * Untyped escape hatch — a resource proxy that accepts ANY action name and - * forwards it as `:`. Used by `client.core(resource)`, - * `session.service(resource)`, and `agent.service(resource)` for actions - * that are not (yet) in the manifest. - */ -export type DynamicResource = Record; - -export function makeDynamicResource( - rpc: HttpRpc, - scope: ScopeKind, - params: ScopeParams, - resource: string, -): DynamicResource { - return new Proxy({} as DynamicResource, { - get(_target, prop) { - if (typeof prop !== 'string') return undefined; - return (arg?: unknown) => rpc.call(scope, params, `${resource}:${prop}`, arg); - }, - }); -} diff --git a/packages/server-e2e/src/v2/transport/ws.ts b/packages/server-e2e/src/v2/transport/ws.ts deleted file mode 100644 index 4134a27cad..0000000000 --- a/packages/server-e2e/src/v2/transport/ws.ts +++ /dev/null @@ -1,301 +0,0 @@ -/** - * `/api/v2/ws` client — a single multiplexed WebSocket for RPC `call`s and - * event `listen`s. - * - * Protocol (see `server-v2/src/transport/ws/wsProtocol.ts`): - * server → { type:'ready' } on connect - * client → { type:'hello', token? } (no ack; server accepts synchronously) - * client → { type:'call', id, scope, sessionId?, agentId?, sa, arg? } - * server → { type:'result', id, data } | { type:'error', id, code, msg } - * client → { type:'listen', id, scope, sessionId?, agentId?, event } - * server → { type:'event', id, data } (streamed until `unlisten`) - * server → { type:'ping' } → client → { type:'pong' } - * - * Auth is enforced at the HTTP upgrade, so the bearer token is sent on the - * upgrade request (`authorization` header). The post-connect `hello` is a - * defense-in-depth handshake, not the real gate. - */ -import { ulid } from 'ulid'; -import { WebSocket as NodeWebSocket } from 'ws'; - -import { RpcError } from '../errors.js'; - -import type { ScopeKind, ScopeParams } from './http.js'; - -export interface V2SocketOptions { - /** Base URL of the server, e.g. `http://127.0.0.1:58627`. */ - readonly baseUrl: string; - /** Default `/api/v2`. WS endpoint is `${apiPrefix}/ws`. */ - readonly apiPrefix?: string; - /** Optional bearer token sent on the upgrade request. */ - readonly token?: string; - /** Override the WebSocket implementation (testing / browser). */ - readonly wsImpl?: typeof NodeWebSocket; - /** Default 30s. Per-`call` deadline. */ - readonly callTimeoutMs?: number; -} - -interface PendingCall { - resolve: (data: unknown) => void; - reject: (err: Error) => void; - timer: ReturnType; -} - -interface Listener { - onEvent: (data: unknown) => void; - onError?: (err: RpcError) => void; -} - -const DEFAULT_CALL_TIMEOUT_MS = 30_000; - -/** A low-level v2 WebSocket. Most callers want the `EventsClient` wrapper. */ -export class V2Socket { - private readonly baseUrl: string; - private readonly apiPrefix: string; - private readonly token: string | undefined; - private readonly wsImpl: typeof NodeWebSocket; - private readonly callTimeoutMs: number; - - private ws: NodeWebSocket | null = null; - private readonly calls = new Map(); - private readonly listeners = new Map(); - private closed = false; - private readonly closeWaiters: Array<() => void> = []; - - constructor(opts: V2SocketOptions) { - this.baseUrl = opts.baseUrl.replace(/\/+$/, ''); - this.apiPrefix = opts.apiPrefix ?? '/api/v2'; - this.token = opts.token; - this.wsImpl = opts.wsImpl ?? NodeWebSocket; - this.callTimeoutMs = opts.callTimeoutMs ?? DEFAULT_CALL_TIMEOUT_MS; - } - - /** Open the socket, wait for `ready`, and send `hello`. */ - connect(): Promise { - if (this.ws) return Promise.resolve(); - return new Promise((resolve, reject) => { - const wsUrl = `${this.baseUrl.replace(/^http/, 'ws')}${this.apiPrefix}/ws`; - const headers: Record = {}; - if (this.token !== undefined) headers['authorization'] = `Bearer ${this.token}`; - const ws = new this.wsImpl(wsUrl, { headers }); - this.ws = ws; - - let opened = false; - ws.once('error', (err) => { - if (!opened) reject(err as Error); - }); - ws.on('message', (data) => { - if (!opened) { - const frame = parseFrame(data); - if (frame?.type === 'ready') { - opened = true; - this.send({ type: 'hello', token: this.token }); - resolve(); - return; - } - } - this.onMessage(data); - }); - ws.on('close', () => this.onClose()); - ws.on('error', () => this.onClose()); - }); - } - - /** Fire an RPC call over the socket; resolves with `data` or rejects. */ - call(scope: ScopeKind, params: ScopeParams, sa: string, arg?: unknown): Promise { - const ws = this.requireWs(); - const id = `call-${ulid()}`; - return new Promise((resolve, reject) => { - const timer = setTimeout(() => { - this.calls.delete(id); - reject(new Error(`call ${sa} timed out after ${this.callTimeoutMs}ms`)); - }, this.callTimeoutMs); - timer.unref?.(); - this.calls.set(id, { - resolve: (data) => resolve(data as T), - reject, - timer, - }); - this.send({ - type: 'call', - id, - scope, - sessionId: params.sessionId, - agentId: params.agentId, - sa, - arg, - }); - void ws; - }); - } - - /** - * Subscribe to an event stream. `onEvent` fires for each pushed event. Returns - * an `unlisten` handle (also sends `unlisten` to the server). - */ - listen( - scope: ScopeKind, - params: ScopeParams, - event: string, - onEvent: (data: unknown) => void, - onError?: (err: RpcError) => void, - ): () => void { - this.requireWs(); - const id = `listen-${ulid()}`; - this.listeners.set(id, { onEvent, onError }); - this.send({ - type: 'listen', - id, - scope, - sessionId: params.sessionId, - agentId: params.agentId, - event, - }); - return () => { - if (!this.listeners.has(id)) return; - this.listeners.delete(id); - this.send({ type: 'unlisten', id }); - }; - } - - /** Close the socket. Idempotent. */ - close(): Promise { - if (this.closed || !this.ws) return Promise.resolve(); - this.closed = true; - return new Promise((resolve) => { - this.closeWaiters.push(resolve); - this.ws?.close(); - }); - } - - private requireWs(): NodeWebSocket { - if (!this.ws || this.closed) { - throw new Error('v2 ws not connected — call `await socket.connect()` first'); - } - return this.ws; - } - - private send(frame: ClientFrame): void { - if (!this.ws || this.closed) return; - try { - this.ws.send(JSON.stringify(frame)); - } catch { - // best-effort - } - } - - private onMessage(data: unknown): void { - const frame = parseFrame(data); - if (frame === null) return; - switch (frame.type) { - case 'ping': - this.send({ type: 'pong' }); - return; - case 'result': { - const pending = this.calls.get(frame.id); - if (pending) { - this.calls.delete(frame.id); - clearTimeout(pending.timer); - pending.resolve(frame.data); - } - return; - } - case 'error': { - const pending = this.calls.get(frame.id); - if (pending) { - this.calls.delete(frame.id); - clearTimeout(pending.timer); - pending.reject(new RpcError({ code: frame.code, msg: frame.msg, data: null, request_id: frame.id })); - return; - } - const listener = this.listeners.get(frame.id); - if (listener?.onError) { - listener.onError(new RpcError({ code: frame.code, msg: frame.msg, data: null, request_id: frame.id })); - } - return; - } - case 'event': { - const listener = this.listeners.get(frame.id); - if (listener) listener.onEvent(frame.data); - return; - } - default: - return; - } - } - - private onClose(): void { - if (this.closed && this.calls.size === 0 && this.listeners.size === 0) { - // already cleaned up - } - this.closed = true; - for (const pending of this.calls.values()) { - clearTimeout(pending.timer); - pending.reject(new Error('v2 ws closed before call result')); - } - this.calls.clear(); - this.listeners.clear(); - for (const waiter of this.closeWaiters.splice(0)) waiter(); - this.ws = null; - } -} - -// ── Wire frame shapes (client view; not validated) ───────────────────────── - -interface ReadyFrame { - readonly type: 'ready'; - readonly heartbeatMs: number; -} -interface ResultFrame { - readonly type: 'result'; - readonly id: string; - readonly data: unknown; -} -interface ErrorFrame { - readonly type: 'error'; - readonly id: string; - readonly code: number; - readonly msg: string; -} -interface EventFrame { - readonly type: 'event'; - readonly id: string; - readonly data: unknown; -} -interface PingFrame { - readonly type: 'ping'; -} -type ServerFrame = ReadyFrame | ResultFrame | ErrorFrame | EventFrame | PingFrame; - -type ClientFrame = - | { readonly type: 'hello'; readonly token?: string } - | { - readonly type: 'call'; - readonly id: string; - readonly scope: ScopeKind; - readonly sessionId?: string; - readonly agentId?: string; - readonly sa: string; - readonly arg?: unknown; - } - | { readonly type: 'cancel'; readonly id: string } - | { - readonly type: 'listen'; - readonly id: string; - readonly scope: ScopeKind; - readonly sessionId?: string; - readonly agentId?: string; - readonly event: string; - } - | { readonly type: 'unlisten'; readonly id: string } - | { readonly type: 'pong' }; - -function parseFrame(data: unknown): ServerFrame | null { - const raw = typeof data === 'string' ? data : Buffer.isBuffer(data) ? data.toString('utf8') : null; - if (raw === null) return null; - try { - return JSON.parse(raw) as ServerFrame; - } catch { - return null; - } -} diff --git a/packages/server-e2e/test/v2/smoke.test.ts b/packages/server-e2e/test/v2/smoke.test.ts deleted file mode 100644 index a78240d638..0000000000 --- a/packages/server-e2e/test/v2/smoke.test.ts +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Smoke test for the v2 wire surface — boots `server-v2` in-process (port 0) - * and exercises the typed `Klient` over HTTP (core/session/agent channels) - * and WS (agent event stream). - * - * The test client is a pure wire client: server state is arranged through the - * in-process `server.core` reference only where the RPC surface offers no way - * (e.g. creating the main agent, server-v2 gap G10); session creation goes - * through the legacy `/api/v1` REST surface, and every other assertion goes - * through `Klient`. - */ -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; - -import { - ensureMainAgent, - IAgentRPCService, - ISessionIndex, - ISessionLifecycleService, - ISessionMetadata, - IWorkspaceRegistry, -} from '@moonshot-ai/agent-core-v2'; -import { type RunningServer, startServer } from '@moonshot-ai/kap-server'; -import { Klient } from '@moonshot-ai/klient'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; - -import { HttpClient } from '../../src/http.js'; - -describe('Klient (server-v2 smoke)', () => { - let server: RunningServer | undefined; - let home: string | undefined; - let client: Klient | undefined; - let v1: HttpClient | undefined; - let wsOpened = false; - - beforeEach(async () => { - home = await mkdtemp(join(tmpdir(), 'kimi-sdk-smoke-')); - server = await startServer({ host: '127.0.0.1', port: 0, homeDir: home, logLevel: 'silent' }); - const baseUrl = `http://127.0.0.1:${server.port}`; - const token = server.authTokenService.getToken(); - client = new Klient({ url: baseUrl, token }); - v1 = new HttpClient({ baseUrl, apiPrefix: '/api/v1', token, fetchImpl: fetch }); - wsOpened = false; - }); - - afterEach(async () => { - if (wsOpened) { - client?.ws().close(); - wsOpened = false; - } - client = undefined; - v1 = undefined; - if (server) { - await server.close(); - server = undefined; - } - if (home) { - await rm(home, { recursive: true, force: true, maxRetries: 3, retryDelay: 25 } as never); - home = undefined; - } - }); - - async function createSession(cwd: string): Promise { - const session = await v1!.createSession({ metadata: { cwd } }); - return session.id; - } - - async function createMainAgent(sessionId: string): Promise { - const session = server!.core.accessor.get(ISessionLifecycleService).get(sessionId); - if (session === undefined) throw new Error(`session ${sessionId} not found`); - await ensureMainAgent(session); - } - - it('lists sessions (core)', async () => { - await createSession(home as string); - const page = await client!.core(ISessionIndex).list({ limit: 20 }); - expect(page.items.length).toBeGreaterThanOrEqual(1); - }); - - it('creates and reads a workspace (core)', async () => { - const registry = client!.core(IWorkspaceRegistry); - const created = await registry.createOrTouch(home as string); - expect(created.root).toBe(home); - - const got = await registry.get(created.id); - expect(got?.root).toBe(home); - }); - - it('reads, renames, and archives a session (session scope)', async () => { - const sid = await createSession(home as string); - const s = client!.session(sid); - - const before = await s.service(ISessionMetadata).read(); - expect(before.id).toBe(sid); - - await s.service(ISessionMetadata).setTitle('renamed'); - const after = await s.service(ISessionMetadata).read(); - expect(after.title).toBe('renamed'); - - await s.service(ISessionLifecycleService).archive(sid); - }); - - it('submits a prompt and runs a shell command (agent scope)', async () => { - const sid = await createSession(home as string); - await createMainAgent(sid); - const agent = client!.session(sid).agent('main').service(IAgentRPCService); - - const submitted = await agent.prompt({ - input: [{ type: 'text', text: 'hello' }], - }); - expect(typeof submitted?.turn_id).toBe('number'); - - const shell = await agent.runShellCommand({ command: 'printf hello' }); - expect(shell.stdout).toBe('hello'); - expect(shell.stderr).toBe(''); - }); - - it('streams agent events over ws', async () => { - const sid = await createSession(home as string); - await createMainAgent(sid); - - const ws = client!.ws(); - wsOpened = true; - const received: unknown[] = []; - const sub = ws.session(sid).agent('main').listen('events', (e) => { - received.push(e); - }); - - await client!.session(sid).agent('main').service(IAgentRPCService).prompt({ - input: [{ type: 'text', text: 'hi' }], - }); - - const deadline = Date.now() + 10_000; - while (received.length === 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 50)); - } - sub.dispose(); - expect(received.length).toBeGreaterThan(0); - }); -}); diff --git a/packages/server-e2e/tsconfig.json b/packages/server-e2e/tsconfig.json deleted file mode 100644 index e1e0487d39..0000000000 --- a/packages/server-e2e/tsconfig.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": "../../tsconfig.json", - "compilerOptions": { - "experimentalDecorators": true - }, - "include": ["src", "test", "scenarios", "../agent-core/src/prompt-modules.d.ts"], - "exclude": [ - // The v2 smoke test boots `server-v2` in-process, which pulls in server-v2's - // full source graph. server-v2 currently has a pre-existing type error in - // `src/transport/ws/eventMap.ts` (`ISessionInteractionService.onDidChange` - // was removed upstream in agent-core-v2). The smoke test still runs under - // vitest (esbuild transpiles without typechecking); only `tsc` skips it. - "test/v2/smoke.test.ts" - ] -} diff --git a/packages/server-e2e/tsdown.config.ts b/packages/server-e2e/tsdown.config.ts deleted file mode 100644 index b27e00afd7..0000000000 --- a/packages/server-e2e/tsdown.config.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - entry: ['./src/index.ts'], - format: ['esm'], - dts: false, - outDir: 'dist', - clean: true, - deps: { - alwaysBundle: [/^@moonshot-ai\//], - neverBundle: [], - }, -}); diff --git a/packages/server-e2e/vitest.config.ts b/packages/server-e2e/vitest.config.ts deleted file mode 100644 index 8f393810df..0000000000 --- a/packages/server-e2e/vitest.config.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { fileURLToPath } from 'node:url'; - -import { defineConfig } from 'vitest/config'; - -import { rawTextPlugin } from '../../build/raw-text-plugin.mjs'; - -// `rawTextPlugin` is required because importing `@moonshot-ai/kap-server` (for -// the v2 SDK tests) pulls in agent-core-v2's barrel, which imports `*.md?raw` -// prompt templates. -export default defineConfig({ - plugins: [rawTextPlugin()], - resolve: { - alias: { - '@moonshot-ai/protocol': fileURLToPath( - new URL('../protocol/src/index.ts', import.meta.url), - ), - }, - }, - test: { - name: 'server-e2e', - include: ['test/**/*.test.ts'], - reporters: ['default', './test/report/vitest-reporter.ts'], - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6a73bdf63e..972042480c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,25 @@ importers: '@moonshot-ai/agent-core-v2': specifier: workspace:^ version: link:../agent-core-v2 + zod: + specifier: 'catalog:' + version: 4.3.6 + devDependencies: + '@moonshot-ai/kap-server': + specifier: workspace:^ + version: link:../kap-server + '@moonshot-ai/protocol': + specifier: workspace:^ + version: link:../protocol + '@types/ws': + specifier: ^8.18.0 + version: 8.18.1 + ulid: + specifier: ^3.0.1 + version: 3.0.2 + ws: + specifier: ^8.18.0 + version: 8.20.0 packages/kosong: dependencies: @@ -810,34 +829,6 @@ importers: specifier: ^4.3.6 version: 4.3.6 - packages/server-e2e: - dependencies: - '@moonshot-ai/protocol': - specifier: workspace:^ - version: link:../protocol - ulid: - specifier: ^3.0.1 - version: 3.0.2 - ws: - specifier: ^8.18.0 - version: 8.20.0 - zod: - specifier: 'catalog:' - version: 4.3.6 - devDependencies: - '@moonshot-ai/agent-core-v2': - specifier: workspace:^ - version: link:../agent-core-v2 - '@moonshot-ai/kap-server': - specifier: workspace:^ - version: link:../kap-server - '@moonshot-ai/klient': - specifier: workspace:^ - version: link:../klient - '@types/ws': - specifier: ^8.18.0 - version: 8.18.1 - packages/telemetry: {} packages: