feat(serve): query a single session's status by id (#5857)

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

* 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) <noreply@anthropic.com>

---------

Co-authored-by: 云胧 <yungsen.hys@alibaba-inc.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
samuelhsin 2026-06-26 01:14:42 +08:00 committed by GitHub
parent 096bba13d5
commit 24edd459ea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 184 additions and 12 deletions

View file

@ -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

View file

@ -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`.

View file

@ -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:

View file

@ -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.

View file

@ -268,6 +268,7 @@ describe('qwen serve — capabilities envelope', () => {
'session_tasks',
'session_stats',
'session_lsp',
'session_status',
'session_close',
'session_metadata',
'mcp_guardrails',

View file

@ -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() {

View file

@ -1008,6 +1008,14 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// daemon. Cleared in the `finally` of the creator.
let inFlightChannelSpawn: Promise<ChannelInfo> | undefined;
const byId = new Map<string, SessionEntry>();
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<string>` 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);

View file

@ -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`

View file

@ -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

View file

@ -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<ServeWorkspaceMcpStatus>;
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({

View file

@ -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;