From 24edd459eadf69d7cc9c2dc52a9f440bc776d364 Mon Sep 17 00:00:00 2001 From: samuelhsin Date: Fri, 26 Jun 2026 01:14:42 +0800 Subject: [PATCH] feat(serve): query a single session's status by id (#5857) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(serve): query a single session's status by id Add a daemon HTTP endpoint, GET /session/:id/status, that returns the live status summary for one session by its id — the same per-item shape that the workspace session list produces (sessionId, workspaceCwd, createdAt, displayName, clientCount, hasActivePrompt). It answers 200 with the summary when the daemon holds a live session with that id, and 404 when the id is unknown. Previously the only way to read a session's live state was the full paginated workspace session list, forcing a caller that already holds a session id to fetch every page and filter client-side just to answer "is this session still running?". A by-id lookup is the natural primitive for polling a single known session's hasActivePrompt / clientCount — for example, a client UI that disables controls or shows a "task in progress" hint while a specific session is running. The data already exists on the bridge, so this adds one accessor (getSessionSummary) sharing the same summary builder as the list path, one route, unit tests, and a docs note. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(serve): clarify /session/:id/status returns the live bridge view The session-status docs said the response is "the same item shape that GET /workspace/:id/sessions lists". That parity only holds at the bridge layer; the HTTP list endpoint enriches each item with persisted session-store data, so for the same live session the two routes diverge on createdAt (persisted first-prompt time vs live spawn time), updatedAt (present only on the list), and displayName (derived from the stored title/prompt vs the live session's own, usually unset). Reword to describe /status as the raw live-session view, spell out those differences, and fix the 404 note to match the actual { error, sessionId } body (no code field). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(serve): advertise GET /session/:id/status via session_status capability The new single-session status route had no entry in the capability registry, so clients couldn't feature-detect it the way they pre-flight the sibling read-only session routes (session_context, session_tasks, session_stats, session_lsp, …). Add an always-on `session_status` tag, mirror it in the registered-features test, and document it in the protocol feature list, the capability→route map, and the capability versioning reference. Co-Authored-By: Claude Opus 4.8 (1M context) * test(serve): pin that /session/:id/status omits displayName when unset The docs state the route returns displayName only when the live session has one, but no test asserted the key is absent from the HTTP body in that case — it relied implicitly on res.json() dropping the undefined-valued key. Add a sibling 200 test with a summary that has no displayName and assert the key is not present, so a future change to the shared summary builder can't silently break the documented shape. Co-Authored-By: Claude Opus 4.8 (1M context) * test(serve): add session_status to the integration capabilities baseline The capabilities-envelope integration test pins the full caps.features list returned by a live daemon, so adding the session_status capability tag to the registry made the live list diverge from the test's hardcoded baseline (CI: expected 65, received 66). Add session_status to that baseline in the same position the registry emits it (after session_lsp), and to the session-lifecycle capability-tag reference for completeness. Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: 云胧 Co-authored-by: Claude Opus 4.8 (1M context) --- .../developers/daemon/08-session-lifecycle.md | 2 +- .../daemon/11-capabilities-versioning.md | 2 +- docs/developers/qwen-serve-protocol.md | 5 +- docs/users/qwen-serve.md | 24 +++++- .../cli/qwen-serve-routes.test.ts | 1 + packages/acp-bridge/src/bridge.test.ts | 33 ++++++++ packages/acp-bridge/src/bridge.ts | 23 ++++-- packages/acp-bridge/src/bridgeTypes.ts | 9 ++ packages/cli/src/serve/capabilities.ts | 1 + packages/cli/src/serve/server.test.ts | 82 +++++++++++++++++++ packages/cli/src/serve/server.ts | 14 ++++ 11 files changed, 184 insertions(+), 12 deletions(-) diff --git a/docs/developers/daemon/08-session-lifecycle.md b/docs/developers/daemon/08-session-lifecycle.md index c60e7bdd7c..47ba304e2c 100644 --- a/docs/developers/daemon/08-session-lifecycle.md +++ b/docs/developers/daemon/08-session-lifecycle.md @@ -258,7 +258,7 @@ new session arrives. - `BridgeOptions.sessionScope` (default `'single'`; optional `'thread'`). - `BridgeOptions.initializeTimeoutMs` (default 10s) — ACP `initialize` handshake. - `BridgeOptions.channelIdleTimeoutMs` (default 0; reap the ACP child immediately). -- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `session_lsp`, `non_blocking_prompt`. +- Capability tags: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume` (deprecated alias), `session_list`, `session_close`, `session_metadata`, `session_set_model`, `client_identity`, `client_heartbeat`, `session_recap`, `session_btw`, `session_context_usage`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `non_blocking_prompt`. ## Caveats & Known Limits diff --git a/docs/developers/daemon/11-capabilities-versioning.md b/docs/developers/daemon/11-capabilities-versioning.md index c0424fe1b1..e626f696d7 100644 --- a/docs/developers/daemon/11-capabilities-versioning.md +++ b/docs/developers/daemon/11-capabilities-versioning.md @@ -89,7 +89,7 @@ Baseline tags are not present in the `Map` and are advertised unconditionally. T Foundation: `health`, `capabilities`. -Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. +Sessions: `session_create`, `session_scope_override`, `session_load`, `session_resume`, `unstable_session_resume`, `session_list`, `session_prompt`, `session_cancel`, `session_events`, `session_set_model`, `session_close`, `session_metadata`, `session_context`, `session_context_usage`, `session_supported_commands`, `session_tasks`, `session_stats`, `session_lsp`, `session_status`, `session_approval_mode_control`, `session_recap`, `session_btw`, **`session_shell_command`** (conditional), `session_language`, `session_rewind`, `session_hooks`, `session_branch`. Streaming: `slow_client_warning`, `typed_event_schema`. diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 6215f0b34b..5661284a96 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -129,7 +129,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'workspace_agents', 'workspace_agent_generate', 'workspace_env', 'workspace_preflight', 'session_context', 'session_context_usage', 'session_supported_commands', 'session_tasks', 'session_stats', - 'session_lsp', + 'session_lsp', 'session_status', 'session_close', 'session_metadata', 'mcp_guardrails', 'workspace_mcp_manage', 'mcp_guardrail_events', 'mcp_server_runtime_mutation', @@ -161,6 +161,8 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design `session_lsp` advertises `GET /session/:id/lsp`, the read-only structured LSP status snapshot for daemon clients. Older daemons return `404`; pre-flight this tag before exposing remote LSP status. +`session_status` advertises `GET /session/:id/status`, the live bridge summary for a single session by id (`clientCount` / `hasActivePrompt` and the core fields). Older daemons return `404`; pre-flight this tag before polling a single session's status instead of scanning the full session list. + `session_approval_mode_control`, `workspace_tool_toggle`, `workspace_init`, and `workspace_mcp_restart` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) advertise the four mutation control routes documented under "Mutation: approval, tools, init, MCP restart" below. All four are strict-gated by the PR 15 mutation gate (a daemon configured without a bearer token rejects them with 401 `token_required`). Older daemons return `404`; pre-flight each tag before exposing the corresponding affordance. `mcp_guardrails` (issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 14) covers the MCP budget surface: the `clientCount` / `clientBudget` / `budgetMode` / `budgets[]` fields on `GET /workspace/mcp`, the `disabledReason` field on per-server cells, and the `--mcp-client-budget` / `--mcp-budget-mode` CLI flags. Older daemons omit the new fields entirely; SDK clients pre-flight this tag before relying on `budgets[]` semantics. The registry descriptor also carries `modes: ['warn', 'enforce']` for future feature-modes exposure — for now, clients infer mode from the snapshot's `budgetMode` field. Server refusal under `enforce` mode is deterministic by `Object.entries(mcpServers)` declaration order; a future scope-precedence layer (if qwen-code adopts one) would shift this to "lowest-precedence first" to mirror claude-code's `plugin < user < project < local` convention. @@ -349,6 +351,7 @@ Capability tags: - `session_context` → `GET /session/:id/context` - `session_supported_commands` → `GET /session/:id/supported-commands` - `session_tasks` → `GET /session/:id/tasks` +- `session_status` → `GET /session/:id/status` Common status cell: diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index e6eddea48c..b59ea88282 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -86,9 +86,31 @@ The daemon also exposes read-only runtime snapshots for client UIs and operators: `GET /daemon/status`, `GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`, `GET /workspace/preflight`, -`GET /session/:id/context`, `GET /session/:id/supported-commands`, and +`GET /session/:id/status`, `GET /session/:id/context`, +`GET /session/:id/supported-commands`, and `GET /session/:id/tasks`, and `GET /session/:id/lsp`. +`GET /session/:id/status` returns the live bridge summary for a single session: +`sessionId`, `workspaceCwd`, `createdAt`, optional `displayName`, `clientCount`, +and `hasActivePrompt`. It answers `200` with the summary when the daemon holds a +live session with that id, and `404` (body `{ "error": …, "sessionId": … }`) +otherwise. Use it to poll whether one known session is still running +(`hasActivePrompt`) or how many clients are attached (`clientCount`) without +fetching and scanning the whole paginated session list: + +```bash +curl http://127.0.0.1:4170/session/$SESSION_ID/status +# → {"sessionId":"…","workspaceCwd":"…","createdAt":"…","clientCount":1,"hasActivePrompt":false} +``` + +This is the raw live-session view, so `clientCount` and `hasActivePrompt` match +the corresponding entry in `GET /workspace/:id/sessions` — but the two routes +are not byte-identical. The list endpoint enriches each item with persisted +session-store data: its `createdAt` is the persisted first-prompt time, and it +adds `updatedAt` plus a `displayName` derived from the stored title or first +prompt. `/status` instead reports the live session's own `createdAt`, omits +`updatedAt`, and returns `displayName` only when one is set on the live session. + `GET /session/:id/lsp` returns structured per-session LSP status. Start the daemon with `--experimental-lsp` to enable LSP in spawned agent sessions; otherwise the route returns `enabled: false` with no servers. diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index cac0bf8017..f8af69abc1 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -268,6 +268,7 @@ describe('qwen serve — capabilities envelope', () => { 'session_tasks', 'session_stats', 'session_lsp', + 'session_status', 'session_close', 'session_metadata', 'mcp_guardrails', diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index a3e3e55749..62156c04b1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -5236,6 +5236,39 @@ describe('createAcpSessionBridge', () => { }); }); + describe('getSessionSummary', () => { + it('returns the live summary for a known session id', async () => { + const factory: ChannelFactory = async () => makeChannel().channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const summary = bridge.getSessionSummary(session.sessionId); + expect(summary).toMatchObject({ + sessionId: session.sessionId, + workspaceCwd: WS_A, + hasActivePrompt: false, + }); + // Agrees with the list builder for the same session — same source, + // single item. + const fromList = bridge + .listWorkspaceSessions(WS_A) + .find((s) => s.sessionId === session.sessionId); + expect(summary).toEqual(fromList); + + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for an unknown session id', async () => { + const bridge = makeBridge({ + channelFactory: async () => makeChannel().channel, + }); + expect(() => bridge.getSessionSummary('missing')).toThrow( + SessionNotFoundError, + ); + await bridge.shutdown(); + }); + }); + describe('setSessionModel', () => { /** Set up a channel where the agent records setSessionModel calls. */ async function setup() { diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index ab6d9b73c0..b7509b4944 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -1008,6 +1008,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { // daemon. Cleared in the `finally` of the creator. let inFlightChannelSpawn: Promise | undefined; const byId = new Map(); + const toSessionSummary = (entry: SessionEntry): BridgeSessionSummary => ({ + sessionId: entry.sessionId, + workspaceCwd: entry.workspaceCwd, + createdAt: entry.createdAt, + displayName: entry.displayName, + clientCount: entry.clientIds.size, + hasActivePrompt: entry.promptActive, + }); // Pending + resolved permission state lives in // `MultiClientPermissionMediator` (constructed below). The bridge // keeps `entry.pendingPermissionIds: Set` on each @@ -3558,19 +3566,18 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const out: BridgeSessionSummary[] = []; for (const entry of byId.values()) { if (entry.workspaceCwd === key) { - out.push({ - sessionId: entry.sessionId, - workspaceCwd: entry.workspaceCwd, - createdAt: entry.createdAt, - displayName: entry.displayName, - clientCount: entry.clientIds.size, - hasActivePrompt: entry.promptActive, - }); + out.push(toSessionSummary(entry)); } } return out; }, + getSessionSummary(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return toSessionSummary(entry); + }, + recordHeartbeat(sessionId, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 77e32cfd03..280b26b32f 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -392,6 +392,15 @@ export interface AcpSessionBridge { */ listWorkspaceSessions(workspaceCwd: string): BridgeSessionSummary[]; + /** + * Live status summary for a single session by id — the same shape + * `listWorkspaceSessions` produces per item. Throws + * `SessionNotFoundError` when no live session with that id exists on + * this daemon. Lets a caller that already holds a session id poll + * `hasActivePrompt` / `clientCount` without scanning the whole list. + */ + getSessionSummary(sessionId: string): BridgeSessionSummary; + /** * Record a client heartbeat for the session. Throws * `SessionNotFoundError` for unknown ids and `InvalidClientIdError` diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index fbe9d7da3e..9f47881dfe 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -82,6 +82,7 @@ export const SERVE_CAPABILITY_REGISTRY = { session_tasks: { since: 'v1' }, session_stats: { since: 'v1' }, session_lsp: { since: 'v1' }, + session_status: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Daemon supports the MCP client guardrail surface: an in-process diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 2f58237e09..2631882e7c 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -202,6 +202,7 @@ const EXPECTED_STAGE1_FEATURES = [ 'session_tasks', 'session_stats', 'session_lsp', + 'session_status', 'session_close', 'session_metadata', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client @@ -381,6 +382,7 @@ interface FakeBridgeOpts { context?: BridgeClientRequestContext, ) => boolean; listImpl?: (workspaceCwd: string) => BridgeSessionSummary[]; + summaryImpl?: (sessionId: string) => BridgeSessionSummary; workspaceMcpImpl?: () => Promise; workspaceMcpToolsImpl?: ( serverName: string, @@ -557,6 +559,7 @@ interface FakeBridge extends AcpSessionBridge { context?: BridgeClientRequestContext; }>; listCalls: string[]; + summaryCalls: string[]; workspaceMcpCalls: number; workspaceMcpToolsCalls: string[]; workspaceSkillsCalls: number; @@ -682,6 +685,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const permissionVotes: FakeBridge['permissionVotes'] = []; const sessionPermissionVotes: FakeBridge['sessionPermissionVotes'] = []; const listCalls: string[] = []; + const summaryCalls: string[] = []; let workspaceMcpCalls = 0; const workspaceMcpToolsCalls: string[] = []; let workspaceSkillsCalls = 0; @@ -740,6 +744,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { const respondImpl = opts.respondImpl ?? (() => true); const sessionRespondImpl = opts.sessionRespondImpl ?? (() => true); const listImpl = opts.listImpl ?? (() => []); + const summaryImpl = + opts.summaryImpl ?? + ((sessionId: string): BridgeSessionSummary => { + throw new SessionNotFoundError(sessionId); + }); const workspaceMcpImpl = opts.workspaceMcpImpl ?? (async () => ({ @@ -1076,6 +1085,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { permissionVotes, sessionPermissionVotes, listCalls, + summaryCalls, workspaceMcpToolsCalls, extensionEvents, sessionContextCalls, @@ -1217,6 +1227,10 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { listCalls.push(workspaceCwd); return listImpl(workspaceCwd); }, + getSessionSummary(sessionId) { + summaryCalls.push(sessionId); + return summaryImpl(sessionId); + }, async getWorkspaceMcpStatus() { workspaceMcpCalls += 1; return workspaceMcpImpl(); @@ -6370,6 +6384,74 @@ describe('createServeApp', () => { }); }); + describe('GET /session/:id/status', () => { + it('200 with the live session summary', async () => { + const summary: BridgeSessionSummary = { + sessionId: 's-1', + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + displayName: 'demo', + clientCount: 2, + hasActivePrompt: true, + }; + const bridge = fakeBridge({ summaryImpl: () => summary }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/s-1/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(summary); + expect(bridge.summaryCalls).toEqual(['s-1']); + }); + + it('200 omits displayName when the live session has none', async () => { + const summary: BridgeSessionSummary = { + sessionId: 's-2', + workspaceCwd: WS_BOUND, + createdAt: '2026-05-17T12:00:00.000Z', + clientCount: 0, + hasActivePrompt: false, + }; + const bridge = fakeBridge({ summaryImpl: () => summary }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/s-2/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect('displayName' in res.body).toBe(false); + }); + + it('404 when the session id is unknown to the daemon', async () => { + // fakeBridge's default getSessionSummary throws SessionNotFoundError. + const bridge = fakeBridge(); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/ghost/status') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('ghost'); + expect(bridge.summaryCalls).toEqual(['ghost']); + }); + }); + describe('POST /session/:id/model', () => { it('200 with the agent response on success', async () => { const bridge = fakeBridge({ diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 1c329d52c0..95f1401c54 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1074,6 +1074,7 @@ function advertisedMaxPendingPromptsPerSession( * - `POST /session/:id/load` * - `POST /session/:id/resume` * - `GET /workspace/:id/sessions` + * - `GET /session/:id/status` * - `GET /session/:id/context` * - `GET /session/:id/supported-commands` * - `GET /session/:id/tasks` @@ -3323,6 +3324,19 @@ export function createServeApp( } }); + app.get('/session/:id/status', (req, res) => { + const sessionId = requireSessionId(req, res); + if (sessionId === null) return; + try { + res.status(200).json(bridge.getSessionSummary(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/status', + sessionId, + }); + } + }); + app.get('/session/:id/context', async (req, res) => { const sessionId = requireSessionId(req, res); if (sessionId === null) return;