diff --git a/docs/design/session-recap/session-recap-design.md b/docs/design/session-recap/session-recap-design.md index af93fcc813e..03fdce7b653 100644 --- a/docs/design/session-recap/session-recap-design.md +++ b/docs/design/session-recap/session-recap-design.md @@ -21,16 +21,42 @@ returns: ## Triggers -| Trigger | Conditions | Implementation | -| ---------- | -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | -| **Manual** | User runs `/recap` | `recapCommand.ts` calls the same underlying service | -| **Auto** | Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is `Idle` | `useAwaySummary.ts` — 5min blur timer + `useFocus` event listener | - -Both paths funnel into a single function — `generateSessionRecap()` — to -guarantee identical behavior. The auto-trigger is gated by -`general.showSessionRecap` (default: off — explicit opt-in, so ambient -LLM calls are never silently added to a user's bill); the manual -command ignores that setting. +| Trigger | Conditions | Implementation | +| --------------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Manual** | User runs `/recap` | `recapCommand.ts` calls the same underlying service | +| **Auto** | Terminal blurred (DECSET 1004 focus protocol) for ≥ 5 min + focus returns + stream is `Idle` | `useAwaySummary.ts` — 5min blur timer + `useFocus` event listener | +| **Daemon HTTP** | Remote client calls `POST /session/:id/recap` | `server.ts` route → `bridge.generateSessionRecap` (ext-method roundtrip) → `acpAgent.ts` calls `generateSessionRecap(session.getConfig(), signal)` | + +All three paths funnel into the same `generateSessionRecap()` function +in `core/services/sessionRecap.ts` to guarantee identical behavior. The +auto-trigger is gated by `general.showSessionRecap` (default: off — +explicit opt-in, so ambient LLM calls are never silently added to a +user's bill); the manual command and daemon HTTP route ignore that +setting (the caller is making an explicit request). + +### Daemon access path + +The daemon route is non-strict-gated (mirrors `/session/:id/prompt`'s +posture — recap costs tokens but mutates no state). Capability tag +`session_recap` advertises the route on `/capabilities.features`. SDK +helpers: `DaemonClient.recapSession(sessionId, opts)` and +`DaemonSessionClient.recap(opts)`. See +`docs/developers/qwen-serve-protocol.md` § `POST /session/:id/recap` +for the wire contract and error envelope. + +Cancellation is **absent in v1**. The route does not listen for HTTP +client disconnect, no `AbortSignal` is threaded into +`bridge.generateSessionRecap`, and the ACP child handler passes a +never-aborting `AbortController().signal` to the core helper (no +cross-process abort plumbing yet). The only ceilings are the bridge's +60s `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-closed race +against ACP channel death. Wiring an HTTP-side AbortController in +isolation would be cosmetic — the child-side LLM call would still run +to completion, so e2e cancel is not achievable without the cross- +process abort piece. This is acceptable for v1 because recap is short +(single-attempt side-query, `maxOutputTokens: 300`, ~1–5s typical). +A future request-id-based cancel ext-method can plumb full end-to-end +cancellation if/when the bandwidth cost justifies it. ## Architecture diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index dc5b96066d5..97b202371ab 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -1107,6 +1107,36 @@ Response: On success, publishes `model_switched` to the SSE stream. On failure, publishes `model_switch_failed` (so passive subscribers see the failure, not just the caller). Races against the agent channel exit so a wedged child can't block the HTTP handler. +### `POST /session/:id/recap` + +Capability tag: `session_recap`. Bridge → ACP extMethod `qwen/control/session/recap`. + +Generate a one-sentence "where did I leave off" summary of the session. Wraps core's `generateSessionRecap` (`packages/core/src/services/sessionRecap.ts`), which runs a side-query against the fast model with tools disabled, `maxOutputTokens: 300`, and a strict `...` output format. The side-query reads the session's existing GeminiClient chat history and does **not** add to it. + +Request body is ignored (send `{}` or empty). Non-strict mutation gate — posture mirrors `/session/:id/prompt` (the call costs tokens but mutates no state). No SSE event is published. + +Response (200): + +```json +{ + "sessionId": "sess:42", + "recap": "Debugging the auth retry race. Next: add deterministic timing to the integration test." +} +``` + +`recap` is `null` (a normal 200, not an error) when: + +- the session has fewer than two dialog turns yet, +- the side-query returned no extractable `...` payload, +- or any underlying model error occurred (the core helper is best-effort and never throws). + +Errors: + +- `400 {code: 'invalid_client_id'}` — malformed `X-Qwen-Client-Id` header. +- `404` — session unknown. + +Cancellation: **none in v1**. The route does not listen for HTTP client disconnect, no `AbortSignal` is plumbed into the bridge, and the ACP child runs the side-query to completion regardless of whether the caller has disconnected. The only ceilings are the bridge's 60s backstop timeout (`SESSION_RECAP_TIMEOUT_MS`) and the transport-closed race against ACP channel death. This is acceptable because recap is short (single-attempt, `maxOutputTokens: 300`, ~1–5s typical); a request-id-based cancel ext-method can plumb full end-to-end cancellation in a future release if the bandwidth cost ever justifies it. + ### Mutation: approval, tools, init, MCP restart Issue [#4175](https://github.com/QwenLM/qwen-code/issues/4175) Wave 4 PR 17 adds four mutation control routes that let remote clients change runtime posture without touching the daemon host's CLI. All four: diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 0bc91349302..92f76e07b40 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -15,6 +15,9 @@ Run Qwen Code as a local HTTP daemon so multiple clients (IDE plugins, web UIs, - **First-responder permissions** — when the agent asks for permission to run a tool, every connected client sees the request; whichever client answers first wins. - **One daemon, one workspace** — each `qwen serve` process binds to exactly one workspace at boot (per [#3803](https://github.com/QwenLM/qwen-code/issues/3803) §02). Multi-workspace deployments run one daemon per workspace on separate ports (or behind an orchestrator). - **Remote runtime control** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) PR 17) — change a session's approval mode (`POST /session/:id/approval-mode`), toggle a tool per workspace (`POST /workspace/tools/:name/enable`), scaffold an empty `QWEN.md` (`POST /workspace/init`, mechanical only — does NOT call the model; for AI-fill, follow up with `POST /session/:id/prompt`), or restart a single MCP server with a budget pre-check (`POST /workspace/mcp/:server/restart`). All four are strict-gated — configure `--token` first. +- **Session recap** ([#4175](https://github.com/QwenLM/qwen-code/issues/4175) follow-up) — fetch a one-sentence "where did I leave off" summary of an active session (`POST /session/:id/recap`). Wraps core's `generateSessionRecap` as a side-query against the fast model; pollutes neither the main chat history nor the SSE stream. Non-strict gate (same posture as `/prompt`); SDK helper `client.recapSession(sessionId)`. + - **Known limit — token-cost amplification:** the route is a pure-cost endpoint (each call is an LLM side-query, no state benefit) and the daemon has no per-route rate limit in v1. On a no-token loopback default a buggy or malicious local client can spam it to burn tokens. Configure `--token` (and optionally `--require-auth`) on shared dev hosts before exposing the daemon. + - **Concurrent recap safety:** two simultaneous `/recap` calls on the same session run two independent side-queries. `generateSessionRecap` reads a snapshot of the chat history via `GeminiClient.getChat().getHistory()` and feeds it to a separate `BaseLlmClient.generateText` call (via `runSideQuery`); it never appends to or mutates the session's `GeminiChat`. Safe to call from multiple clients without coordination. ## v0.16-alpha known limits diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 4388510e583..e584c2d5da1 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4306,6 +4306,82 @@ describe('createHttpAcpBridge', () => { }); }); + describe('generateSessionRecap (#4175 follow-up)', () => { + function recapFactory( + respond: ( + params: Record, + ) => Record | Promise>, + ): ChannelFactory { + return async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: (method, params) => { + if (method === 'qwen/control/session/recap') { + return Promise.resolve(respond(params)); + } + return Promise.resolve({}); + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + } + + it('forwards through the ACP child and returns the recap verbatim', async () => { + const recapText = + 'Refactoring the auth middleware. Next: regenerate the integration fixtures.'; + let observedParams: Record | undefined; + const bridge = makeBridge({ + channelFactory: recapFactory((params) => { + observedParams = params; + return { sessionId: params['sessionId'], recap: recapText }; + }), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const result = await bridge.generateSessionRecap(session.sessionId); + expect(result).toEqual({ + sessionId: session.sessionId, + recap: recapText, + }); + expect(observedParams).toEqual({ sessionId: session.sessionId }); + await bridge.shutdown(); + }); + + it('preserves a null recap (best-effort failure surface)', async () => { + const bridge = makeBridge({ + channelFactory: recapFactory((params) => ({ + sessionId: params['sessionId'], + recap: null, + })), + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const result = await bridge.generateSessionRecap(session.sessionId); + expect(result.recap).toBeNull(); + await bridge.shutdown(); + }); + + it('throws SessionNotFoundError for unknown sessionId', async () => { + const bridge = makeBridge({ + channelFactory: recapFactory(() => ({ + sessionId: 'never', + recap: null, + })), + }); + await expect( + bridge.generateSessionRecap('does-not-exist'), + ).rejects.toBeInstanceOf(SessionNotFoundError); + await bridge.shutdown(); + }); + }); + describe('setWorkspaceToolEnabled (#4175 Wave 4 PR 17)', () => { it('throws when no persistDisabledTools callback is wired', async () => { const bridge = makeBridge(); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index e056645c89a..f8664fb18cb 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -320,6 +320,16 @@ const DEFAULT_INIT_TIMEOUT_MS = 10_000; * as long as the slowest legitimate per-server discovery. */ const MCP_RESTART_TIMEOUT_MS = 300_000; +/** + * Backstop timeout for `qwen/control/session/recap`. The underlying + * side-query is single-attempt with `maxOutputTokens: 300`, so a + * healthy call finishes in 1–5 seconds; we cap at 60s to absorb model- + * provider hiccups without inheriting the 10s `initTimeoutMs` default + * (which would false-fire on any GPT-style slow start). The race is a + * safety net against a wedged ACP channel — there is no HTTP-side + * disconnect cancellation in v1 (see server.ts route comment). + */ +const SESSION_RECAP_TIMEOUT_MS = 60_000; const DEFAULT_MAX_SESSIONS = 20; /** * Soft upper bound on `BridgeOptions.eventRingSize` to catch operator @@ -2858,6 +2868,40 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { }; }, + async generateSessionRecap(sessionId, _context) { + // #4175 follow-up. Thin pass-through to `qwen/control/session/ + // recap` — the ACP child runs `generateSessionRecap` against the + // session's GeminiClient history and returns `{sessionId, recap}` + // where `recap` may be `null` for too-short histories or transient + // model failures. The core helper is documented to never throw, + // so the only paths that surface as bridge errors are: unknown + // sessionId (`SessionNotFoundError`), transport closed mid-flight + // (race against `getTransportClosedReject`), and the backstop + // `SESSION_RECAP_TIMEOUT_MS` race for a wedged ACP channel. + // + // `_context` carries the trusted client id for future event + // fan-out (e.g. a `session_recap_generated` push event), but + // recap is informational-only today — no SSE broadcast. + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const info = channelInfoForEntry(entry); + if (!info || info.isDying) throw new SessionNotFoundError(sessionId); + const response = (await Promise.race([ + withTimeout( + entry.connection.extMethod(SERVE_CONTROL_EXT_METHODS.sessionRecap, { + sessionId, + }), + SESSION_RECAP_TIMEOUT_MS, + SERVE_CONTROL_EXT_METHODS.sessionRecap, + ), + getTransportClosedReject(entry), + ])) as { sessionId: string; recap: string | null }; + return { + sessionId: entry.sessionId, + recap: response.recap ?? null, + }; + }, + async setWorkspaceToolEnabled(toolName, enabled, originatorClientId) { // #4175 Wave 4 PR 17. Pure file IO + event fan-out — no ACP // roundtrip. The settings file is the source of truth; live diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 7c6a027504c..4f89cbdf1d7 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -326,6 +326,22 @@ export interface HttpAcpBridge { persisted: boolean; }>; + /** + * Generate a one-sentence "where did I leave off" recap of a live + * session. Forwards through `qwen/control/session/recap`, which + * invokes `generateSessionRecap` (`core/services/sessionRecap.ts`) in + * the ACP child against the per-session chat history. + * + * Best-effort: the helper returns `null` when history is too short or + * the underlying side-query fails — both surface as a 200 response + * with `recap: null`. Hard errors (unknown session, ACP transport + * down) throw as usual. + */ + generateSessionRecap( + sessionId: string, + context?: BridgeClientRequestContext, + ): Promise<{ sessionId: string; recap: string | null }>; + /** * Add or remove a tool name from the workspace's `tools.disabled` * settings list and fan-out a `tool_toggled` event to every live diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index e61233e7029..7c70c178fd5 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -107,6 +107,7 @@ export const SERVE_STATUS_EXT_METHODS = { export const SERVE_CONTROL_EXT_METHODS = { sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', + sessionRecap: 'qwen/control/session/recap', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', } as const; diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 7dc3cb5de18..2e8e23be617 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -10,6 +10,7 @@ import { AuthType, clearCachedCredentialFile, createDebugLogger, + generateSessionRecap, QwenOAuth2Event, qwenOAuth2Events, MCP_BUDGET_WARN_FRACTION, @@ -2243,6 +2244,38 @@ class QwenAgent implements Agent { const current = config.getApprovalMode(); return { previous, current }; } + case SERVE_CONTROL_EXT_METHODS.sessionRecap: { + // #4175 follow-up. Generate a one-sentence "where did I leave + // off" summary by running `generateSessionRecap` against the + // session's GeminiClient history. Best-effort: the core helper + // is documented to return `null` on any failure (short history, + // transient model error, etc.) and never throws — we surface + // that null verbatim so the daemon route returns a 200 with + // `recap: null` rather than a 5xx. + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + // v1: no cross-process abort plumbing. The bridge does not listen + // for HTTP client disconnect and no AbortSignal is threaded through + // the ext-method, so the LLM call in this child always runs to + // completion. The only ceilings are the bridge's 60s + // `SESSION_RECAP_TIMEOUT_MS` backstop and the transport-closed race + // against ACP channel death. Acceptable because recap is short + // (single-attempt side-query, `maxOutputTokens: 300`). A future + // request-id-based cancel ext-method can plumb a real signal + // end-to-end if the bandwidth cost ever becomes an issue. + const recap = await generateSessionRecap( + config, + new AbortController().signal, + ); + return { sessionId, recap }; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 5fe5cd3293c..09827d634f3 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -162,6 +162,15 @@ export const SERVE_CAPABILITY_REGISTRY = { // `'in_flight'` (concurrent discovery in progress), `'disabled'` // (server is configured but explicitly disabled). workspace_mcp_restart: { since: 'v1' }, + // #4175 follow-up. Daemon hosts `POST /session/:id/recap`, which + // generates a one-sentence "where did I leave off" summary by + // running `generateSessionRecap` (`core/services/sessionRecap.ts`) as + // a side-query against the fast model. Non-strict mutation gate — + // posture mirrors `/session/:id/prompt` (token cost, not state + // mutation). The route returns `{sessionId, recap}` where `recap` + // may be `null` for too-short histories or transient model failures + // (best-effort, never throws). SDK helper: `DaemonClient.recapSession`. + session_recap: { since: 'v1' }, // F2 (#4175 commit 5). Daemon hosts a workspace-shared MCP transport // pool (`QwenAgent.mcpPool`); `GET /workspace/mcp` reflects pool-level // accounting (`entryCount`, `entrySummary` on each per-server cell). diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 1c7cea28fe0..3f0066fa212 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -134,6 +134,9 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_tool_toggle', 'workspace_init', 'workspace_mcp_restart', + // #4175 follow-up. Daemon hosts `POST /session/:id/recap` (wraps + // core's `generateSessionRecap` for one-sentence session summaries). + 'session_recap', // Issue #4175 PR 21 — auth device-flow surface advertised unconditionally. // Registry order on origin/main has PR 21 appended last, so the // baseline assertion below mirrors that even though PR 21 landed @@ -248,6 +251,10 @@ interface FakeBridgeOpts { previous: ApprovalMode; persisted: boolean; }>; + generateSessionRecapImpl?: ( + sessionId: string, + context?: BridgeClientRequestContext, + ) => Promise<{ sessionId: string; recap: string | null }>; setToolEnabledImpl?: ( toolName: string, enabled: boolean, @@ -336,6 +343,10 @@ interface FakeBridge extends HttpAcpBridge { opts: { persist: boolean }; context?: BridgeClientRequestContext; }>; + generateSessionRecapCalls: Array<{ + sessionId: string; + context?: BridgeClientRequestContext; + }>; setToolEnabledCalls: Array<{ toolName: string; enabled: boolean; @@ -499,6 +510,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { previous: ApprovalMode.DEFAULT, persisted: o.persist, })); + const generateSessionRecapCalls: FakeBridge['generateSessionRecapCalls'] = []; + const generateSessionRecapImpl = + opts.generateSessionRecapImpl ?? + (async (sessionId: string) => ({ + sessionId, + recap: 'Default fake recap.', + })); const setToolEnabledCalls: FakeBridge['setToolEnabledCalls'] = []; const setToolEnabledImpl = opts.setToolEnabledImpl ?? @@ -562,6 +580,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionSupportedCommandsCalls, setModelCalls, setApprovalModeCalls, + generateSessionRecapCalls, setToolEnabledCalls, initWorkspaceCalls, restartMcpServerCalls, @@ -697,6 +716,13 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return setApprovalModeImpl(sessionId, mode, o, context); }, + async generateSessionRecap(sessionId, context) { + generateSessionRecapCalls.push({ + sessionId, + ...(context ? { context } : {}), + }); + return generateSessionRecapImpl(sessionId, context); + }, async setWorkspaceToolEnabled(toolName, enabled, originatorClientId) { setToolEnabledCalls.push({ toolName, @@ -2353,6 +2379,107 @@ describe('createServeApp', () => { }); }); + describe('POST /session/:id/recap (#4175 follow-up)', () => { + it('200 with the recap on success and forwards no body', async () => { + const bridge = fakeBridge({ + generateSessionRecapImpl: async (sessionId) => ({ + sessionId, + recap: + 'Refactoring the auth retry. Next: regenerate the snapshot tests.', + }), + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); + expect(res.status).toBe(200); + expect(res.body).toEqual({ + sessionId: 'session-A', + recap: + 'Refactoring the auth retry. Next: regenerate the snapshot tests.', + }); + expect(bridge.generateSessionRecapCalls).toHaveLength(1); + expect(bridge.generateSessionRecapCalls[0]?.sessionId).toBe('session-A'); + }); + + it('200 with recap:null is a valid best-effort response', async () => { + // The core helper `generateSessionRecap` is documented to return + // `null` when history is too short or the side-query fails. That + // must surface as a normal 200 — a 5xx here would force daemon + // clients to special-case "we have no recap yet" as an error. + const bridge = fakeBridge({ + generateSessionRecapImpl: async (sessionId) => ({ + sessionId, + recap: null, + }), + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); + expect(res.status).toBe(200); + expect(res.body.recap).toBeNull(); + }); + + it('passes client identity context into the bridge', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + await request(app) + .post('/session/session-A/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('X-Qwen-Client-Id', 'client-1') + .send(); + expect(bridge.generateSessionRecapCalls[0]?.context).toEqual({ + clientId: 'client-1', + }); + }); + + it('404 when bridge throws SessionNotFoundError', async () => { + const bridge = fakeBridge({ + generateSessionRecapImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, + }); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/missing/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); + expect(res.status).toBe(404); + expect(res.body.sessionId).toBe('missing'); + }); + + it('400 on malformed X-Qwen-Client-Id header', async () => { + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .set('X-Qwen-Client-Id', 'bad client id with spaces') + .send(); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + expect(bridge.generateSessionRecapCalls).toHaveLength(0); + }); + + it('non-strict gate: works on no-token loopback default', async () => { + // Posture mirrors /session/:id/prompt — the route costs tokens + // but mutates no state, so it should NOT require operators to + // configure a token. This pins the contract so a future cleanup + // that mass-flips session-scoped routes to strict catches the + // regression. + const bridge = fakeBridge(); + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/recap') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); + expect(res.status).toBe(200); + }); + }); + describe('POST /session/:id/approval-mode (#4175 Wave 4 PR 17)', () => { // Strict-gated route: refuses on no-token loopback defaults. All // tests configure a token and forward `Authorization: Bearer …`. diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 79d0776d0f0..1e74ec3c2fd 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -1470,6 +1470,51 @@ export function createServeApp( } }); + app.post('/session/:id/recap', mutate(), async (req, res) => { + // #4175 follow-up. Wraps `generateSessionRecap` (core/services/ + // sessionRecap.ts) so daemon clients can fetch a one-sentence + // "where did I leave off" summary without driving the agent through + // a full prompt turn. Non-strict gate (token cost, not state + // mutation), matching `/session/:id/prompt`'s posture. + // + // v1 cancellation: NONE on the route side. There is intentionally no + // `res.once('close')` listener and no `AbortSignal` plumbed into + // `bridge.generateSessionRecap`. The only ceilings are the bridge's + // 60s `SESSION_RECAP_TIMEOUT_MS` backstop and the + // `getTransportClosedReject` race against ACP transport death. This + // matches the ACP child's `acpAgent.ts` handler, which also passes + // a never-aborting `AbortController().signal` to the core helper + // because there is no cross-process abort plumbing yet. Wiring an + // HTTP-side AbortController would be cosmetic — the child-side LLM + // call would still run to completion, so e2e cancel is not + // achievable in v1. Recap is short (single-attempt side-query, + // ~1–5s typical, `maxOutputTokens: 300`), so this is acceptable. + // + // Best-effort — `recap: null` on short history or transient model + // failure is a normal 200, not an error. + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + try { + const response = await bridge.generateSessionRecap( + sessionId, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(response); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/recap', + sessionId, + }); + } + }); + app.post( '/session/:id/approval-mode', mutate({ strict: true }), diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index fcc55f3361f..26c5d441c52 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -47,6 +47,7 @@ import type { DaemonApprovalModeResult, DaemonInitWorkspaceResult, DaemonMcpRestartResult, + DaemonSessionRecapResult, DaemonToolToggleResult, } from './types.js'; @@ -975,6 +976,52 @@ export class DaemonClient { ); } + /** + * #4175 follow-up. Generate a one-sentence "where did I leave off" + * recap of the session. Wraps `generateSessionRecap` (core/services/ + * sessionRecap.ts) via an ACP control-channel ext-method, so the + * summary is computed against the active GeminiClient chat history + * inside the daemon's ACP child. + * + * Non-strict mutation gate — posture matches `/session/:id/prompt` + * (the route costs tokens but mutates no state). Calls `_fetch` + * directly without the per-call `fetchTimeoutMs` wrapper because the + * underlying side-query can take longer than the default 30s under + * a slow model. Older daemons (pre-recap support) return 404 — + * pre-flight `caps.features.session_recap` before calling. + * + * Cancellation: the optional `signal` aborts only the LOCAL HTTP + * fetch. It does NOT propagate to the daemon — the bridge-side wait + * continues until the 60s `SESSION_RECAP_TIMEOUT_MS` backstop, and + * the side-query inside the ACP child always runs to completion (no + * cross-process abort plumbing in v1). A future request-id-based + * cancel ext-method will plumb a real signal end-to-end if/when the + * bandwidth cost justifies it. + * + * `recap` may be `null` on too-short histories or transient model + * failures (a 200 response with `recap: null`), per the best-effort + * contract of the core helper. + */ + async recapSession( + sessionId: string, + opts?: { signal?: AbortSignal; clientId?: string }, + ): Promise { + const res = await this._fetch( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/recap`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts?.clientId, + ), + body: '{}', + signal: opts?.signal, + }, + ); + if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/recap'); + return (await res.json()) as DaemonSessionRecapResult; + } + /** * #4175 Wave 4 PR 17. Toggle a tool name in the workspace's * `tools.disabled` settings list. Strict-gated mutation route — the diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index cae2ca111bb..4e8a2374e5c 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -14,6 +14,7 @@ import { import type { DaemonEvent, DaemonSessionContextStatus, + DaemonSessionRecapResult, DaemonSessionState, DaemonSession, DaemonSessionSupportedCommandsStatus, @@ -218,6 +219,23 @@ export class DaemonSessionClient { ); } + /** + * One-sentence "where did I leave off" recap of this session. See + * `DaemonClient.recapSession` for the full contract: best-effort + * (may return `recap: null`); the optional `signal` aborts only the + * local HTTP fetch — the daemon-side wait + the LLM call in the ACP + * child both run to completion regardless (no cross-process abort + * plumbing in v1). + */ + async recap(opts?: { + signal?: AbortSignal; + }): Promise { + return await this.client.recapSession(this.sessionId, { + ...(opts?.signal ? { signal: opts.signal } : {}), + ...(this.clientId ? { clientId: this.clientId } : {}), + }); + } + async context(): Promise { return await this.client.sessionContext(this.sessionId, this.clientId); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 3474b34f2f5..7397d933298 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -235,6 +235,7 @@ export type { DaemonApprovalModeResult, DaemonInitWorkspaceResult, DaemonMcpRestartResult, + DaemonSessionRecapResult, DaemonToolToggleResult, DaemonAvailableCommand, DaemonCapabilities, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 45c733ea866..caccc949890 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -761,6 +761,26 @@ export interface DaemonInitWorkspaceResult { action: 'created' | 'overwrote' | 'noop'; } +/** + * #4175 follow-up. Returned from `POST /session/:id/recap`. The recap + * is a one-sentence "where did I leave off" summary generated by core's + * `generateSessionRecap` via a side-query against the fast model. + * + * `recap` is `null` (not absent, not an empty string) when: + * - the session has fewer than two dialog turns yet, + * - the side-query returns no extractable `...` payload, + * - or any underlying model error occurred (the core helper is + * best-effort and never throws). + * + * The route returns 200 in all three cases; only hard errors (unknown + * session, ACP transport down, bridge timeout) surface as non-2xx. + * Pre-flight `caps.features.session_recap` before calling. + */ +export interface DaemonSessionRecapResult { + sessionId: string; + recap: string | null; +} + /** * #4175 Wave 4 PR 17. Result body of `POST /workspace/mcp/:server/ * restart`. Discriminated by `restarted`: `true` carries the wall- diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index e85017db67f..8007f99bf5d 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -29,6 +29,7 @@ export { type DaemonApprovalModeResult, type DaemonInitWorkspaceResult, type DaemonMcpRestartResult, + type DaemonSessionRecapResult, type DaemonMcpServerRestartedData, type DaemonMcpServerRestartedEvent, type DaemonMcpServerRestartRefusedData, diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index d0ae6003c7c..3e4b87f04fe 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -53,6 +53,7 @@ interface CapturedRequest { method: string; headers: Record; body: string | null; + signal?: AbortSignal | null; } function recordingFetch( @@ -74,7 +75,13 @@ function recordingFetch( h.forEach((v, k) => (headers[k.toLowerCase()] = v)); } const body = typeof init?.body === 'string' ? init.body : null; - const captured: CapturedRequest = { url, method, headers, body }; + const captured: CapturedRequest = { + url, + method, + headers, + body, + signal: init?.signal ?? null, + }; calls.push(captured); return reply(captured); }, @@ -1343,6 +1350,77 @@ describe('DaemonClient', () => { }); }); + describe('recapSession (#4175 follow-up)', () => { + it('POSTs an empty body and returns the typed recap', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessionId: 's-1', + recap: + 'Debugging the auth retry race. Next: add deterministic timing.', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const result = await client.recapSession('s-1'); + expect(result).toEqual({ + sessionId: 's-1', + recap: 'Debugging the auth retry race. Next: add deterministic timing.', + }); + expect(calls[0]?.url).toBe('http://daemon/session/s-1/recap'); + expect(calls[0]?.method).toBe('POST'); + expect(calls[0]?.body).toBe('{}'); + }); + + it('returns recap:null verbatim when the daemon reports best-effort failure', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(200, { sessionId: 's-1', recap: null }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const result = await client.recapSession('s-1'); + expect(result.recap).toBeNull(); + }); + + it('URL-encodes the session id', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { sessionId: 's/1', recap: 'x' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await client.recapSession('s/1'); + expect(calls[0]?.url).toBe('http://daemon/session/s%2F1/recap'); + }); + + it('forwards X-Qwen-Client-Id when supplied', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { sessionId: 's-1', recap: 'x' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await client.recapSession('s-1', { clientId: 'client-1' }); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + }); + + it('forwards the AbortSignal so callers can cancel mid-flight', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { sessionId: 's-1', recap: 'x' }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const ctrl = new AbortController(); + await client.recapSession('s-1', { signal: ctrl.signal }); + expect(calls[0]?.signal).toBe(ctrl.signal); + }); + + it('throws on 404 when session is unknown', async () => { + const { fetch } = recordingFetch(() => + jsonResponse(404, { + error: 'session not found', + code: 'session_not_found', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + await expect(client.recapSession('s-1')).rejects.toMatchObject({ + status: 404, + }); + }); + }); + describe('setWorkspaceToolEnabled (#4175 Wave 4 PR 17)', () => { it('POSTs the enabled flag and URL-encodes the tool name', async () => { const { fetch, calls } = recordingFetch(() => diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 1943d62eb61..86059cb0d1f 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -357,6 +357,35 @@ describe('DaemonSessionClient', () => { expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); }); + it('forwards recap through DaemonClient with the bound clientId and signal', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessionId: 's-1', + recap: 'Refactoring the auth flow. Next: run the integration tests.', + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + clientId: 'client-1', + }, + }); + const ctrl = new AbortController(); + const result = await session.recap({ signal: ctrl.signal }); + expect(result).toEqual({ + sessionId: 's-1', + recap: 'Refactoring the auth flow. Next: run the integration tests.', + }); + expect(calls[0]?.url).toBe('http://daemon/session/s-1/recap'); + expect(calls[0]?.method).toBe('POST'); + expect(calls[0]?.headers['x-qwen-client-id']).toBe('client-1'); + expect(calls[0]?.signal).toBe(ctrl.signal); + }); + it('forwards session-scoped operations through DaemonClient', async () => { const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/prompt')) { diff --git a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts index 319b9eb2103..46ede5bedb9 100644 --- a/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts +++ b/packages/sdk-typescript/test/unit/daemon-public-surface.test.ts @@ -30,6 +30,7 @@ import type { DaemonSessionDiedData, DaemonSessionDiedEvent, DaemonSessionEvent, + DaemonSessionRecapResult, DaemonSessionUpdateData, DaemonSessionUpdateEvent, DaemonSessionViewState, @@ -106,6 +107,11 @@ describe('public SDK entry — typed daemon event surface (#4217)', () => { expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); expectTypeOf().not.toBeNever(); + // #4175 follow-up: the recap result type lives under the daemon + // sub-barrel and is re-exported at the top-level. Without this + // assertion a future barrel reshuffle could silently drop the + // result type SDK consumers need to type `client.recapSession`. + expectTypeOf().not.toBeNever(); }); it('exposes the PR 21 auth device-flow surface at the public entry', () => {