diff --git a/CHANGELOG.md b/CHANGELOG.md index 175d29f..b79942a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +- `lich serve` `health` also returns `protocol_version` (`1`, bumped only on + breaking changes) and `capabilities: { methods, notifications }` (#144). + `methods` lists `prompt.*` only when the server has an Agent. - `lich serve` queues frames per session on each connection instead of per connection (#144). A long `prompt.submit` no longer holds up other sessions or session-less frames such as `health` on the same socket. Frames diff --git a/docs/architecture/serve.md b/docs/architecture/serve.md index 78056d9..1aaa413 100644 --- a/docs/architecture/serve.md +++ b/docs/architecture/serve.md @@ -42,7 +42,7 @@ method `event` (no `id`). | Method | Params | Result | | --- | --- | --- | -| `health` | `{}` | `{ status: "ok", version }` (`LICH_VERSION`) | +| `health` | `{}` | `{ status: "ok", version, protocol_version, capabilities }` | | `session.create` | `{ label?, source }` | `{ session_id }` | | `session.list` | `{}` | `{ sessions: [{ id, mtime_ms }, ...] }` | | `session.clear` | `{ session_id }` | `{ session_id }` | @@ -50,6 +50,13 @@ method `event` (no `id`). | `prompt.submit` | `{ session_id, text }` | reply, usage, `session_path`, `stopped_reason`, … | | `prompt.abort` | `{ session_id }` | `{ session_id, aborted }` | +`health` reports `version` (`LICH_VERSION`), `protocol_version` +(`SERVE_PROTOCOL_VERSION`, currently `1`) and +`capabilities: { methods, notifications }`. `protocol_version` changes only +on breaking changes; new features are added to `capabilities`, so clients +check it before calling a method. `methods` lists `prompt.submit` and +`prompt.abort` only when the server has an Agent. + `health` and `session.list` have no param fields. Typed clients still send `params: {}`, because `ServeRequest` requires `params`. JSON-RPC 2.0 also allows omitting `params`; serve handlers accept that omission the same as `{}`. @@ -113,7 +120,6 @@ same WebSocket that issued `prompt.submit` while the call is still in flight. - Origin allowlisting is deferred until Electron’s page origin policy is decided; do not assume `file://` / `app://` behavior here. - Frame size capped at ~1 MiB (`maxPayload`). -- `health` returns `{ status: "ok", version }` (`LICH_VERSION` from `src/version.ts`). - Frames are queued per session within a connection: frames whose `params.session_id` match are handled in arrival order and get in-order replies, while other sessions, and frames that name no session (such as diff --git a/src/index.ts b/src/index.ts index 2249415..7c1e8c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,6 +63,7 @@ export { SERVE_METHODS, SERVE_NOTIFICATION_EVENT, SERVE_ERROR_CODES, + SERVE_PROTOCOL_VERSION, } from "./serve/protocol.js"; export type { HealthParams, @@ -78,6 +79,7 @@ export type { PromptAbortResult, PromptSubmitParams, PromptSubmitResult, + ServeCapabilities, ServeEventNotification, ServeEventParams, ServeMethod, diff --git a/src/serve/protocol.ts b/src/serve/protocol.ts index f2d1f66..2f77615 100644 --- a/src/serve/protocol.ts +++ b/src/serve/protocol.ts @@ -24,6 +24,12 @@ export const SERVE_NOTIFICATION_EVENT = "event" as const; export type ServeNotificationMethod = typeof SERVE_NOTIFICATION_EVENT; +/** + * Wire protocol version reported by `health`. Bumped only on breaking changes; + * additive features show up in `capabilities` instead. + */ +export const SERVE_PROTOCOL_VERSION = 1; + export type JsonRpcId = string | number; export interface JsonRpcRequest { @@ -75,7 +81,19 @@ export type HealthParams = Record; export interface HealthResult { status: "ok"; + /** Lich release (`LICH_VERSION`). */ version: string; + /** `SERVE_PROTOCOL_VERSION` of this server. */ + protocol_version: number; + capabilities: ServeCapabilities; +} + +/** What this server answers, so clients can check before calling. */ +export interface ServeCapabilities { + /** Methods served; `prompt.*` only when the server has an Agent. */ + methods: ServeMethod[]; + /** Server → client notification methods. */ + notifications: ServeNotificationMethod[]; } export interface SessionCreateParams { diff --git a/src/serve/rpc.ts b/src/serve/rpc.ts index e78c102..2435051 100644 --- a/src/serve/rpc.ts +++ b/src/serve/rpc.ts @@ -10,7 +10,12 @@ import type { JsonRpcSuccess, ServeMethod, } from "./protocol.js"; -import { SERVE_ERROR_CODES, SERVE_METHODS } from "./protocol.js"; +import { + SERVE_ERROR_CODES, + SERVE_METHODS, + SERVE_NOTIFICATION_EVENT, + SERVE_PROTOCOL_VERSION, +} from "./protocol.js"; import type { ServeEventNotify, ServePromptService } from "./prompts.js"; import type { ServeSessionStore } from "./sessions.js"; @@ -78,7 +83,17 @@ async function dispatch_method( if (params !== undefined && is_empty_params(params) !== true) { return error_response(id, SERVE_ERROR_CODES.INVALID_PARAMS, "Invalid params"); } - const result: HealthResult = { status: "ok", version: context.version }; + const result: HealthResult = { + status: "ok", + version: context.version, + protocol_version: SERVE_PROTOCOL_VERSION, + capabilities: { + methods: SERVE_METHODS.filter( + (name) => context.prompts !== undefined || name.startsWith("prompt.") !== true, + ), + notifications: [SERVE_NOTIFICATION_EVENT], + }, + }; return { jsonrpc: "2.0", id: id as JsonRpcId, result }; } if (method === "session.create") { diff --git a/test/serve_cli.test.ts b/test/serve_cli.test.ts index b7281c5..0300f83 100644 --- a/test/serve_cli.test.ts +++ b/test/serve_cli.test.ts @@ -165,7 +165,7 @@ describe("bun src/cli.ts serve boot", () => { expect(result).toEqual({ jsonrpc: "2.0", id: 1, - result: { status: "ok", version: LICH_VERSION }, + result: expect.objectContaining({ status: "ok", version: LICH_VERSION, protocol_version: 1 }), }); child.kill("SIGTERM"); await wait_exit(child); diff --git a/test/serve_rpc.test.ts b/test/serve_rpc.test.ts index 25c48f9..47dc189 100644 --- a/test/serve_rpc.test.ts +++ b/test/serve_rpc.test.ts @@ -130,7 +130,7 @@ describe("serve rpc framing", () => { expect(null_id.body).toEqual({ jsonrpc: "2.0", id: null, - result: { status: "ok", version: "9.9.9" }, + result: expect.objectContaining({ status: "ok", version: "9.9.9" }), }); const null_params = await dispatch( @@ -140,7 +140,7 @@ describe("serve rpc framing", () => { expect(null_params.body).toEqual({ jsonrpc: "2.0", id: 3, - result: { status: "ok", version: "9.9.9" }, + result: expect.objectContaining({ status: "ok", version: "9.9.9" }), }); }); diff --git a/test/serve_transport.test.ts b/test/serve_transport.test.ts index 792b478..93a1365 100644 --- a/test/serve_transport.test.ts +++ b/test/serve_transport.test.ts @@ -9,6 +9,8 @@ import path from "node:path"; import { Writable } from "node:stream"; import WebSocket from "ws"; import { LICH_VERSION } from "../src/index.js"; +import type { ServePromptService } from "../src/serve/prompts.js"; +import { SERVE_METHODS, SERVE_PROTOCOL_VERSION, type HealthResult } from "../src/serve/protocol.js"; import { handle_serve_rpc_message } from "../src/serve/rpc.js"; import { create_serve_server, type ServeServer } from "../src/serve/server.js"; import { create_serve_session_store } from "../src/serve/sessions.js"; @@ -91,7 +93,7 @@ afterEach(async () => { }); describe("serve rpc health", () => { - it("returns status and version for health", async () => { + it("returns status, versions and capabilities for health", async () => { const sessions = create_serve_session_store(path.join(await make_temp_dir("serve-health"), "s")); const raw = await handle_serve_rpc_message( JSON.stringify({ jsonrpc: "2.0", id: 1, method: "health", params: {} }), @@ -100,10 +102,28 @@ describe("serve rpc health", () => { expect(JSON.parse(raw ?? "")).toEqual({ jsonrpc: "2.0", id: 1, - result: { status: "ok", version: "9.9.9" }, + result: { + status: "ok", + version: "9.9.9", + protocol_version: SERVE_PROTOCOL_VERSION, + capabilities: { + methods: ["health", "session.create", "session.list", "session.clear", "session.resume"], + notifications: ["event"], + }, + }, }); }); + it("lists prompt methods in health only when an agent is configured", async () => { + const sessions = create_serve_session_store(path.join(await make_temp_dir("serve-health-agent"), "s")); + const raw = await handle_serve_rpc_message( + JSON.stringify({ jsonrpc: "2.0", id: 1, method: "health", params: {} }), + { version: "9.9.9", sessions, prompts: {} as ServePromptService }, + ); + const body = JSON.parse(raw ?? "") as { result: HealthResult }; + expect(body.result.capabilities.methods).toEqual([...SERVE_METHODS]); + }); + it("returns agent-not-configured for prompt.submit without an agent", async () => { const sessions = create_serve_session_store(path.join(await make_temp_dir("serve-stub"), "s")); const raw = await handle_serve_rpc_message( @@ -146,7 +166,7 @@ describe("serve websocket transport", () => { expect(result).toEqual({ jsonrpc: "2.0", id: 1, - result: { status: "ok", version: LICH_VERSION }, + result: expect.objectContaining({ status: "ok", version: LICH_VERSION }), }); });