diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 74224ab1cfc..5673bb4c036 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -69,7 +69,9 @@ Attaches to existing sessions are NOT counted toward the cap, so an idle daemon' ## Capabilities -Every Stage 1 daemon advertises 9 feature tags. Clients **must** gate UI off `features`, not off `mode` (per design §10). +The daemon advertises its supported feature tags from the serve capability +registry. Clients **must** gate UI off `features`, not off `mode` (per design +§10). ``` ['health', 'capabilities', 'session_create', 'session_list', @@ -104,6 +106,10 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo ```json { "v": 1, + "protocolVersions": { + "current": "v1", + "supported": ["v1"] + }, "mode": "http-bridge", "features": ["health", "capabilities", "..."], "modelServices": [], @@ -113,6 +119,8 @@ Pass `?deep=1` (also accepts `?deep=true` or bare `?deep`) for a probe that expo Stable contract: when `v` increments the frame layout has changed in a backwards-incompatible way. +> **`protocolVersions`** describes the serve protocol versions the daemon can speak. `current` is the daemon's preferred protocol version and `supported` is the compatible set. Additive to v=1: older v=1 daemons omit this field, so SDK clients that target older builds should treat it as optional. + > **`modelServices` is always `[]` in Stage 1.** The agent uses its single default model service and doesn't enumerate it over the wire. Stage 2 will populate this from registered model adapters so SDK clients can build service-pickers; until then, do NOT rely on this field being non-empty. > **`workspaceCwd`** is the canonical absolute path this daemon binds to (#3803 §02 — 1 daemon = 1 workspace). Use it to (a) detect mismatch before posting `/session` and (b) omit `cwd` on `POST /session` (the route falls back to this path). Multi-workspace deployments expose multiple daemons on different ports, each with its own `workspaceCwd`. Additive to v=1: pre-§02 v=1 daemons omit the field — clients that target older builds should null-check before consuming it. diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts new file mode 100644 index 00000000000..0f96352d315 --- /dev/null +++ b/packages/cli/src/serve/capabilities.ts @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export const SERVE_PROTOCOL_VERSION = 'v1' as const; + +export const SUPPORTED_SERVE_PROTOCOL_VERSIONS = [ + SERVE_PROTOCOL_VERSION, +] as const; + +export type ServeProtocolVersion = + (typeof SUPPORTED_SERVE_PROTOCOL_VERSIONS)[number]; + +export interface ServeProtocolVersions { + current: ServeProtocolVersion; + supported: ServeProtocolVersion[]; +} + +export interface ServeCapabilityDescriptor { + since: ServeProtocolVersion; +} + +export const SERVE_CAPABILITY_REGISTRY = { + health: { since: SERVE_PROTOCOL_VERSION }, + capabilities: { since: SERVE_PROTOCOL_VERSION }, + session_create: { since: SERVE_PROTOCOL_VERSION }, + session_list: { since: SERVE_PROTOCOL_VERSION }, + session_prompt: { since: SERVE_PROTOCOL_VERSION }, + session_cancel: { since: SERVE_PROTOCOL_VERSION }, + session_events: { since: SERVE_PROTOCOL_VERSION }, + session_set_model: { since: SERVE_PROTOCOL_VERSION }, + permission_vote: { since: SERVE_PROTOCOL_VERSION }, +} as const satisfies Record; + +export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY; + +export const SERVE_FEATURES = Object.freeze( + Object.keys(SERVE_CAPABILITY_REGISTRY) as ServeFeature[], +); + +export function getServeFeatures(): ServeFeature[] { + return [...SERVE_FEATURES]; +} + +export function getServeProtocolVersions(): ServeProtocolVersions { + return { + current: SERVE_PROTOCOL_VERSION, + supported: [...SUPPORTED_SERVE_PROTOCOL_VERSIONS], + }; +} diff --git a/packages/cli/src/serve/index.ts b/packages/cli/src/serve/index.ts index 52288bbe2ed..85a5d12db6a 100644 --- a/packages/cli/src/serve/index.ts +++ b/packages/cli/src/serve/index.ts @@ -12,10 +12,20 @@ export { } from './runQwenServe.js'; export { CAPABILITIES_SCHEMA_VERSION, + SERVE_CAPABILITY_REGISTRY, + SERVE_FEATURES, + SERVE_PROTOCOL_VERSION, STAGE1_FEATURES, + SUPPORTED_SERVE_PROTOCOL_VERSIONS, + getServeFeatures, + getServeProtocolVersions, type CapabilitiesEnvelope, + type ServeCapabilityDescriptor, + type ServeFeature, type ServeMode, type ServeOptions, + type ServeProtocolVersion, + type ServeProtocolVersions, type Stage1Feature, } from './types.js'; export { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1c916d271b9..7e7ef756c9d 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -11,6 +11,12 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import request from 'supertest'; import { createServeApp } from './server.js'; import { runQwenServe, type RunHandle } from './runQwenServe.js'; +import { + getServeFeatures, + getServeProtocolVersions, + SERVE_CAPABILITY_REGISTRY, + SERVE_PROTOCOL_VERSION, +} from './capabilities.js'; import type { CancelNotification, PromptRequest, @@ -30,11 +36,7 @@ import { type HttpAcpBridge, } from './httpAcpBridge.js'; import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; -import { - CAPABILITIES_SCHEMA_VERSION, - STAGE1_FEATURES, - type ServeOptions, -} from './types.js'; +import { CAPABILITIES_SCHEMA_VERSION, type ServeOptions } from './types.js'; const baseOpts: ServeOptions = { hostname: '127.0.0.1', @@ -51,6 +53,17 @@ const baseOpts: ServeOptions = { // WS_B). const WS_BOUND = path.resolve(path.sep, 'work', 'bound'); const WS_DIFFERENT = path.resolve(path.sep, 'work', 'different'); +const EXPECTED_STAGE1_FEATURES = [ + 'health', + 'capabilities', + 'session_create', + 'session_list', + 'session_prompt', + 'session_cancel', + 'session_events', + 'session_set_model', + 'permission_vote', +] as const; interface FakeBridgeOpts { spawnImpl?: (req: BridgeSpawnRequest) => Promise; @@ -190,6 +203,25 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { } describe('createServeApp', () => { + describe('serve capability registry', () => { + it('returns a fresh ordered feature list', () => { + const features = getServeFeatures(); + expect(features).toEqual([...EXPECTED_STAGE1_FEATURES]); + + features.pop(); + expect(getServeFeatures()).toEqual([...EXPECTED_STAGE1_FEATURES]); + }); + + it('marks every current feature as v1', () => { + expect(Object.keys(SERVE_CAPABILITY_REGISTRY)).toEqual([ + ...EXPECTED_STAGE1_FEATURES, + ]); + expect( + Object.values(SERVE_CAPABILITY_REGISTRY).map(({ since }) => since), + ).toEqual(EXPECTED_STAGE1_FEATURES.map(() => SERVE_PROTOCOL_VERSION)); + }); + }); + describe('GET /health', () => { it('returns 200 ok', async () => { const app = createServeApp(baseOpts); @@ -209,8 +241,9 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`); expect(res.status).toBe(200); expect(res.body.v).toBe(CAPABILITIES_SCHEMA_VERSION); + expect(res.body.protocolVersions).toEqual(getServeProtocolVersions()); expect(res.body.mode).toBe('http-bridge'); - expect(res.body.features).toEqual([...STAGE1_FEATURES]); + expect(res.body.features).toEqual(getServeFeatures()); expect(res.body.modelServices).toEqual([]); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 39cb996bde3..2144ee072a1 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -20,10 +20,10 @@ import { WorkspaceMismatchError, type HttpAcpBridge, } from './httpAcpBridge.js'; +import { getServeFeatures, getServeProtocolVersions } from './capabilities.js'; import { SubscriberLimitExceededError, type BridgeEvent } from './eventBus.js'; import { CAPABILITIES_SCHEMA_VERSION, - STAGE1_FEATURES, type CapabilitiesEnvelope, type ServeOptions, } from './types.js'; @@ -197,8 +197,9 @@ export function createServeApp( app.get('/capabilities', (_req, res) => { const envelope: CapabilitiesEnvelope = { v: CAPABILITIES_SCHEMA_VERSION, + protocolVersions: getServeProtocolVersions(), mode: opts.mode, - features: [...STAGE1_FEATURES], + features: getServeFeatures(), modelServices: [], // #3803 §02: surface the bound workspace so clients can detect // mismatch pre-flight and omit `cwd` on `POST /session`. diff --git a/packages/cli/src/serve/types.ts b/packages/cli/src/serve/types.ts index 19ccdf4c3e9..395ca48f752 100644 --- a/packages/cli/src/serve/types.ts +++ b/packages/cli/src/serve/types.ts @@ -4,6 +4,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { + SERVE_FEATURES, + type ServeFeature, + type ServeProtocolVersions, +} from './capabilities.js'; + /** * Stage 1 daemon mode shape. * @@ -87,6 +93,11 @@ export interface ServeOptions { */ export interface CapabilitiesEnvelope { v: 1; + /** + * Serve protocol versions supported by this daemon. Optional because this is + * additive to v=1; older v=1 daemons omit it. + */ + protocolVersions?: ServeProtocolVersions; mode: ServeMode; features: string[]; /** @@ -119,34 +130,21 @@ export interface CapabilitiesEnvelope { export const CAPABILITIES_SCHEMA_VERSION = 1 as const; -/** - * Stage 1 ships only the routes wired in `server.ts`. As routes land in - * follow-up PRs, append the corresponding feature tag here so clients can - * progressively enable UI affordances. - * - * The annotation is intentionally absent: `as const` widens to - * `readonly ['health', 'capabilities', ...]` and the derived - * `Stage1Feature` union catches typos at compile time. Annotating as - * `readonly string[]` would erase the literal information. - */ -// FIXME(stage-1.5, chiga0 finding 5): -// `STAGE1_FEATURES` is a hard-coded constant — `extMethod` plugins -// can't contribute to the capability set without editing the daemon. -// Stage 1.5 should convert this to a registry that bridges and -// plugins push into, alongside an `ext_*` event family + a -// `POST /ext/:method` route. Tracked under #3803. -// Reference: https://github.com/QwenLM/qwen-code/pull/3889#issuecomment-4427773706 -export const STAGE1_FEATURES = [ - 'health', - 'capabilities', - 'session_create', - 'session_list', - 'session_prompt', - 'session_cancel', - 'session_events', - 'session_set_model', - 'permission_vote', -] as const; +/** @deprecated Use SERVE_FEATURES from the capability registry. */ +export const STAGE1_FEATURES = SERVE_FEATURES; + +/** @deprecated Use ServeFeature from the capability registry. */ +export type Stage1Feature = ServeFeature; -/** Compile-time-checked feature identifier — element of STAGE1_FEATURES. */ -export type Stage1Feature = (typeof STAGE1_FEATURES)[number]; +export { + getServeFeatures, + getServeProtocolVersions, + SERVE_CAPABILITY_REGISTRY, + SERVE_FEATURES, + SERVE_PROTOCOL_VERSION, + SUPPORTED_SERVE_PROTOCOL_VERSIONS, + type ServeCapabilityDescriptor, + type ServeFeature, + type ServeProtocolVersion, + type ServeProtocolVersions, +} from './capabilities.js'; diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 106f6e4f42e..4939f97d3b2 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -18,6 +18,7 @@ export type { DaemonCapabilities, DaemonEvent, DaemonMode, + DaemonProtocolVersions, DaemonSession, DaemonSessionSummary, PermissionOutcome, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 7114b0f6670..9c880ff55a6 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -15,9 +15,19 @@ export type DaemonMode = 'http-bridge' | 'native'; +export interface DaemonProtocolVersions { + current: string; + supported: string[]; +} + /** Capabilities envelope returned from `GET /capabilities`. */ export interface DaemonCapabilities { v: 1; + /** + * Serve protocol versions supported by the daemon. Optional because this is + * additive to v=1; older v=1 daemons omit it. + */ + protocolVersions?: DaemonProtocolVersions; mode: DaemonMode; /** * Feature tags the client should gate UI off (e.g. `permission_vote`, diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 2dc5a2e8fea..6d4a0d48fd2 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -16,6 +16,7 @@ export { type DaemonClientOptions, type DaemonEvent, type DaemonMode, + type DaemonProtocolVersions, type DaemonSession, type DaemonSessionSummary, type PermissionOutcome, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index e39c93d1ba3..18164d0acde 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -98,6 +98,10 @@ describe('DaemonClient', () => { it('GETs /capabilities and returns the v1 envelope', async () => { const envelope = { v: 1 as const, + protocolVersions: { + current: 'v1', + supported: ['v1'], + }, mode: 'http-bridge' as const, features: ['health', 'capabilities'], modelServices: [], @@ -111,6 +115,19 @@ describe('DaemonClient', () => { // omit `cwd` from `POST /session` (route falls back). expect(caps.workspaceCwd).toBe('/work/bound'); }); + + it('accepts old v1 envelopes without protocolVersions', async () => { + const envelope: DaemonCapabilities = { + v: 1, + mode: 'http-bridge', + features: ['health', 'capabilities'], + modelServices: [], + workspaceCwd: '/work/bound', + }; + const { fetch } = recordingFetch(() => jsonResponse(200, envelope)); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect(client.capabilities()).resolves.toEqual(envelope); + }); }); describe('bearer auth', () => {