Skip to content
17 changes: 16 additions & 1 deletion docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,24 @@ 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 status summary for a single
session — the same item shape that `GET /workspace/:id/sessions` lists

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The docs state this returns "the same item shape that GET /workspace/:id/sessions lists", but the list endpoint enriches updatedAt from SessionService persisted data (file mtime) via listWorkspaceSessionsForResponse(). The new route calls bridge.getSessionSummary() directly, which never populates updatedAt — SessionEntry has no such field.

Clients relying on this "same shape" contract will see updatedAt missing from the single-item response while it is always present in the list response.

Either update the docs to describe the subset (e.g., "a live-only summary with the same core fields"), or enrich the response in the route handler to match the list endpoint:

app.get('/session/:id/status', async (req, res) => {
  const sessionId = requireSessionId(req, res);
  if (sessionId === null) return;
  try {
    const live = bridge.getSessionSummary(sessionId);
    const existing = await sessionService.getSession(sessionId);
    res.status(200).json({
      ...live,
      updatedAt: existing?.mtime
        ? new Date(existing.mtime).toISOString()
        : undefined,
    });
  } catch (err) {
    sendBridgeError(res, err, { route: 'GET /session/:id/status', sessionId });
  }
});

— qwen3.7-max via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e029794 — reworded to describe the live bridge summary and spelled out that the list endpoint enriches createdAt (persisted first-prompt time), adds updatedAt, and derives displayName, whereas /status returns the live createdAt, omits updatedAt, and only returns displayName when set. No longer claims byte-parity.

(`sessionId`, `workspaceCwd`, `createdAt`, `displayName?`, `clientCount`,
`hasActivePrompt`). It answers `200` with the summary when the daemon holds a
live session with that id, and `404 session_not_found` otherwise. Use it to

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This says unknown sessions return 404 session_not_found, but the shared SessionNotFoundError mapper currently responds with only { error, sessionId }; it does not include a code field. A client following this doc could key off a non-existent error code and mishandle the main negative response for this route.

Please either document the actual response shape or intentionally add and test a code: 'session_not_found' mapping for this route contract.

— GPT-5 Codex via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in e029794 — the docs now describe the actual { error, sessionId } body (no code field), matching the shared SessionNotFoundError mapper used by every other session route.

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

`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.
Expand Down
33 changes: 33 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5233,6 +5233,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() {
Expand Down
23 changes: 15 additions & 8 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -3548,19 +3556,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);
Expand Down
9 changes: 9 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,6 +390,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`
Expand Down
58 changes: 58 additions & 0 deletions packages/cli/src/serve/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@ interface FakeBridgeOpts {
context?: BridgeClientRequestContext,
) => boolean;
listImpl?: (workspaceCwd: string) => BridgeSessionSummary[];
summaryImpl?: (sessionId: string) => BridgeSessionSummary;
workspaceMcpImpl?: () => Promise<ServeWorkspaceMcpStatus>;
workspaceMcpToolsImpl?: (
serverName: string,
Expand Down Expand Up @@ -557,6 +558,7 @@ interface FakeBridge extends AcpSessionBridge {
context?: BridgeClientRequestContext;
}>;
listCalls: string[];
summaryCalls: string[];
workspaceMcpCalls: number;
workspaceMcpToolsCalls: string[];
workspaceSkillsCalls: number;
Expand Down Expand Up @@ -682,6 +684,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;
Expand Down Expand Up @@ -738,6 +741,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 () => ({
Expand Down Expand Up @@ -1074,6 +1082,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge {
permissionVotes,
sessionPermissionVotes,
listCalls,
summaryCalls,
workspaceMcpToolsCalls,
extensionEvents,
sessionContextCalls,
Expand Down Expand Up @@ -1215,6 +1224,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();
Expand Down Expand Up @@ -6367,6 +6380,51 @@ describe('createServeApp', () => {
});
});

describe('GET /session/:id/status', () => {
it('200 with the live session summary', async () => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This 200 test only covers the displayName-present case. The PR documents a specific /status contract — "returns displayName only when one is set on the live session" (docs/users/qwen-serve.md) — but nothing asserts the key is absent from the HTTP body when unset.

That omission relies entirely on res.json() dropping the undefined-valued key that toSessionSummary always sets (bridge.ts: displayName: entry.displayName). A future "consistency" change to that helper (e.g. entry.displayName ?? '') would silently break the documented shape with the whole suite still green. A sibling test pins the contract:

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);
});

— claude-opus-4-8[1m] via Qwen Code /qreview

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added your suggested test in 842d722 — a 200 case with a no-displayName summary asserting 'displayName' in res.body === false, so a future change to the shared summary builder can't silently break the documented shape. Thanks!

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('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({
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -3323,6 +3324,19 @@ export function createServeApp(
}
});

app.get('/session/:id/status', (req, res) => {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This adds a new public route, but the route side tables are not updated with it. /capabilities.features still has no tag for this endpoint, so clients cannot feature-detect it the way they do for neighboring session routes such as session_context, session_tasks, session_stats, and session_lsp. resolveDaemonTelemetryRoute also has no GET /session/:id/status matcher, so polling traffic and failures for this endpoint will not get normalized telemetry attribution.

Please add an always-on capability tag such as session_status, update the expected feature/protocol docs, and add a telemetry matcher for GET /session/:id/status that carries the session id.

— GPT-5 Codex via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added the always-on session_status capability tag in e4104ef — registry, registered-features test, and the protocol feature list / capability→route map / capability-versioning reference. On the telemetry matcher: I left resolveDaemonTelemetryRoute alone for sibling-consistency — none of the GET status routes (context/stats/tasks/lsp/supported-commands) are in it; adding only /status would make it the lone read-only GET route with telemetry attribution. Happy to add matchers for all of them as a separate change if you'd prefer.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This returns raw bridge data while GET /workspace/:id/sessions enriches with persisted fields (createdAt from item.startTime, updatedAt from item.mtime, displayName from item.customTitle || item.prompt). This divergence is well-documented but no test pins it. A future refactor could silently harmonize or break the divergence with no test catching it.

Suggested fix: Add a server-level test that sets up a session with persisted metadata and asserts the two endpoints return different createdAt / displayName values for the same session.

— qwen3.7-max via Qwen Code /review

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;
Expand Down
Loading