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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 36 additions & 10 deletions docs/design/session-recap/session-recap-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
30 changes: 30 additions & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<recap>...</recap>` 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 `<recap>...</recap>` 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:
Expand Down
3 changes: 3 additions & 0 deletions docs/users/qwen-serve.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
76 changes: 76 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4306,6 +4306,82 @@ describe('createHttpAcpBridge', () => {
});
});

describe('generateSessionRecap (#4175 follow-up)', () => {
function recapFactory(
respond: (
params: Record<string, unknown>,
) => Record<string, unknown> | Promise<Record<string, unknown>>,
): 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<string, unknown> | 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();
Expand Down
44 changes: 44 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions packages/acp-bridge/src/bridgeTypes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/acp-integration/acpAgent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
AuthType,
clearCachedCredentialFile,
createDebugLogger,
generateSessionRecap,
QwenOAuth2Event,
qwenOAuth2Events,
MCP_BUDGET_WARN_FRACTION,
Expand Down Expand Up @@ -2243,6 +2244,38 @@ class QwenAgent implements Agent {
const current = config.getApprovalMode();
return { previous, current };
}
case SERVE_CONTROL_EXT_METHODS.sessionRecap: {

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 ~30-line sessionRecap ext-method handler has no test coverage. acpAgent.test.ts tests other ext-method handlers (rewindSession, renameSession, workspaceMcp, etc.) but sessionRecap is absent. The uncovered paths include: RequestError.invalidParams on missing/invalid sessionId, sessionOrThrow for unknown sessions, generateSessionRecap(config, signal) return value wrapping, and recap: null pass-through.

Consider adding a describe block mirroring the existing renameSession test pattern to pin the routing, parameter validation, and response shape.

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

Thanks — agreed, will add. Deferring to a follow-up PR to keep this one focused on chiga0's doc-only gate. The follow-up will add a describe('SERVE_CONTROL_EXT_METHODS.sessionRecap') block in acpAgent.test.ts mirroring the renameSession pattern, covering: success path, recap: null pass-through, RequestError.invalidParams on missing/invalid sessionId, SESSION_ID_RE rejection (will also be added — sibling handlers enforce it but recap currently doesn't), and sessionOrThrow on unknown session.

// #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)) {
Expand Down
9 changes: 9 additions & 0 deletions packages/cli/src/serve/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
Loading