Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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": [],
Expand All @@ -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.
Expand Down
52 changes: 52 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
@@ -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<string, ServeCapabilityDescriptor>;

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],
};
}
10 changes: 10 additions & 0 deletions packages/cli/src/serve/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
45 changes: 39 additions & 6 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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',
Expand All @@ -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<BridgeSession>;
Expand Down Expand Up @@ -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);
Expand All @@ -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([]);
});

Expand Down
5 changes: 3 additions & 2 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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`.
Expand Down
58 changes: 28 additions & 30 deletions packages/cli/src/serve/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -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[];
/**
Expand Down Expand Up @@ -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';
1 change: 1 addition & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export type {
DaemonCapabilities,
DaemonEvent,
DaemonMode,
DaemonProtocolVersions,
DaemonSession,
DaemonSessionSummary,
PermissionOutcome,
Expand Down
10 changes: 10 additions & 0 deletions packages/sdk-typescript/src/daemon/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand Down
1 change: 1 addition & 0 deletions packages/sdk-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ export {
type DaemonClientOptions,
type DaemonEvent,
type DaemonMode,
type DaemonProtocolVersions,
type DaemonSession,
type DaemonSessionSummary,
type PermissionOutcome,
Expand Down
17 changes: 17 additions & 0 deletions packages/sdk-typescript/test/unit/DaemonClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -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', () => {
Expand Down
Loading