From a8761b00a388a7c826325ccf3af747ec1004e5b6 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 15:40:58 +0800 Subject: [PATCH 1/2] feat(klient): contract-driven facade with http/ipc/memory transports - add zod-validated contract sections (global/session/agent) under src/contract and a facade exposing global.*, session(id).*, agent(id).* - select transport once at creation via subpath entries (@moonshot-ai/klient/http|ipc|memory); drop legacy channel/client/ httpChannel/wsChannel/wsKlient/proxy implementations - absorb packages/server-e2e into packages/klient test/e2e suites (dual-backend, legacy v1, v2 wire) and remove the server-e2e workspace - expose model registry and catalog services on kap-server v2 RPC surface --- .agents/skills/agent-core-dev/server-align.md | 2 +- .changeset/README.md | 1 - .changeset/config.json | 1 - .changeset/kap-server-v2-model-channels.md | 6 + .changeset/klient-global-facade-catalog.md | 5 + AGENTS.md | 2 +- flake.nix | 2 - .../src/transport/channelRegistry.ts | 4 + packages/klient/AGENTS.md | 83 +++ packages/{server-e2e => klient}/Dockerfile | 4 +- packages/klient/README.md | 163 +++--- packages/klient/examples/basic.ts | 85 ++- packages/klient/examples/init.ts | 107 ---- packages/klient/examples/inspect-init.ts | 54 -- packages/klient/examples/media-recovery.ts | 457 ---------------- .../examples/sea-e2e-auto-background.ts | 230 -------- .../klient/examples/session-history-smoke.ts | 307 ----------- packages/klient/examples/smoke.ts | 330 +++--------- packages/klient/package.json | 28 +- .../scripts/run-docker-e2e.sh | 10 +- packages/klient/src/channel.ts | 22 - packages/klient/src/client.ts | 135 ----- packages/klient/src/contract/agent/events.ts | 174 ++++++ packages/klient/src/contract/agent/rpc.ts | 213 ++++++++ packages/klient/src/contract/global/auth.ts | 104 ++++ .../klient/src/contract/global/catalog.ts | 68 +++ packages/klient/src/contract/global/config.ts | 40 ++ packages/klient/src/contract/global/env.ts | 26 + packages/klient/src/contract/global/events.ts | 140 +++++ packages/klient/src/contract/global/flags.ts | 30 ++ packages/klient/src/contract/global/hostFs.ts | 33 ++ packages/klient/src/contract/global/models.ts | 77 +++ .../klient/src/contract/global/plugins.ts | 239 +++++++++ .../klient/src/contract/global/providers.ts | 50 ++ .../klient/src/contract/global/sessions.ts | 36 ++ .../klient/src/contract/global/workspaces.ts | 35 ++ packages/klient/src/contract/helpers.ts | 35 ++ packages/klient/src/contract/index.ts | 54 ++ .../klient/src/contract/session/activity.ts | 20 + .../klient/src/contract/session/approval.ts | 35 ++ .../klient/src/contract/session/events.ts | 66 +++ .../src/contract/session/interaction.ts | 38 ++ .../klient/src/contract/session/lifecycle.ts | 86 +++ .../klient/src/contract/session/metadata.ts | 76 +++ .../klient/src/contract/session/question.ts | 56 ++ packages/klient/src/contract/types.ts | 46 ++ packages/klient/src/core/channel.ts | 50 ++ packages/klient/src/{ => core}/errors.ts | 2 +- packages/klient/src/core/events/hub.ts | 227 ++++++++ packages/klient/src/core/facade/agent.ts | 68 +++ packages/klient/src/core/facade/global.ts | 380 +++++++++++++ packages/klient/src/core/facade/session.ts | 142 +++++ packages/klient/src/core/klient.ts | 98 ++++ packages/klient/src/core/validation.ts | 70 +++ packages/klient/src/httpChannel.ts | 69 --- packages/klient/src/index.ts | 153 ++++-- packages/klient/src/proxy.ts | 21 - packages/klient/src/services/sessionIndex.ts | 37 -- .../klient/src/transports/http/channel.ts | 106 ++++ .../klient/src/transports/http/eventBridge.ts | 63 +++ packages/klient/src/transports/http/index.ts | 15 + packages/klient/src/transports/ipc/channel.ts | 227 ++++++++ packages/klient/src/transports/ipc/codec.ts | 49 ++ packages/klient/src/transports/ipc/host.ts | 189 +++++++ packages/klient/src/transports/ipc/index.ts | 19 + .../src/transports/memory/dispatcher.ts | 186 +++++++ .../klient/src/transports/memory/index.ts | 57 ++ .../src/transports/memory/serviceRegistry.ts | 55 ++ .../src/{ => transports/ws}/wsSocket.ts | 2 +- packages/klient/src/wsChannel.ts | 79 --- packages/klient/src/wsKlient.ts | 129 ----- packages/klient/test/client.test.ts | 131 ----- packages/klient/test/contract-parity.ts | 503 ++++++++++++++++++ .../klient/test/e2e/dual/01-prompt.test.ts | 38 ++ .../klient/test/e2e/dual/02-approval.test.ts | 56 ++ .../klient/test/e2e/dual/05-workspace.test.ts | 42 ++ .../klient/test/e2e/dual/06-catalog.test.ts | 31 ++ .../klient/test/e2e/dual/07-children.test.ts | 38 ++ .../klient/test/e2e/dual/08-pending.test.ts | 63 +++ .../src => klient/test/e2e/harness}/client.ts | 0 .../test/e2e/harness}/envelope.ts | 0 .../src => klient/test/e2e/harness}/http.ts | 0 .../src => klient/test/e2e/harness}/index.ts | 1 - .../src => klient/test/e2e/harness}/report.ts | 0 .../test/e2e/harness}/reverse-rpc.ts | 0 .../src => klient/test/e2e/harness}/wait.ts | 0 .../src => klient/test/e2e/harness}/ws.ts | 0 .../test/e2e/legacy}/client.test.ts | 4 +- .../e2e/legacy/image-file-prompts.test.ts | 149 ++++++ .../test => klient/test/e2e/legacy}/log.ts | 2 +- .../e2e/legacy}/prompt-queue-steer.test.ts | 4 +- .../test/e2e/legacy}/refresh-replay.test.ts | 4 +- .../test/e2e/legacy}/report.test.ts | 8 +- .../e2e/legacy}/report/vitest-reporter.ts | 2 +- .../test/e2e/legacy}/send-and-cancel.test.ts | 4 +- .../test/e2e/legacy}/session-resume.test.ts | 4 +- .../klient/test/e2e/legacy/terminal.test.ts | 172 ++++++ .../test/e2e}/v2/channelRegistry.test.ts | 0 .../test/e2e/v2/helpers}/token.ts | 8 +- packages/klient/test/e2e/v2/smoke.test.ts | 200 +++++++ .../test => klient/test/e2e}/v2/token.test.ts | 2 +- packages/klient/test/facade.test.ts | 208 ++++++++ packages/klient/test/helpers/conformance.ts | 171 ++++++ packages/klient/test/helpers/dual.ts | 164 ++++++ packages/klient/test/helpers/engine.ts | 26 + packages/klient/test/helpers/typeAssert.ts | 15 + packages/klient/test/http-conformance.test.ts | 36 ++ packages/klient/test/http.test.ts | 209 ++++++++ packages/klient/test/ipc.test.ts | 74 +++ packages/klient/test/memory.test.ts | 71 +++ .../test/{ws.test.ts => wsSocket.test.ts} | 156 ++---- packages/klient/tsconfig.json | 2 +- packages/klient/tsdown.config.ts | 7 +- packages/klient/vitest.config.ts | 1 + packages/server-e2e/.gitignore | 1 - packages/server-e2e/AGENTS.md | 44 -- packages/server-e2e/README.md | 151 ------ packages/server-e2e/package.json | 46 -- .../scenarios/01-create-and-send.ts | 78 --- .../scenarios/02-tool-call-with-approval.ts | 108 ---- .../server-e2e/scenarios/03-refresh-replay.ts | 323 ----------- packages/server-e2e/scenarios/05-workspace.ts | 162 ------ .../server-e2e/scenarios/06-model-catalog.ts | 60 --- .../scenarios/07-session-children.ts | 152 ------ .../scenarios/08-pending-recovery.ts | 204 ------- .../scenarios/09-image-file-prompts.ts | 143 ----- packages/server-e2e/scenarios/11-terminal.ts | 266 --------- packages/server-e2e/scenarios/README.md | 57 -- packages/server-e2e/scenarios/_template.ts | 54 -- packages/server-e2e/scripts/run-scenarios.ts | 126 ----- packages/server-e2e/src/v2/client.ts | 153 ------ packages/server-e2e/src/v2/errors.ts | 37 -- packages/server-e2e/src/v2/index.ts | 91 ---- packages/server-e2e/src/v2/resources/agent.ts | 109 ---- packages/server-e2e/src/v2/resources/core.ts | 63 --- .../server-e2e/src/v2/resources/events.ts | 68 --- .../server-e2e/src/v2/resources/manifest.ts | 113 ---- .../server-e2e/src/v2/resources/session.ts | 86 --- packages/server-e2e/src/v2/resources/types.ts | 107 ---- packages/server-e2e/src/v2/transport/http.ts | 92 ---- .../server-e2e/src/v2/transport/rpcProxy.ts | 85 --- packages/server-e2e/src/v2/transport/ws.ts | 301 ----------- packages/server-e2e/test/v2/smoke.test.ts | 141 ----- packages/server-e2e/tsconfig.json | 15 - packages/server-e2e/tsdown.config.ts | 13 - packages/server-e2e/vitest.config.ts | 24 - pnpm-lock.yaml | 47 +- 147 files changed, 6603 insertions(+), 5831 deletions(-) create mode 100644 .changeset/kap-server-v2-model-channels.md create mode 100644 .changeset/klient-global-facade-catalog.md create mode 100644 packages/klient/AGENTS.md rename packages/{server-e2e => klient}/Dockerfile (91%) delete mode 100644 packages/klient/examples/init.ts delete mode 100644 packages/klient/examples/inspect-init.ts delete mode 100644 packages/klient/examples/media-recovery.ts delete mode 100644 packages/klient/examples/sea-e2e-auto-background.ts delete mode 100644 packages/klient/examples/session-history-smoke.ts rename packages/{server-e2e => klient}/scripts/run-docker-e2e.sh (96%) delete mode 100644 packages/klient/src/channel.ts delete mode 100644 packages/klient/src/client.ts create mode 100644 packages/klient/src/contract/agent/events.ts create mode 100644 packages/klient/src/contract/agent/rpc.ts create mode 100644 packages/klient/src/contract/global/auth.ts create mode 100644 packages/klient/src/contract/global/catalog.ts create mode 100644 packages/klient/src/contract/global/config.ts create mode 100644 packages/klient/src/contract/global/env.ts create mode 100644 packages/klient/src/contract/global/events.ts create mode 100644 packages/klient/src/contract/global/flags.ts create mode 100644 packages/klient/src/contract/global/hostFs.ts create mode 100644 packages/klient/src/contract/global/models.ts create mode 100644 packages/klient/src/contract/global/plugins.ts create mode 100644 packages/klient/src/contract/global/providers.ts create mode 100644 packages/klient/src/contract/global/sessions.ts create mode 100644 packages/klient/src/contract/global/workspaces.ts create mode 100644 packages/klient/src/contract/helpers.ts create mode 100644 packages/klient/src/contract/index.ts create mode 100644 packages/klient/src/contract/session/activity.ts create mode 100644 packages/klient/src/contract/session/approval.ts create mode 100644 packages/klient/src/contract/session/events.ts create mode 100644 packages/klient/src/contract/session/interaction.ts create mode 100644 packages/klient/src/contract/session/lifecycle.ts create mode 100644 packages/klient/src/contract/session/metadata.ts create mode 100644 packages/klient/src/contract/session/question.ts create mode 100644 packages/klient/src/contract/types.ts create mode 100644 packages/klient/src/core/channel.ts rename packages/klient/src/{ => core}/errors.ts (81%) create mode 100644 packages/klient/src/core/events/hub.ts create mode 100644 packages/klient/src/core/facade/agent.ts create mode 100644 packages/klient/src/core/facade/global.ts create mode 100644 packages/klient/src/core/facade/session.ts create mode 100644 packages/klient/src/core/klient.ts create mode 100644 packages/klient/src/core/validation.ts delete mode 100644 packages/klient/src/httpChannel.ts delete mode 100644 packages/klient/src/proxy.ts delete mode 100644 packages/klient/src/services/sessionIndex.ts create mode 100644 packages/klient/src/transports/http/channel.ts create mode 100644 packages/klient/src/transports/http/eventBridge.ts create mode 100644 packages/klient/src/transports/http/index.ts create mode 100644 packages/klient/src/transports/ipc/channel.ts create mode 100644 packages/klient/src/transports/ipc/codec.ts create mode 100644 packages/klient/src/transports/ipc/host.ts create mode 100644 packages/klient/src/transports/ipc/index.ts create mode 100644 packages/klient/src/transports/memory/dispatcher.ts create mode 100644 packages/klient/src/transports/memory/index.ts create mode 100644 packages/klient/src/transports/memory/serviceRegistry.ts rename packages/klient/src/{ => transports/ws}/wsSocket.ts (99%) delete mode 100644 packages/klient/src/wsChannel.ts delete mode 100644 packages/klient/src/wsKlient.ts delete mode 100644 packages/klient/test/client.test.ts create mode 100644 packages/klient/test/contract-parity.ts create mode 100644 packages/klient/test/e2e/dual/01-prompt.test.ts create mode 100644 packages/klient/test/e2e/dual/02-approval.test.ts create mode 100644 packages/klient/test/e2e/dual/05-workspace.test.ts create mode 100644 packages/klient/test/e2e/dual/06-catalog.test.ts create mode 100644 packages/klient/test/e2e/dual/07-children.test.ts create mode 100644 packages/klient/test/e2e/dual/08-pending.test.ts rename packages/{server-e2e/src => klient/test/e2e/harness}/client.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/envelope.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/http.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/index.ts (98%) rename packages/{server-e2e/src => klient/test/e2e/harness}/report.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/reverse-rpc.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/wait.ts (100%) rename packages/{server-e2e/src => klient/test/e2e/harness}/ws.ts (100%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/client.test.ts (99%) create mode 100644 packages/klient/test/e2e/legacy/image-file-prompts.test.ts rename packages/{server-e2e/test => klient/test/e2e/legacy}/log.ts (94%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/prompt-queue-steer.test.ts (98%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/refresh-replay.test.ts (98%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/report.test.ts (98%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/report/vitest-reporter.ts (97%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/send-and-cancel.test.ts (99%) rename packages/{server-e2e/test => klient/test/e2e/legacy}/session-resume.test.ts (98%) create mode 100644 packages/klient/test/e2e/legacy/terminal.test.ts rename packages/{server-e2e/test => klient/test/e2e}/v2/channelRegistry.test.ts (100%) rename packages/{server-e2e/src/v2 => klient/test/e2e/v2/helpers}/token.ts (96%) create mode 100644 packages/klient/test/e2e/v2/smoke.test.ts rename packages/{server-e2e/test => klient/test/e2e}/v2/token.test.ts (99%) create mode 100644 packages/klient/test/facade.test.ts create mode 100644 packages/klient/test/helpers/conformance.ts create mode 100644 packages/klient/test/helpers/dual.ts create mode 100644 packages/klient/test/helpers/engine.ts create mode 100644 packages/klient/test/helpers/typeAssert.ts create mode 100644 packages/klient/test/http-conformance.test.ts create mode 100644 packages/klient/test/http.test.ts create mode 100644 packages/klient/test/ipc.test.ts create mode 100644 packages/klient/test/memory.test.ts rename packages/klient/test/{ws.test.ts => wsSocket.test.ts} (64%) delete mode 100644 packages/server-e2e/.gitignore delete mode 100644 packages/server-e2e/AGENTS.md delete mode 100644 packages/server-e2e/README.md delete mode 100644 packages/server-e2e/package.json delete mode 100644 packages/server-e2e/scenarios/01-create-and-send.ts delete mode 100644 packages/server-e2e/scenarios/02-tool-call-with-approval.ts delete mode 100644 packages/server-e2e/scenarios/03-refresh-replay.ts delete mode 100644 packages/server-e2e/scenarios/05-workspace.ts delete mode 100644 packages/server-e2e/scenarios/06-model-catalog.ts delete mode 100644 packages/server-e2e/scenarios/07-session-children.ts delete mode 100644 packages/server-e2e/scenarios/08-pending-recovery.ts delete mode 100644 packages/server-e2e/scenarios/09-image-file-prompts.ts delete mode 100644 packages/server-e2e/scenarios/11-terminal.ts delete mode 100644 packages/server-e2e/scenarios/README.md delete mode 100644 packages/server-e2e/scenarios/_template.ts delete mode 100644 packages/server-e2e/scripts/run-scenarios.ts delete mode 100644 packages/server-e2e/src/v2/client.ts delete mode 100644 packages/server-e2e/src/v2/errors.ts delete mode 100644 packages/server-e2e/src/v2/index.ts delete mode 100644 packages/server-e2e/src/v2/resources/agent.ts delete mode 100644 packages/server-e2e/src/v2/resources/core.ts delete mode 100644 packages/server-e2e/src/v2/resources/events.ts delete mode 100644 packages/server-e2e/src/v2/resources/manifest.ts delete mode 100644 packages/server-e2e/src/v2/resources/session.ts delete mode 100644 packages/server-e2e/src/v2/resources/types.ts delete mode 100644 packages/server-e2e/src/v2/transport/http.ts delete mode 100644 packages/server-e2e/src/v2/transport/rpcProxy.ts delete mode 100644 packages/server-e2e/src/v2/transport/ws.ts delete mode 100644 packages/server-e2e/test/v2/smoke.test.ts delete mode 100644 packages/server-e2e/tsconfig.json delete mode 100644 packages/server-e2e/tsdown.config.ts delete mode 100644 packages/server-e2e/vitest.config.ts 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/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..2405fabfc7 --- /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 { 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 { sessionActivityContract } from './session/activity.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, + sessionActivity: sessionActivityContract, + sessionInteractionService: sessionInteractionContract, + sessionApprovalService: sessionApprovalContract, + sessionQuestionService: sessionQuestionContract, + // agent scope + agentRPCService: agentRpcContract, +}; + +export type { KlientContract, ProcedureContract, ServiceContract } from './types.js'; diff --git a/packages/klient/src/contract/session/activity.ts b/packages/klient/src/contract/session/activity.ts new file mode 100644 index 0000000000..6e8aceceef --- /dev/null +++ b/packages/klient/src/contract/session/activity.ts @@ -0,0 +1,20 @@ +/** + * `sessionActivity` — session-level activity and status. Mirrors + * `agent-core-v2/session/sessionActivity/sessionActivity.ts`. + */ + +import { z } from 'zod'; + +import type { ServiceContract } from '../types.js'; + +export const sessionStatusSchema = z.enum([ + 'running', + 'idle', + 'awaiting_approval', + 'awaiting_question', +]); + +export const sessionActivityContract = { + status: { input: z.tuple([]), output: sessionStatusSchema }, + isIdle: { input: z.tuple([]), output: z.boolean() }, +} satisfies ServiceContract; 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..c778553806 --- /dev/null +++ b/packages/klient/src/core/facade/session.ts @@ -0,0 +1,142 @@ +/** + * 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 { + AgentMeta, + SessionMeta, + SessionMetaPatch, +} from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; +import type { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +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; +} + +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: () => call(scope, 'sessionActivity', 'status', []) as Promise, + 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..90722fc99f 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, +} 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 { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +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..c882826234 --- /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 { ISessionActivity } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +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'; + +/** 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, + sessionActivity: ISessionActivity, + sessionInteractionService: ISessionInteractionService, + sessionApprovalService: ISessionApprovalService, + sessionQuestionService: ISessionQuestionService, + agentRPCService: IAgentRPCService, +}; + +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..5da4e02d67 --- /dev/null +++ b/packages/klient/test/contract-parity.ts @@ -0,0 +1,503 @@ +/** + * 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 { AgentContextData } from '@moonshot-ai/agent-core-v2/agent/contextMemory/types'; +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 { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; +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 { + 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 { sessionStatusSchema } from '../src/contract/session/activity.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/activity.ts +const _sessionStatus: AssertWire = 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 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..086016c700 --- /dev/null +++ b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts @@ -0,0 +1,149 @@ +/** + * 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 }); + + let terminalStatus: 'idle' | 'aborted' = 'idle'; + try { + await client.abortPrompt(sid, submit.prompt_id); + terminalStatus = 'aborted'; + 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.waitForSessionStatus(sid, terminalStatus, { 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 status = await v2Rpc(baseUrl, token, `/session/${sid}/sessionActivity/status`, []); + expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(status); + + 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: From fc55640b286828c75cc0ea7240b82f048df8c775 Mon Sep 17 00:00:00 2001 From: "haozhe.yang" Date: Thu, 16 Jul 2026 16:35:13 +0800 Subject: [PATCH 2/2] fix(klient): derive session status from agentActivityView The engine retired its sessionActivity service in #1751 (session busy is now derived from agent activity views), but the facade still called the deleted wire channel and imported the deleted engine module, breaking typecheck and every klient suite at import time. - drop the sessionActivity contract/registry entries and mirror the agentActivityView service instead (agent scope) - compose session status() client-side from the pending interaction lists and each agent's agentActivityView, keeping the retired service's precedence and typing SessionStatus locally in the facade - replace the deleted-channel call in the v2 smoke suite with a sessionInteractionService probe - fix the legacy image-file suite to wait with the harness's waitForSessionBusy --- .../klient/src/contract/agent/activity.ts | 75 +++++++++++++++++++ packages/klient/src/contract/index.ts | 4 +- .../klient/src/contract/session/activity.ts | 20 ----- packages/klient/src/core/facade/session.ts | 36 ++++++++- packages/klient/src/index.ts | 2 +- .../src/transports/memory/serviceRegistry.ts | 4 +- packages/klient/test/contract-parity.ts | 49 ++++++++++-- .../e2e/legacy/image-file-prompts.test.ts | 4 +- packages/klient/test/e2e/v2/smoke.test.ts | 9 ++- 9 files changed, 166 insertions(+), 37 deletions(-) create mode 100644 packages/klient/src/contract/agent/activity.ts delete mode 100644 packages/klient/src/contract/session/activity.ts 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/index.ts b/packages/klient/src/contract/index.ts index 2405fabfc7..4ec7235ba9 100644 --- a/packages/klient/src/contract/index.ts +++ b/packages/klient/src/contract/index.ts @@ -7,6 +7,7 @@ */ 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'; @@ -19,7 +20,6 @@ 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 { sessionActivityContract } from './session/activity.js'; import { sessionApprovalContract } from './session/approval.js'; import { sessionInteractionContract } from './session/interaction.js'; import { sessionLifecycleContract } from './session/lifecycle.js'; @@ -43,12 +43,12 @@ export const globalContract: KlientContract = { // session scope (+ the app-registered lifecycle service) sessionLifecycleService: sessionLifecycleContract, sessionMetadata: sessionMetadataContract, - sessionActivity: sessionActivityContract, 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/activity.ts b/packages/klient/src/contract/session/activity.ts deleted file mode 100644 index 6e8aceceef..0000000000 --- a/packages/klient/src/contract/session/activity.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * `sessionActivity` — session-level activity and status. Mirrors - * `agent-core-v2/session/sessionActivity/sessionActivity.ts`. - */ - -import { z } from 'zod'; - -import type { ServiceContract } from '../types.js'; - -export const sessionStatusSchema = z.enum([ - 'running', - 'idle', - 'awaiting_approval', - 'awaiting_question', -]); - -export const sessionActivityContract = { - status: { input: z.tuple([]), output: sessionStatusSchema }, - isIdle: { input: z.tuple([]), output: z.boolean() }, -} satisfies ServiceContract; diff --git a/packages/klient/src/core/facade/session.ts b/packages/klient/src/core/facade/session.ts index c778553806..cce957aa60 100644 --- a/packages/klient/src/core/facade/session.ts +++ b/packages/klient/src/core/facade/session.ts @@ -7,12 +7,12 @@ * 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 { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; import type { ApprovalRequest, ApprovalResponse, @@ -52,6 +52,14 @@ export interface SessionInteractionsFacade { 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; @@ -94,7 +102,31 @@ export function createSessionFacade(call: ScopedCaller, sessionId: string): Sess update: (patch) => call(scope, 'sessionMetadata', 'update', [patch]) as Promise, setArchived: (archived) => call(scope, 'sessionMetadata', 'setArchived', [archived]) as Promise, - status: () => call(scope, 'sessionActivity', 'status', []) 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 () => { diff --git a/packages/klient/src/index.ts b/packages/klient/src/index.ts index 90722fc99f..ff3101461c 100644 --- a/packages/klient/src/index.ts +++ b/packages/klient/src/index.ts @@ -54,6 +54,7 @@ export type { SessionFacade, SessionInteractionsFacade, SessionQuestionsFacade, + SessionStatus, } from './core/facade/session.js'; export type { AgentContextData, @@ -111,7 +112,6 @@ export type { SessionMeta, SessionMetaPatch, } from '@moonshot-ai/agent-core-v2/session/sessionMetadata/sessionMetadata'; -export type { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; export type { ApprovalRequest, ApprovalResponse, diff --git a/packages/klient/src/transports/memory/serviceRegistry.ts b/packages/klient/src/transports/memory/serviceRegistry.ts index c882826234..4191cc6f83 100644 --- a/packages/klient/src/transports/memory/serviceRegistry.ts +++ b/packages/klient/src/transports/memory/serviceRegistry.ts @@ -23,11 +23,11 @@ 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 { ISessionActivity } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; 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>> = { @@ -45,11 +45,11 @@ export const serviceTokens: Readonly>> bootstrapService: IBootstrapService, sessionLifecycleService: ISessionLifecycleService, sessionMetadata: ISessionMetadata, - sessionActivity: ISessionActivity, sessionInteractionService: ISessionInteractionService, sessionApprovalService: ISessionApprovalService, sessionQuestionService: ISessionQuestionService, agentRPCService: IAgentRPCService, + agentActivityView: IAgentActivityView, }; export { IEventService }; diff --git a/packages/klient/test/contract-parity.ts b/packages/klient/test/contract-parity.ts index 5da4e02d67..b9ad1cbae2 100644 --- a/packages/klient/test/contract-parity.ts +++ b/packages/klient/test/contract-parity.ts @@ -10,7 +10,19 @@ 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, @@ -38,7 +50,6 @@ import type { QuestionResponse, QuestionResult, } from '@moonshot-ai/agent-core-v2/session/question/question'; -import type { SessionStatus } from '@moonshot-ai/agent-core-v2/session/sessionActivity/sessionActivity'; import type { AgentMeta, SessionMeta, @@ -106,6 +117,18 @@ import type { 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, @@ -140,7 +163,6 @@ import { turnStartedEventSchema, warningEventSchema, } from '../src/contract/agent/events.js'; -import { sessionStatusSchema } from '../src/contract/session/activity.js'; import { approvalRequestSchema, approvalResponseSchema, @@ -382,9 +404,6 @@ const _sessionMetadataChangedEvent: AssertWire< SessionMetadataChangedEvent > = true; -// session/activity.ts -const _sessionStatus: AssertWire = true; - // session/lifecycle.ts const _createSessionOptions: AssertWire = true; @@ -420,6 +439,26 @@ const _questionAnswers: 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. diff --git a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts index 086016c700..71b8a2da2f 100644 --- a/packages/klient/test/e2e/legacy/image-file-prompts.test.ts +++ b/packages/klient/test/e2e/legacy/image-file-prompts.test.ts @@ -113,10 +113,8 @@ describeLive('legacy: image file prompts', () => { expect(submit.prompt_id.length).toBeGreaterThan(0); log('prompt submitted', { file_id: png.id, prompt_id: submit.prompt_id }); - let terminalStatus: 'idle' | 'aborted' = 'idle'; try { await client.abortPrompt(sid, submit.prompt_id); - terminalStatus = 'aborted'; log('prompt aborted', { prompt_id: submit.prompt_id }); } catch (error) { if ( @@ -129,7 +127,7 @@ describeLive('legacy: image file prompts', () => { throw error; } } - await client.waitForSessionStatus(sid, terminalStatus, { timeoutMs: SHORT_TIMEOUT_MS }); + await client.waitForSessionBusy(sid, false, { timeoutMs: SHORT_TIMEOUT_MS }); } finally { for (const fileId of files.toReversed()) { try { diff --git a/packages/klient/test/e2e/v2/smoke.test.ts b/packages/klient/test/e2e/v2/smoke.test.ts index b89860c54c..460f02fb52 100644 --- a/packages/klient/test/e2e/v2/smoke.test.ts +++ b/packages/klient/test/e2e/v2/smoke.test.ts @@ -127,8 +127,13 @@ describe('Klient (server-v2 smoke)', () => { ); expect(after.title).toBe('renamed'); - const status = await v2Rpc(baseUrl, token, `/session/${sid}/sessionActivity/status`, []); - expect(['idle', 'running', 'awaiting_approval', 'awaiting_question']).toContain(status); + const pending = await v2Rpc( + baseUrl, + token, + `/session/${sid}/sessionInteractionService/listPending`, + [], + ); + expect(pending).toEqual([]); await v2Rpc(baseUrl, token, `/session/${sid}/sessionLifecycleService/archive`, [sid]); });