diff --git a/docs/design/daemon-acp-http/README.md b/docs/design/daemon-acp-http/README.md new file mode 100644 index 00000000000..84f4a05ea8d --- /dev/null +++ b/docs/design/daemon-acp-http/README.md @@ -0,0 +1,568 @@ +# Daemon ACP-over-HTTP → Official ACP Streamable HTTP Transport + +> Targets `daemon_mode_b_main`. Branch: `feat/daemon-acp-http-streamable`. +> Author: arnoo.gao. Date: 2026-05-24. Status: **Design v1 → implementation**. +> Design-first per repo workflow: this doc lands before/with the implementation PR so the wire contract is reviewable. + +--- + +## 0. TL;DR + +The daemon (`qwen serve`) today speaks a **bespoke REST + SSE** dialect to web/SDK +clients, while speaking **real ACP JSON-RPC over stdio** to the spawned `qwen --acp` +child. This proposal adds a **second northbound transport** that implements the +**official ACP Streamable HTTP transport** (RFD #721) at a single `/acp` endpoint, +so any ACP-native client (Zed, Goose, future SDKs) can drive the daemon directly +over the standard protocol — no qwen-specific REST knowledge required. + +**Decision: dual-transport, additive.** The new `/acp` endpoint is mounted +alongside the existing REST surface, reusing the same `HttpAcpBridge` + +`EventBus` underneath. The REST API is *not* removed. Rationale in §6. + +**Decision: extension namespace = `_qwen/…`** (single-underscore prefix, the +ACP-spec-reserved form for custom methods) for daemon features that have no +standard ACP method (model switch, workspace introspection, heartbeat, +multi-client permission policy, SSE backpressure tuning). Rationale in §5. + +A complete, locally-runnable reference implementation ships in this PR +(`packages/cli/src/serve/acpHttp/`) plus a verification harness +(`scripts/acp-http-smoke.mjs`). + +--- + +## 1. Background — what "ACP over HTTP" means today + +Three tiers (verified at commit `0c0430939`): + +``` +┌──────────────┐ bespoke REST + SSE (HTTP/1.1) ┌────────────┐ ACP JSON-RPC ┌──────────────┐ +│ web / SDK │ ───────────────────────────────► │ qwen │ (stdio NDJSON) │ qwen --acp │ +│ client │ ◄─── GET /session/:id/events ──── │ serve │ ◄─────────────► │ child (Agent)│ +│ (ACP client) │ (text/event-stream) │ (daemon) │ ndJsonStream │ │ +└──────────────┘ └────────────┘ └──────────────┘ + northbound: NOT ACP wire bridge southbound: real ACP +``` + +### 1.1 Northbound (client ↔ daemon) — bespoke, today + +- Express 5 app in `packages/cli/src/serve/server.ts` (~30 routes). +- Discrete REST verbs, **not** JSON-RPC: + - `POST /session` (create), `POST /session/:id/prompt`, `POST /session/:id/cancel`, + `POST /session/:id/load|resume`, `POST /session/:id/model`, + `POST /session/:id/permission/:requestId`, `POST /session/:id/heartbeat`, + `DELETE /session/:id`, plus `/workspace/*`, `/capabilities`, `/health`. +- Server→client streaming: `GET /session/:id/events` → `text/event-stream`. + - Frames: `id: \nevent: \ndata: \n\n` (`server.ts:formatSseFrame`, ~2626). + - Per-session **monotonic `id`** + `Last-Event-ID` resume backed by a + ring-buffer `EventBus` (`acp-bridge/src/eventBus.ts`). + - Event `type`s: `session_update`, `client_evicted`, `slow_client_warning`, + `state_resync_required`, `stream_error`, … +- Auth: `Authorization: Bearer ` (`serve/auth.ts`), CORS deny + host allowlist. +- Backpressure: per-connection serialized write chain + 15 s heartbeat comments. + +### 1.2 Southbound (daemon ↔ child) — already ACP + +- `acp-bridge/src/spawnChannel.ts` spawns `qwen --acp`, wraps stdin/stdout with + `ndJsonStream` from `@agentclientprotocol/sdk` (`^0.14.1`). +- `acp-bridge/src/bridge.ts:729` `new ClientSideConnection(() => client, channel.stream)` + — the daemon is the ACP **client**, the child is the ACP **agent**. +- Extension methods already in use on this leg: `unstable_setSessionModel`, + `unstable_resumeSession`, `unstable_listSessions` (`acp-integration/acpAgent.ts`). + +### 1.3 Why migrate the northbound + +- Every client (webui, TS SDK, Java SDK, Python SDK, VSCode companion) re-implements + the bespoke REST mapping. An ACP-standard endpoint lets ACP-native editors attach + with zero qwen-specific glue. +- Aligns the daemon's remote surface with the protocol it already speaks internally. + +--- + +## 2. Target: ACP Streamable HTTP (RFD #721) + +Merged **Draft** RFD (`agentclientprotocol/agent-client-protocol#721`, merged 2026-04-22). +Not yet normative; not yet in any SDK. We implement against the RFD wire design. + +### 2.1 Endpoint & verbs (single `/acp`) + +| Verb | Behavior | +|------|----------| +| `POST /acp` | Send JSON-RPC. `initialize` → **`200`** + JSON body (capabilities) and sets `Acp-Connection-Id`. All other requests/notifications → **`202 Accepted`**, empty body; the *response* (if any) is delivered on the matching long-lived SSE stream. | +| `GET /acp` | Open a long-lived **SSE** stream. (`Upgrade: websocket` → WebSocket; **deferred**, see §7.) | +| `DELETE /acp` | Terminate the connection → `202`. | + +### 2.2 Two-tier long-lived streams + +- **Connection-scoped stream**: `GET /acp` with header `Acp-Connection-Id`, no session + header. Carries connection-level responses (`session/new`, `session/load`, + `authenticate`) and connection-level notifications. +- **Session-scoped stream**: `GET /acp` with `Acp-Connection-Id` **and** `Acp-Session-Id`. + Carries `session/update` notifications, **agent→client requests** + (`session/request_permission`, `fs/read_text_file`, …), and responses to + session POSTs (`session/prompt`, `session/cancel`). + +### 2.3 Identity (3 layers) + +- `Acp-Connection-Id` (HTTP header) — transport binding, minted at `initialize`. +- `Acp-Session-Id` (HTTP header) — required on session-scoped GET + session POSTs. +- `sessionId` (JSON-RPC param) — inside method params (must match the header). + +### 2.4 Divergences from MCP StreamableHTTP + +ACP uses **long-lived** streams (not per-request SSE), **two** ID headers (connection +vs session), `202`-for-non-initialize, HTTP/2-required, WebSocket-required-client. We +borrow the single-endpoint + POST/GET-SSE + session-header skeleton but adapt to the +long-lived dual-ID model. We do **not** reuse `@modelcontextprotocol/sdk`'s +`StreamableHTTPServerTransport` (its per-request stream model and single +`Mcp-Session-Id` don't fit). + +### 2.5 Standard methods (confirmed from current schema) + +- Client→Agent requests: `initialize`, `authenticate`, `session/new`, `session/load`, + `session/prompt`, `session/resume`, `session/close`, `session/list`, + `session/set_mode`, `session/set_config_option`, `logout`. +- Client→Agent notification: `session/cancel`. +- Agent→Client requests: `fs/read_text_file`, `fs/write_text_file`, + `session/request_permission`, `terminal/create|output|wait_for_exit|kill|release`. +- Agent→Client notification: `session/update`. + +--- + +## 3. Architecture of the new transport + +The daemon must present an **ACP Agent surface over HTTP** northbound, while it +remains an ACP **client** to the child southbound. The `/acp` layer is therefore a +**JSON-RPC router** that terminates the HTTP transport and bridges into the existing +`HttpAcpBridge`. + +``` + POST /acp (JSON-RPC requests/responses/notifs) +client ──────────────────────────────────────────────► ┌───────────────────────────┐ +(editor) │ AcpHttpTransport │ + ◄── GET /acp (connection-scoped SSE) ────────── │ - connection registry │ + ◄── GET /acp (session-scoped SSE) ───────────── │ - JSON-RPC id correlation│ + │ - method dispatch │ + └────────────┬──────────────┘ + │ reuses + ┌────────────▼──────────────┐ + │ HttpAcpBridge + EventBus │ (unchanged) + └────────────┬──────────────┘ + │ ACP stdio (unchanged) + qwen --acp child +``` + +### 3.1 New module layout (`packages/cli/src/serve/acpHttp/`) + +| File | Responsibility | +|------|----------------| +| `index.ts` | `mountAcpHttp(app, bridge, opts)` — registers `/acp` routes on the existing Express app. | +| `connectionRegistry.ts` | `Acp-Connection-Id` → `AcpConnection` (connection SSE writer, `Map`, pending agent→client requests by JSON-RPC id, monotonic id allocator). TTL + DELETE cleanup. | +| `jsonRpc.ts` | JSON-RPC 2.0 parse/validate/serialize helpers; error codes (`-32600` etc.); `_qwen/` namespace guard. | +| `dispatch.ts` | Maps inbound JSON-RPC methods → `HttpAcpBridge` calls. Maps `BridgeEvent`s → outbound JSON-RPC frames. The translation table (§4). | +| `sseStream.ts` | Long-lived SSE writer (reuses the backpressure/heartbeat pattern from `server.ts`). Distinct from REST `/events` (different framing: full JSON-RPC objects, not qwen event envelopes). | + +No change to `bridge.ts` / `eventBus.ts` (additive consumer only). + +### 3.2 Connection & session lifecycle + +1. `POST /acp {initialize}` → mint `connectionId`, create `AcpConnection`, reply `200` + with `{protocolVersion, agentCapabilities, _meta:{qwen:{…}}}` + `Acp-Connection-Id` header. +2. Client opens `GET /acp` (connection-scoped) carrying `Acp-Connection-Id`. +3. `POST /acp {session/new}` → `202`; daemon calls `bridge.createSession(...)`; pushes + the JSON-RPC response (with `sessionId`) down the **connection** stream. +4. Client opens `GET /acp` (session-scoped) with `Acp-Connection-Id`+`Acp-Session-Id`; + daemon `bridge.subscribeEvents(sessionId)` and pipes translated frames. +5. `POST /acp {session/prompt}` → `202`; `bridge.sendPrompt(...)`; `session/update` + notifications stream live on the session stream; the final prompt **response** + (`{id, result:{stopReason}}`) is pushed on the session stream when it settles. +6. Agent→client request (e.g. `session/request_permission`) is emitted as a JSON-RPC + **request** on the session stream with a daemon-allocated id; the client answers via + `POST /acp {id, result}`; `dispatch` resolves it through the bridge's permission API. +7. `DELETE /acp` (or connection-stream close + TTL) tears down sessions/subscriptions. + +--- + +## 4. Translation table (bridge ⇄ ACP/HTTP) + +### 4.1 Inbound (client POST → bridge) + +| ACP method | Bridge call | Response routed to | +|------------|-------------|--------------------| +| `initialize` | (none; capabilities from `capabilities.ts`) | inline `200` | +| `authenticate` | existing auth provider (`serve/auth/*`) | connection stream | +| `session/new` | `bridge.createSession` | connection stream | +| `session/load` / `session/resume` | `bridge.restoreSession('load'|'resume')` | connection stream | +| `session/prompt` | `bridge.sendPrompt` | session stream (deferred until settle) | +| `session/cancel` (notif) | `bridge.cancel` | — | +| `session/list` | `bridge.listSessions` (`unstable_listSessions`) | connection stream | +| `session/set_mode` | approval-mode route logic | session stream | +| JSON-RPC **response** (to agent→client req) | resolve pending (`§4.3`) | — | +| `_qwen/session/set_model` | `bridge.setSessionModel` (`unstable_setSessionModel`) | session stream | +| `_qwen/workspace/list` etc. | workspace introspection routes | connection stream | +| `_qwen/session/heartbeat` | `bridge.heartbeat` | connection stream | + +### 4.2 Outbound (BridgeEvent → JSON-RPC on session stream) + +| BridgeEvent.type | Emitted as | +|------------------|-----------| +| `session_update` | `{method:"session/update", params:}` notification | +| permission request | `{id:, method:"session/request_permission", params}` request | +| `client_evicted` / `slow_client_warning` / `state_resync_required` | `{method:"_qwen/notify", params:{kind,…}}` notification | +| `stream_error` | JSON-RPC error response on the active prompt id (or `_qwen/notify`) | +| prompt settle | `{id:, result:{stopReason}}` | + +### 4.3 Pending agent→client requests + +`AcpConnection` keeps `Map`. +When the client POSTs a JSON-RPC response object, `dispatch` matches `id`, then calls the +bridge resolution path (e.g. permission `POST /session/:id/permission/:requestId` +internal equivalent). + +> **v1 status:** only the `session/request_permission` agent→client round-trip is +> implemented. `fs/*` and `terminal/*` agent→client forwarding is **deferred** (§7) — the +> daemon does not yet advertise `fs`/`terminal` client-capability negotiation on `/acp`, +> so ACP clients should not assume filesystem/terminal semantics over this transport in +> v1. The intended end state (forward `fs/*` to the client; fall back to the daemon's +> workspace FS when the client lacks the `fs` capability) is the follow-up described in §7. + +--- + +## 5. Extension strategy (requirement #2) + +ACP reserves any method starting with `_` for custom extensions and provides `_meta` +on every type. The codebase's southbound leg already uses `unstable_*` method names. + +**Northbound choice:** vendor-namespaced **`_qwen//`** method names +(spec-compliant `_` prefix). Capabilities advertised under +`agentCapabilities._meta.qwen` at `initialize` so clients feature-detect before use. + +| Need | No standard ACP method? | Extension | +|------|------------------------|-----------| +| Model switch | yes | `_qwen/session/set_model` | +| Workspace MCP/skills/providers/env introspection | yes | `_qwen/workspace/list`, `_qwen/workspace/` | +| Heartbeat / last-seen | yes | `_qwen/session/heartbeat` | +| Multi-client permission policy (consensus/designated) | partial | `session/request_permission` + `_meta.qwen.policy` | +| SSE backpressure tuning (`maxQueued`) | yes | `Acp-Qwen-Max-Queued` header on session GET | +| Resume cursor (ring `Last-Event-ID`) | RFD Phase 4 | `Last-Event-ID` header + `_meta.qwen.eventId` on frames | + +Standard methods are **never** renamed; extensions are strictly additive and ignorable. + +--- + +## 6. Dual-transport vs. replace (requirement #4) + +**Decision: dual-transport (additive).** + +- The official transport is a **Draft** RFD, not normative, and absent from every SDK — + hard-replacing would couple us to an unratified design and break webui + 3 SDKs + + VSCode companion at once. +- The REST surface carries features with no clean ACP mapping yet (workspace + introspection, multi-client permission mediation, ring-buffer resume, capability + registry). Those degrade to `_qwen/*` extensions on `/acp` but the REST surface stays + authoritative until the RFD ratifies. +- Both transports share **one** `HttpAcpBridge` + `EventBus` instance, so there is no + state duplication — `/acp` and `/session/*` can even drive the same live session + concurrently (multi-client is already supported by the bridge). +- Toggle (v1, shipped): on by default; **`QWEN_SERVE_ACP_HTTP=0`** disables the mount. A + `--no-acp-http` CLI flag and an `acp_http` tag in `/capabilities` for client feature- + detection are **deferred** to a follow-up (not in v1) — until then clients detect the + transport by probing `POST /acp {initialize}`. + +Migration path: once the RFD ratifies and SDKs ship, REST routes can be reframed as a +thin compat shim over `/acp` (separate, later PR). + +--- + +## 7. Scope of the implementation PR + +**In scope (runnable + verified locally):** +- `POST /acp` dispatch for `initialize`, `session/new`, `session/prompt`, + `session/cancel`, `session/load`, JSON-RPC response handling. +- Connection-scoped + session-scoped `GET /acp` SSE streams with JSON-RPC framing. +- `session/update` streaming + final prompt response correlation. +- `session/request_permission` agent→client round-trip. +- `_qwen/session/set_model` extension as the worked example of #2. +- Bearer-auth + host allowlist reuse (same middleware as REST). +- Unit tests (`acpHttp/*.test.ts`) + a black-box smoke script driving a real daemon. + +**Deferred (documented, not built now):** +- WebSocket upgrade path (RFD-required client cap; SSE suffices for local verify). +- HTTP/2 multiplexing (we run HTTP/1.1; POST and long-lived GET use separate sockets, + which works for CLI/Node clients and ≤6-connection browsers). Documented divergence. +- Full `fs/*` + `terminal/*` agent→client forwarding (permission path proves the + mechanism; rest is mechanical follow-up). +- SSE resumability hardening parity with the ring buffer (Phase 4 in RFD). + +--- + +## 8. Local verification plan + +1. `npm run build` (or workspace build of `cli` + `acp-bridge`). +2. Start daemon: `qwen serve --listen 127.0.0.1:0 --token ` (or env token). +3. Run `node scripts/acp-http-smoke.mjs`: + - `POST /acp {initialize}` → assert `200` + `Acp-Connection-Id`. + - Open connection SSE; `POST {session/new}` → assert response on stream. + - Open session SSE; `POST {session/prompt:"say hi"}` → assert ≥1 `session/update` + then a final `{result:{stopReason}}`. + - Trigger a tool needing permission → assert `session/request_permission` request, + POST a grant response → assert prompt completes. + - `POST {_qwen/session/set_model}` → assert model switch + `session/update`. +4. Vitest: `acpHttp/*.test.ts` green. + +--- + +## 9. Risks + +| Risk | Mitigation | +|------|-----------| +| RFD changes before ratification | Behind capability tag + `_qwen` namespace; isolated module; easy to revise. | +| HTTP/1.1 vs required HTTP/2 | Localhost/CLI clients unaffected; documented; h2 is a transport swap later. | +| Two transports on one bridge race | Bridge already supports multi-client; reuse its locking. | +| `fs/*` forwarding vs daemon-local FS | Capability-gated: forward when client declares `fs`, else local. | + +--- + +## 10. Implementation & verification log (v1) + +Implemented in `packages/cli/src/serve/acpHttp/` (`jsonRpc.ts`, `sseStream.ts`, +`connectionRegistry.ts`, `dispatch.ts`, `index.ts`), mounted from `server.ts` +via `mountAcpHttp(app, bridge, { boundWorkspace })`. + +### Automated (`packages/cli/src/serve/acpHttp/*.test.ts`) + +`transport.test.ts` boots a real Express server + the real `mountAcpHttp` over +a controllable fake bridge and drives it with `fetch` + manual SSE parsing. +15 tests green, covering: `initialize` 200 + `Acp-Connection-Id`; unknown-conn +400; `session/new` reply on the connection stream; prompt → `session/update` +stream + final result correlation; `session/request_permission` agent→client→ +agent round-trip; `_qwen/session/set_model`; method-not-found; `DELETE` teardown. + +### Live daemon (real model) + +Booted `qwen serve --port 8767 --token … --workspace …` (bundle entry so the +spawned `qwen --acp` child is self-contained) and ran `scripts/acp-http-smoke.mjs`: + +``` +✓ initialize: connectionId=… protocolVersion=1 +✓ session/new: sessionId=… +→ prompt: "Reply with the single word: pong" +pong +✓ prompt complete: 10 session/update frames, stopReason=end_turn +✓ DELETE /acp — connection closed +ALL CHECKS PASSED ✅ +``` + +Error-path was also confirmed live: when the child failed to start, the bridge +timeout surfaced to the client as a JSON-RPC error frame on the connection +stream (`{"id":2,"error":{"code":-32603,…}}`), proving id-correlation + the +202/SSE split under failure. + +### Review fold-in — bridge-issued clientId (found in live verify) + +First live run failed `session/prompt` with *"client id … is not registered for +session"*. Root cause: `spawnOrAttach`/`loadSession` **ignore** a caller-supplied +clientId the bridge has never issued and stamp a fresh one (returned in +`BridgeSession.clientId`); the dispatcher was echoing the connection's own +(unregistered) id on `sendPrompt`. Fix: persist the bridge-stamped id on the +`SessionBinding` and echo it on every per-session call (`sessionCtx`). Re-verified +green above. + +--- + +## 11. Review round 2 — fold-ins + +Two independent reviews (correctness/concurrency + protocol-conformance/security) plus a self-read. +All fixes verified by the expanded vitest suite (**18 tests**) + a fresh live smoke run +(21 `session/update` frames → `stopReason=end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| R1 | **P0** | Session-stream **reconnect was permanently dead**: `SessionBinding.abort` was created once and reused; on stream close it was aborted forever, so a reconnect's `subscribeEvents(signal)` got an already-aborted signal and received zero events. | `attachSessionStream` now installs a **fresh** `AbortController` per stream (and closes any prior stream); `index.ts` pumps on that fresh signal. | +| R2 | **P0** | `await dispatcher.handle()` ran **after** `res.end(202)`; a throwing bridge call (notably the un-try/caught `isResponse` path) would reject and surface as an unhandled rejection → possible daemon crash. | Wrapped the `isResponse` path in try/catch; `.catch()` on the awaited `handle(...)` and on `pumpSessionEvents(...)`. | +| R3 | **P1** | **No connection→session ownership**: any authenticated connection could open the session SSE for, or prompt, *any* sessionId in the workspace (read-eavesdrop; prompt was only blocked incidentally by the unregistered-clientId error). | `AcpConnection.ownedSessions` populated by `session/new`/`load`/`resume`; session stream returns `403` and per-session POSTs return `INVALID_PARAMS` for unowned ids (`requireOwned`). | +| R4 | **P1** | `mountAcpHttp` handle was discarded → TTL sweep timer + live SSE streams leaked on shutdown. | Handle parked on `app.locals`; `runQwenServe` close hook calls `dispose()` before `bridge.shutdown()` (mirrors the device-flow registry). | +| R5 | **P1** | **Pending permission leak**: closing a session/connection with a permission outstanding left the bridge blocked awaiting a vote. | `closeSessionStream`/`destroy` cancel matching pending requests via an injected `onAbandonPending` → `cancelAbandonedPermission`. | +| R6 | **P1** | Pre-attach frame buffers (`connBuffer`/`binding.buffer`) were unbounded. | Capped at 256 frames (drop-oldest), matching the EventBus `maxQueued`. | +| R7 | **P2** | `initialize` ignored the client's requested `protocolVersion`. | Negotiates `min(requested, 1)`. | +| R8 | **P2** | No `Acp-Session-Id` ↔ `params.sessionId` cross-check (RFD §2.3). | POST asserts they agree; mismatch → `INVALID_PARAMS`. | +| R9 | **P2** | `session/cancel` request-form (with id) never answered; duplicate top-level `_meta.qwen`. | Reply when an id is present; single `agentCapabilities._meta.qwen`. | + +### Accepted / documented (not fixed in v1) + +- **Prompt-result vs trailing `session/update` ordering** (P2): `handlePrompt` awaits `sendPrompt` then + writes the result frame, while updates stream concurrently. In practice the bridge publishes all + `session/update`s to the bus before `sendPrompt` resolves and both share one ordered SSE write + chain, so the result lands last (confirmed: 21 updates then result). A strict barrier is a possible + later hardening if a client reducer proves sensitive. +- **Browser `EventSource` can't set `Authorization`** — `/acp` GET streams require the bearer header, + so browsers need the deferred WebSocket path (§7); CLI/Node clients are unaffected. +- The daemon's real trust boundary remains the **bearer token + single-workspace bind** (same as the + REST surface); R3's ownership check is defense-in-depth + contract correctness, not a tenant boundary. + +--- + +## 12. Review round 3 — PR bot fold-ins (#4472) + +Two automated PR reviewers plus the summary bot. +All fixes verified by the suite (now **22 tests**) + a fresh live run (16 `session/update` → `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| B1 | **P0** | `handlePrompt`'s `AbortController` was never aborted — a disconnecting/cancelling client left the agent running (burned model quota, blocked the session FIFO). Flagged by both bots + 5 sub-agents. | `promptAbort` parked on `SessionBinding`; aborted by `session/cancel` and by session/connection teardown (`closeSessionStream`/`destroy`). | +| B2 | **P0** | `sessionCtx` missing `fromLoopback` → every ACP permission vote treated as remote; `local-only` policy would reject loopback clients. | Capture loopback at `initialize` (kernel `remoteAddress`, not forgeable headers) → `AcpConnection.fromLoopback` → threaded through `sessionCtx`. | +| B3 | **P0** | SSE write failures silently swallowed → zombie streams (heartbeats fire, zero events delivered, no logs). | First write failure logs + closes the stream. | +| B4 | **P0** | Idle sweep destroyed connections with no log + no connection cap (initialize-flood). | Sweep logs each reap; `pumpSessionEvents` calls `touch()` (long quiet prompts aren't reaped); `maxConnections` cap (64) → `503`. | +| B5 | **P1** | `sessionCtx` silently fell back to the connection's unregistered clientId when the binding lacked one (untested, always-fired in `FakeBridge`). | Throw on missing stamped clientId (invariant violation); `FakeBridge` now stamps one. | +| B6 | **P1** | `session/new|load|resume` accepted `cwd` unvalidated (REST validates string/length/absolute — amplification DoS). | Shared `parseOptionalWorkspaceCwd` (string, ≤4096, absolute). | +| B7 | **P1** | `session/prompt` forwarded an unvalidated `prompt` to the bridge. | `validatePrompt` (non-empty array of objects), mirroring REST. | +| B8 | **P1** | Raw bridge error messages echoed to the client. | `toRpcError` maps known bridge errors to coded, client-safe shapes; unknown → generic `Internal error` (full detail still to stderr). | +| B9 | **P1** | `nextId` used sequential negatives — a client legally using negative ids could collide in `pending`. | Daemon-originated ids are now strings (`_qwen_perm_N`), disjoint from any client id. | +| B10 | **P2** | `resolveClientResponse` param type excluded `JsonRpcError`; conn-scoped SSE stream had no `onClose`; `DELETE` with no header was a silent 202; `SseStream.close` ran `onClose` outside try/catch; `session/load`·`resume`·`close` untested. | Widened param to `JsonRpcResponse`; conn stream logs on close; `DELETE` missing header → `400`; `onClose` wrapped in try/catch; added load/resume/close + DELETE-400 tests. | + +**Out of scope (base-branch `daemon_mode_b_main`, not this diff)** — the second reviewer flagged +typecheck errors in `acpAgent.ts` (`entryCount`/`entrySummary`/`sessionClose`) and other pre-existing +items it explicitly attributed to the base branch (introduced by #4353). Tracked separately; not +touched here. + +**Still deferred** (documented): per-connection secret for `DELETE`/connection ownership (token remains +the boundary); WebSocket + HTTP/2 (§7); strict prompt-result vs trailing-update barrier (§11). + +--- + +## 13. Review round 4 — PR fold-ins (rebased onto #4469) + +Branch rebased onto `daemon_mode_b_main` (#4353 + #4469) — **clean, no conflicts**. Two PR +reviewers (GPT-5 + qwen3.7-max). Suite now **25 tests**; live re-verified (125 `session/update` +→ `end_turn`). + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| C1 | **P0** | Round-3 "SSE write-failure handling" was documented but NOT implemented — `SseStream` still left it to discarding callers (zombie streams). | `writeRaw` now owns it: first write rejection logs once + `close()`s; `doWrite` also listens for `'error'` (rejects promptly instead of hanging to `'close'`); `onClose` wrapped in try/catch. | +| C2 | **P1** | `fromLoopback` captured only at `initialize` + helper narrower than REST → `local-only` votes from a later POST misjudged. | Per-request loopback threaded through `handle`→`sessionCtx`/`resolveClientResponse`; `isLoopbackReq` widened to `127.0.0.0/8` + `::ffff:127.*` + `::1` (matches REST). | +| C3 | **P1** | Error routing inferred stream from `params.sessionId` → conn-scoped method failures (`session/load`/`resume`/`close`/`heartbeat`) misrouted to a non-existent session stream (silent loss). | `CONN_ROUTED_METHODS` set; errors route the same way as the success path. | +| C4 | **P1** | `bridge.detachClient` never called on teardown → stale bridge-stamped client ids linger in `knownClientIds()`/voter sets. | Registry takes a `DetachSessionFn`; `closeSessionStream`/`destroy` detach each owned session (best-effort). | +| C5 | **P1** | `session/close` skipped local cleanup if `bridge.closeSession` threw. | `closeSessionStream` moved into a `finally`. | +| C6 | **P2** | Windows `cwd` (`C:\…`) rejected by `startsWith('/')`. | `path.isAbsolute` (platform-aware), matching REST. | +| C7 | **P2** | `protocolVersion` could negotiate `0`/negative. | Clamp `Math.max(1, Math.min(requested, 1))`; tests for 0/neg/huge/invalid. | +| C8 | **P2** | `session/load`/`resume` accepted empty `sessionId`. | Reject empty with `INVALID_PARAMS`. | +| C9 | **P2** | Notification-form `session/prompt` errors vanished silently. | Log on the no-id path. | +| C10 | **P2** | Session SSE flushed buffered frames before headers/`retry:`. | `open()` before `attachSessionStream`. | +| C11 | **P2** | Duplicate local `logStderr`. | Shared `writeStderrLine` from `utils/stdioHelpers`. | +| C12 | **P2** | Docs advertised `--no-acp-http` flag, `acp_http` capability tag, and `fs/*` forwarding not in v1. | Doc aligned to shipped surface (env-var toggle only; `fs/*`+`terminal/*` + flag + tag marked deferred). | + +Still deferred (unchanged): WebSocket + HTTP/2; per-connection secret for `DELETE`/ownership +(token + single-workspace remains the boundary); strict prompt-result ordering barrier; the +`as never` bridge-boundary casts (targeted, noted for an adapter-types follow-up). + +--- + +## 14. Review round 5 — PR fold-ins + +One more reviewer pass (qwen3.7-max). Suite **26 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| D1 | **P0** | `resolveClientResponse` deleted the pending entry BEFORE calling `respondToSessionPermission`. A malformed vote (`result: {}`) makes the bridge mediator throw — and with the pending entry already gone, teardown's `abandonPendingForSession` can't cancel it, so the agent's prompt hangs on a vote that never resolves (a token-holder could stall a session with one bad POST). | Wrap the vote in try/catch; on any failure fall back to `cancelAbandonedPermission` so the mediator is always released. New test covers the malformed-vote path. | +| D2 | **P1** | Session-stream `onClose` aborted only the event pump, not `binding.promptAbort` — a client disconnect (tab close / network drop) left the in-flight prompt running (quota + FIFO) until idle TTL. | `onClose` now also aborts the session's `promptAbort`. | +| D3 | **P1** | When `pumpSessionEvents` rejected, the `.catch` only logged — the SSE stream stayed open heartbeating but delivering nothing (zombie, no reconnect signal). | `.catch` now also `closeSessionStream(sessionId)`. | + +--- + +## 15. Review round 6 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **28 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| E1 | **P0** | `handlePrompt` overwrote `binding.promptAbort` without aborting the prior controller — two concurrent `session/prompt`s for one session orphaned the first (runs to completion in the bridge FIFO, unabortable by `session/cancel`). | Abort the prior `promptAbort` before installing the new one. Test added. | +| E2 | **P0** | The `subscribeEvents`-throws path sent a `stream_error` notify then `return`ed (resolved) — the caller's `.catch` never fired, leaving a zombie SSE stream (heartbeats, no events, no reconnect signal). | Re-throw after the notify so the caller's `.catch` closes the stream. Test asserts prompt closure. | +| E3 | **P1** | SSE heartbeat didn't mark the connection active — a long prompt with no intermediate events for >30 min got idle-reaped (streams + prompts killed). | `SseStream` takes an `onHeartbeat` hook; both GET handlers pass `() => conn.touch()`. | +| E4 | **P2** | `pumpSessionEvents` `.catch` closed by sessionId — a reconnect between the throw and the microtask could kill the NEW stream. | Identity-guard: only close if `binding.stream` is still this stream. | +| E6 | **P2** | `sendSession` auto-created a binding — a late pump/reply frame after `closeSessionStream` resurrected a ghost binding that buffered up to 256 frames forever. | `sendSession` is now lookup-only: drops frames when the session has no live binding. | +| E5 | accepted | `session/load`/`resume` don't reject when another live connection owns the session ("hijack"). | **Accepted, not changed:** the daemon's trust boundary is the bearer token + single-workspace bind, and multi-client attach is intentional (the bridge is multi-client by design; REST has the same property). A token-holder gains no capability they lack via REST. Tracked with the other token-boundary items (DELETE ownership, §13). | + +--- + +## 16. Review round 7 — PR fold-ins + +Another reviewer pass (qwen3.7-max). Suite **30 tests**, live re-verified. + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| F1 | **P0** | Concurrent `session/close` TOCTOU: `ownedSessions.delete` ran only in `finally` (after the await), so two concurrent closes both passed `requireOwned` → misleading error to the 2nd + redundant bridge close. | Delete the ownership gate SYNCHRONOUSLY before the await; bridge close runs once. Test added. | +| F2 | **P1** | Pump lifecycle: a CLEAN iterator end (subprocess ended, `done`) resolved → the `.catch` never fired → zombie stream; and a MID-STREAM iterator error sent no `stream_error`. | `pumpSessionEvents` wraps the whole loop (sync + mid-stream errors send `stream_error` then re-throw); the consumer `.then(onDone, onErr)` closes the stream on BOTH paths (identity-guarded). Tests added. | +| F3 | **P2** | 503 connection-cap rejection had no stderr log. | `writeStderrLine` with the cap value. | +| F4 | **P2** | `_qwen/notify stream_error` spread let `event.data.kind` shadow the discriminator. | Spread first, then `kind: 'stream_error'`. | +| F5 | **P2** | `MAX_WORKSPACE_PATH_LENGTH` redeclared (`= 4096`) vs the canonical `fs/paths.js`. | Import from `../fs/paths.js` (no divergence). | +| F6 | **P2** | `isObjectParams` duplicated `jsonRpc.isObject`. | Import `isObject`. | +| F7 | **P2** | Raw `process.stderr.write` in `index.ts`/`sseStream.ts` vs `writeStderrLine` elsewhere. | Unified on `writeStderrLine` across the module. | + +--- + +## 17. REST 等价对齐 + 扩展方案审计落地(round 8) + +目标:让 `/acp` 成为 REST+SSE 的**等价替代**。本批基于审计结论重构扩展方案,并补齐**所有 bridge 已暴露**的能力;bridge 尚未拥有的能力(文件 I/O、设备流、agents/memory CRUD)按架构正确性要求**先由 acp-bridge 补齐**(见 §17.3)。 + +### 17.1 扩展方案审计 → 落地(替换 §5 的旧方案) + +依据**仓库实装 SDK `@agentclientprotocol/sdk@0.14.1`**(非仅官网)核对: +- `session/set_config_option` 是**一等(非 `unstable_`)方法**,请求 `{sessionId, configId, value}`,`category` 含 `model`/`mode`/`thought_level`;而 `set_model` 仍走 `unstable_setSessionModel`。 +- 规范保留 `_` 前缀给扩展,示例为域风格 `_zed.dev/…`;厂商数据放 `_meta` 按域名分键。 + +落地: +- **命名空间 `_qwen/` → 反向域名 `_qwen/`**;`_meta` 统一 `_meta:{ "qwen": … }`(含 `initialize` 能力广告与 `session/request_permission` 的 requestId)。 +- **模型 + 审批模式 → 标准 `session/set_config_option`**(`configId:"model"|"mode"`),路由到现有 `bridge.setSessionModel`/`setSessionApprovalMode`;`session/new` 结果**广告 `configOptions`**(取自子进程会话状态 `getSessionContextStatus().state.configOptions`,已是 ACP 形状)。**删除**厂商 `_qwen/session/set_model`。 +- REST(http+sse) **无需同步修改**:两 transport 共用同一 bridge,状态天然一致。 + +### 17.2 本批新增的 `/acp` 方法(bridge 已支持,1:1 对齐 REST) + +| REST | `/acp` | bridge | +|---|---|---| +| `POST /session/:id/model` / `approval-mode` | **标准** `session/set_config_option`(model/mode) | setSessionModel / setSessionApprovalMode | +| `GET /session/:id/context` | `_qwen/session/context` | getSessionContextStatus | +| `GET /session/:id/supported-commands` | `_qwen/session/supported_commands` | getSessionSupportedCommandsStatus | +| `PATCH /session/:id/metadata` | `_qwen/session/update_metadata` | updateSessionMetadata | +| `GET /workspace/{mcp,skills,providers,env,preflight}` | `_qwen/workspace/{…}` | getWorkspace*Status | +| `POST /workspace/init` | `_qwen/workspace/init` | initWorkspace | +| `POST /workspace/tools/:name/enable` | `_qwen/workspace/set_tool_enabled` | setWorkspaceToolEnabled | +| `POST /workspace/mcp/:server/restart` | `_qwen/workspace/restart_mcp_server` | restartMcpServer | + +(既有:session/new·load·resume·close·list·prompt·cancel、heartbeat、permission、events 已对齐。) + +### 17.3 仍缺口 → 要求 acp-bridge 先补齐(架构正确性) + +REST 的 **文件 I/O**(`/file /glob /list /stat /file/write /file/edit`)、**设备流登录**(`/workspace/auth/*`)、**agents CRUD**(`/workspace/agents`)、**memory CRUD**(`/workspace/memory`)目前**不在 `HttpAcpBridge` 上**——REST 路由直接调 route 级服务(`WorkspaceFileSystemFactory`、`DeviceFlowRegistry`、`SubagentManager`、`writeWorkspaceContextFile`),绕过了 bridge。 + +**决策(采纳评审/owner 意见)**:不让 `/acp` transport 再去直连这些 route 级服务(那会复制 REST 的架构漂移、并使 transport 耦合翻倍)。**正确做法是先在 `@qwen-code/acp-bridge` 的 `HttpAcpBridge` 上补齐这些能力**(如 `readWorkspaceFile`/`writeWorkspaceFile`/`globWorkspace`、`startDeviceFlow`/`pollDeviceFlow`、`listAgents`/`upsertAgent`/`deleteAgent`、`readMemory`/`writeMemory`),让 REST 与 `/acp` 都经由 bridge。届时 `/acp` 再加 `_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*`(文件读因无标准 ACP client→agent 方法,属合法厂商扩展)。 + +**完整等价 = 本批(bridge 已有能力)+ acp-bridge 补齐缺口后的后续批**。 + +--- + +## 18. Review round 9 — PR fold-ins + +| # | Severity | Finding | Fix | +|---|----------|---------|-----| +| G1 | **P1 (regression)** | Session-stream reconnect aborted the in-flight prompt: `attachSessionStream` closed the OLD stream before installing the new one, and the old stream's `onClose` unconditionally aborted `promptAbort` — so a reconnecting client (network glitch/roaming) lost its running prompt. | Install the new stream BEFORE closing the old; identity-guard `onClose`'s prompt-abort (only abort if THIS is still the session's live stream). Test added (prompt survives reconnect). | +| G2 | **P2** | `session/cancel` passed `undefined` as the `CancelNotification` body, dropping client-supplied cancel fields (reason/context) that REST forwards. | Forward `{ ...params, sessionId }` (mirrors REST). | + +Rebased onto latest `daemon_mode_b_main` (#4473/#4483/#4484/#4500), no conflicts. Suite **33 tests**, live re-verified. + +--- + +## 19. 路线图 / 后续 PR(防遗忘) + +本 PR(#4472)= ACP Streamable HTTP transport + **全部 bridge-backed 能力对齐** + 官方扩展方案。已转 **ready**。达到「`/acp` 完全等价 REST+SSE」尚需: + +1. **Follow-up PR 1 — acp-bridge 能力补齐(前置 / bridge-first)**:`HttpAcpBridge` 新增 文件 I/O、设备流、agents CRUD、memory CRUD 方法;REST 路由改走 bridge(消除直连 route 级服务的漂移)。 +2. **Follow-up PR 2 — `/acp` 剩余对齐(依赖 PR 1)**:`_qwen/fs/*`、`_qwen/auth/*`、`_qwen/workspace/agent*`、`_qwen/workspace/memory*` → 完全等价 REST。 + +跟踪:#3803(open decisions)、#4175(Mode B roadmap)均已 comment。 +Deferred 硬化项见 PR 描述「已知 deferred」。 + +--- + +## 20. Extension-namespace rename + SDK-transport analysis (round 11) + +- **Namespace `_qwen.ai/` → `_qwen/`**: ACP's only hard rule is the leading `_`; the `_zed.dev/` domain segment is convention-by-example, not a MUST. Since `qwen` is distinctive, we use the shorter bare form. `_meta` key likewise `"qwen"`. (Survey of real agents: Zed/gemini-cli mostly use `_meta`-on-standard-methods + ACP's own `unstable_*`; bare custom `_` methods are rare — our `_qwen/*` are genuinely-new workspace/session ops with no standard equivalent, so a `_` method is the right tool.) +- **Why hand-rolled transport (not SDK-based)**: the TS SDK ships only `ndJsonStream` (stdio); RFD #721 HTTP is SDK Phase-3 (not implemented). The SDK `Connection` is single-duplex-stream; our transport is multi-stream (POSTs + connection-SSE + per-session-SSE) and needs outbound demux by sessionId — which our dispatcher already knows at routing time. A full SDK rewrite fights that model and wouldn't remove the bulk (bridge translation, SSE lifecycle, ownership, EventBus→JSON-RPC). **Pragmatic improvement (candidate follow-up): adopt the SDK's Zod schema validators + types for param validation while keeping the hand-rolled transport.** SDK clients using `extMethod('_qwen/…')` interoperate with our handlers (identical wire shape). diff --git a/docs/developers/qwen-serve-protocol.md b/docs/developers/qwen-serve-protocol.md index 2ea2b7afd20..4e640ef5499 100644 --- a/docs/developers/qwen-serve-protocol.md +++ b/docs/developers/qwen-serve-protocol.md @@ -125,7 +125,7 @@ registry. Clients **must** gate UI off `features`, not off `mode` (per design 'session_set_model', 'client_identity', 'client_heartbeat', 'session_permission_vote', 'permission_vote', 'workspace_mcp', 'workspace_skills', 'workspace_providers', 'workspace_env', 'workspace_preflight', - 'session_context', 'session_supported_commands', + 'session_context', 'session_supported_commands', 'session_tasks', 'session_close', 'session_metadata', 'mcp_guardrails', 'mcp_guardrail_events', 'workspace_file_read', 'workspace_file_bytes', 'workspace_file_write', @@ -243,6 +243,7 @@ Capability tags: - `workspace_preflight` → `GET /workspace/preflight` - `session_context` → `GET /session/:id/context` - `session_supported_commands` → `GET /session/:id/supported-commands` +- `session_tasks` → `GET /session/:id/tasks` Common status cell: @@ -873,6 +874,36 @@ caller named the path. Success responses and audit events include `available_commands_update` SSE notification. `availableSkills` lists skill names only; clients must not expect skill bodies or paths over this route. +### `GET /session/:id/tasks` + +```json +{ + "v": 1, + "sessionId": "", + "now": 1700000000000, + "tasks": [ + { + "kind": "agent", + "id": "agent-1", + "label": "reviewer: check failure", + "description": "check failure", + "status": "running", + "startTime": 1699999999000, + "runtimeMs": 1000, + "outputFile": "/tmp/agent-1.jsonl", + "isBackgrounded": true, + "subagentType": "reviewer" + } + ] +} +``` + +This route is a read-only out-of-band snapshot. It is intentionally not a +prompt and can be queried while the session is streaming. The response only +contains whitelisted metadata from the agent, shell, and monitor task +registries; controllers, timers, offsets, pending messages, and raw registry +objects are never exposed. + ### `POST /session` Spawn a new agent or attach to an existing one (under `sessionScope: 'single'`, the default). diff --git a/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md b/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md new file mode 100644 index 00000000000..7e9a683bdd8 --- /dev/null +++ b/docs/superpowers/plans/2026-05-27-daemon-workspace-service.md @@ -0,0 +1,1236 @@ +# DaemonWorkspaceService Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extract all workspace-scoped capabilities from HttpAcpBridge into a new DaemonWorkspaceService, enabling /acp transport parity and honest rename to AcpSessionBridge. + +**Architecture:** Scope-based split — workspace-scoped ops go to a new facade (DaemonWorkspaceService) with 4 internal sub-services; session-scoped ops stay in bridge. Child-dependent workspace ops delegate via injected callbacks. Both REST and /acp call the same L2 service. + +**Tech Stack:** TypeScript, Vitest, Express (REST routes), JSON-RPC (ACP), supertest (integration) + +**Spec:** `docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md` + +--- + +## File Map + +### New Files +| File | Responsibility | +|---|---| +| `packages/cli/src/serve/workspace-service/types.ts` | WorkspaceRequestContext, sub-service interfaces, deps interface, result types | +| `packages/cli/src/serve/workspace-service/index.ts` | Facade factory `createDaemonWorkspaceService` | +| `packages/cli/src/serve/workspace-service/fileService.ts` | FileService — wraps fsFactory | +| `packages/cli/src/serve/workspace-service/authService.ts` | AuthService — wraps DeviceFlowRegistry | +| `packages/cli/src/serve/workspace-service/agentsService.ts` | AgentsService — wraps SubagentManager | +| `packages/cli/src/serve/workspace-service/memoryService.ts` | MemoryService — wraps memory file ops | +| `packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts` | FileService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/authService.test.ts` | AuthService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts` | AgentsService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts` | MemoryService unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/facade.test.ts` | Facade + workspace-scoped methods (status/tool/init/restart) unit tests | +| `packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts` | REST ↔ /acp equivalence e2e tests | + +### Modified Files +| File | Change | +|---|---| +| `packages/acp-bridge/src/bridgeTypes.ts` | Rename interface + remove 8 methods + add 2 new methods | +| `packages/acp-bridge/src/bridge.ts` | Remove 8 workspace methods, expose `queryWorkspaceStatus` + `invokeWorkspaceCommand`, rename factory | +| `packages/acp-bridge/src/bridgeOptions.ts` | Update JSDoc references | +| `packages/acp-bridge/src/status.ts` | Update error message class name | +| `packages/cli/src/serve/httpAcpBridge.ts` → rename to `acpSessionBridge.ts` | Update re-exports | +| `packages/cli/src/serve/runQwenServe.ts` | Construct workspace service, inject callbacks | +| `packages/cli/src/serve/server.ts` | Rewire workspace routes to call service | +| `packages/cli/src/serve/workspaceAgents.ts` | Extract business logic → agentsService, keep as route shell | +| `packages/cli/src/serve/workspaceMemory.ts` | Extract business logic → memoryService, keep as route shell | +| `packages/cli/src/serve/routes/workspaceFileRead.ts` | Rewire to call FileService | +| `packages/cli/src/serve/routes/workspaceFileWrite.ts` | Rewire to call FileService | + +--- + +## Task 1: Types & Interfaces + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/types.ts` + +- [ ] **Step 1: Create types file with all interfaces** + +```ts +// packages/cli/src/serve/workspace-service/types.ts +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import type { + ServeWorkspaceMcpStatus, + ServeWorkspaceSkillsStatus, + ServeWorkspaceProvidersStatus, + ServeWorkspaceEnvStatus, + ServeWorkspacePreflightStatus, +} from '@qwen-code/acp-bridge'; + +// --- Request Context --- + +export interface WorkspaceRequestContext { + originatorClientId?: string; + sessionId?: string; + route: string; + workspaceCwd: string; +} + +// --- Sub-service interfaces --- + +export interface FileService { + read(ctx: WorkspaceRequestContext, path: string, opts?: { maxBytes?: number }): Promise; + readBytes(ctx: WorkspaceRequestContext, path: string): Promise; + write(ctx: WorkspaceRequestContext, path: string, content: string, opts?: { mode?: string }): Promise; + edit(ctx: WorkspaceRequestContext, path: string, edits: FileEdit[]): Promise; + glob(ctx: WorkspaceRequestContext, pattern: string): Promise; + list(ctx: WorkspaceRequestContext, path: string): Promise; + stat(ctx: WorkspaceRequestContext, path: string): Promise; +} + +export interface AuthService { + startFlow(ctx: WorkspaceRequestContext): Promise; + getFlowStatus(ctx: WorkspaceRequestContext, flowId: string): Promise; + cancelFlow(ctx: WorkspaceRequestContext, flowId: string): Promise; + getAuthStatus(ctx: WorkspaceRequestContext): Promise; +} + +export interface AgentsService { + list(ctx: WorkspaceRequestContext): Promise; + get(ctx: WorkspaceRequestContext, agentType: string): Promise; + create(ctx: WorkspaceRequestContext, spec: AgentCreateSpec): Promise; + update(ctx: WorkspaceRequestContext, agentType: string, spec: AgentUpdateSpec): Promise; + delete(ctx: WorkspaceRequestContext, agentType: string, opts?: { scope?: string }): Promise; +} + +export interface MemoryService { + list(ctx: WorkspaceRequestContext): Promise; + read(ctx: WorkspaceRequestContext, key: string): Promise; + write(ctx: WorkspaceRequestContext, key: string, content: string): Promise; + delete(ctx: WorkspaceRequestContext, key: string): Promise; +} + +// --- Facade interface --- + +export interface DaemonWorkspaceService { + file: FileService; + auth: AuthService; + agents: AgentsService; + memory: MemoryService; + + initWorkspace(opts: InitWorkspaceOpts, ctx: WorkspaceRequestContext): Promise; + setToolEnabled(toolName: string, enabled: boolean, ctx: WorkspaceRequestContext): Promise; + + getMcpStatus(): Promise; + getSkillsStatus(): Promise; + getProvidersStatus(): Promise; + getEnvStatus(): Promise; + getPreflightStatus(): Promise; + restartMcpServer(serverName: string, ctx: WorkspaceRequestContext, opts?: RestartMcpOpts): Promise; +} + +// --- Deps (callback injection) --- + +export interface WorkspaceEvent { + type: string; + data: Record; + originatorClientId?: string; +} + +export interface DaemonWorkspaceServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + deviceFlowRegistry: DeviceFlowRegistry; + subagentManager: unknown; // type from workspaceAgents.ts — refine during implementation + boundWorkspace: string; + contextFilename: string; + persistDisabledTools: (workspace: string, tool: string, enabled: boolean) => Promise; + + // Cross-cutting callbacks (session-derived infrastructure) + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; + + // Child delegation callbacks + queryWorkspaceStatus: (method: string, idle: () => T) => Promise; + invokeWorkspaceCommand: (method: string, params?: Record, opts?: { timeoutMs?: number }) => Promise; +} + +// --- Result types (refine from existing code during implementation) --- + +export interface FileReadResult { content: string; truncated: boolean; bytesRead: number; } +export interface FileWriteResult { ok: boolean; filePath: string; bytesWritten: number; mode?: string; } +export interface FileEdit { oldText: string; newText: string; } +export interface FileEditResult { ok: boolean; filePath: string; } +export interface ListEntry { name: string; type: 'file' | 'directory' | 'symlink'; } +export interface StatResult { exists: boolean; isFile: boolean; isDirectory: boolean; size: number; } +export interface DeviceFlowStartResult { flowId: string; verificationUri: string; userCode: string; } +export interface DeviceFlowStatus { state: string; /* refine from existing types */ } +export interface AuthStatusResult { authenticated: boolean; /* refine from existing */ } +export interface AgentSummary { agentType: string; /* refine */ } +export interface AgentDetail { agentType: string; /* refine */ } +export interface AgentCreateSpec { agentType: string; content: string; /* refine */ } +export interface AgentUpdateSpec { content: string; /* refine */ } +export interface MemoryEntry { key: string; /* refine */ } +export interface MemoryContent { key: string; content: string; } +export interface InitWorkspaceOpts { /* refine from bridge.ts:3256 */ } +export interface ToolToggleResult { toolName: string; enabled: boolean; } +export interface RestartMcpOpts { entryIndex?: number; } +export interface RestartMcpResult { serverName: string; restarted: boolean; durationMs?: number; } +``` + +> **Note:** Result types marked `/* refine */` should be aligned with existing response shapes during implementation. Read the current route handlers to get exact fields. + +- [ ] **Step 2: Verify types compile** + +Run: `cd packages/cli && npx tsc --noEmit src/serve/workspace-service/types.ts` +Expected: No errors (may need to adjust imports based on actual export paths) + +- [ ] **Step 3: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/types.ts +git commit -m "feat(serve): add DaemonWorkspaceService type definitions" +``` + +--- + +## Task 2: FileService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/fileService.ts` + +- [ ] **Step 1: Write failing tests for FileService.read** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createFileService } from '../fileService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +function makeCtx(overrides: Partial = {}): WorkspaceRequestContext { + return { route: 'GET /file', workspaceCwd: '/workspace', ...overrides }; +} + +describe('FileService', () => { + describe('read', () => { + it('calls fsFactory.forRequest with context and delegates to readFile', async () => { + const mockFs = { readFile: vi.fn().mockResolvedValue({ content: 'hello', truncated: false, bytesRead: 5 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + const result = await service.read(makeCtx({ originatorClientId: 'c1' }), 'src/app.ts'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'c1', + route: 'GET /file', + }); + expect(mockFs.readFile).toHaveBeenCalledWith('src/app.ts', undefined); + expect(result.content).toBe('hello'); + }); + + it('works without originatorClientId (read-only, no auth required)', async () => { + const mockFs = { readFile: vi.fn().mockResolvedValue({ content: '', truncated: false, bytesRead: 0 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + await service.read(makeCtx(), 'README.md'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: undefined, + route: 'GET /file', + }); + }); + }); +}); +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: FAIL — `createFileService` not found + +- [ ] **Step 3: Implement FileService** + +```ts +// packages/cli/src/serve/workspace-service/fileService.ts +import type { WorkspaceFileSystemFactory } from '../fs/index.js'; +import type { FileService, WorkspaceRequestContext, FileReadResult, FileWriteResult, FileEdit, FileEditResult, ListEntry, StatResult } from './types.js'; + +export interface FileServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + boundWorkspace: string; +} + +export function createFileService(deps: FileServiceDeps): FileService { + const { fsFactory } = deps; + + function scopedFs(ctx: WorkspaceRequestContext) { + return fsFactory.forRequest({ + originatorClientId: ctx.originatorClientId, + route: ctx.route, + ...(ctx.sessionId ? { sessionId: ctx.sessionId } : {}), + }); + } + + return { + async read(ctx, path, opts) { + const fs = scopedFs(ctx); + return fs.readFile(path, opts?.maxBytes); + }, + async readBytes(ctx, path) { + const fs = scopedFs(ctx); + return fs.readFileBytes(path); + }, + async write(ctx, path, content, opts) { + const fs = scopedFs(ctx); + return fs.writeFile(path, content, opts); + }, + async edit(ctx, path, edits) { + const fs = scopedFs(ctx); + return fs.editFile(path, edits); + }, + async glob(ctx, pattern) { + const fs = scopedFs(ctx); + return fs.glob(pattern); + }, + async list(ctx, path) { + const fs = scopedFs(ctx); + return fs.listDirectory(path); + }, + async stat(ctx, path) { + const fs = scopedFs(ctx); + return fs.stat(path); + }, + }; +} +``` + +> **Important:** The method names on `WorkspaceFileSystem` (`readFile`, `readFileBytes`, `writeFile`, `editFile`, `glob`, `listDirectory`, `stat`) must be verified against the actual interface at `packages/cli/src/serve/fs/workspaceFileSystem.ts`. Adjust if they differ. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: PASS + +- [ ] **Step 5: Add tests for write (trust gate validates clientId when present)** + +Add to the test file: +```ts + describe('write', () => { + it('passes originatorClientId to forRequest for audit', async () => { + const mockFs = { writeFile: vi.fn().mockResolvedValue({ ok: true, filePath: '/workspace/f.ts', bytesWritten: 3 }) }; + const fsFactory = { forRequest: vi.fn().mockReturnValue(mockFs) }; + const service = createFileService({ fsFactory: fsFactory as any, boundWorkspace: '/workspace' }); + + await service.write(makeCtx({ originatorClientId: 'c1', route: 'POST /file/write' }), 'f.ts', 'abc'); + + expect(fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'c1', + route: 'POST /file/write', + }); + expect(mockFs.writeFile).toHaveBeenCalledWith('f.ts', 'abc', undefined); + }); + }); +``` + +- [ ] **Step 6: Run full FileService tests** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/fileService.test.ts` +Expected: All PASS + +- [ ] **Step 7: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/fileService.ts packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts +git commit -m "feat(serve): add FileService wrapping fsFactory (TDD)" +``` + +--- + +## Task 3: AuthService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/authService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/authService.ts` + +- [ ] **Step 1: Read existing auth route logic** + +Read: `packages/cli/src/serve/server.ts:794-966` (device flow routes) and `packages/cli/src/serve/auth/deviceFlow.ts` to understand the DeviceFlowRegistry interface. + +- [ ] **Step 2: Write failing test** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/authService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createAuthService } from '../authService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/auth/device-flow', workspaceCwd: '/w' }; + +describe('AuthService', () => { + it('startFlow delegates to registry.start and returns flowId + verificationUri + userCode', async () => { + const registry = { + start: vi.fn().mockReturnValue({ id: 'flow-1', verificationUri: 'https://auth.example/device', userCode: 'ABCD-1234' }), + }; + const service = createAuthService({ deviceFlowRegistry: registry as any }); + + const result = await service.startFlow(ctx); + + expect(registry.start).toHaveBeenCalled(); + expect(result.flowId).toBe('flow-1'); + expect(result.verificationUri).toBe('https://auth.example/device'); + }); + + it('cancelFlow delegates to registry.cancel', async () => { + const registry = { cancel: vi.fn().mockReturnValue({ cancelled: true }) }; + const service = createAuthService({ deviceFlowRegistry: registry as any }); + + await service.cancelFlow(ctx, 'flow-1'); + + expect(registry.cancel).toHaveBeenCalledWith('flow-1', undefined); + }); +}); +``` + +- [ ] **Step 3: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts` +Expected: FAIL + +- [ ] **Step 4: Implement AuthService** + +```ts +// packages/cli/src/serve/workspace-service/authService.ts +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; +import type { AuthService, WorkspaceRequestContext, DeviceFlowStartResult, DeviceFlowStatus, AuthStatusResult } from './types.js'; + +export interface AuthServiceDeps { + deviceFlowRegistry: DeviceFlowRegistry; +} + +export function createAuthService(deps: AuthServiceDeps): AuthService { + const { deviceFlowRegistry } = deps; + + return { + async startFlow(ctx) { + const flow = deviceFlowRegistry.start(ctx.originatorClientId); + return { flowId: flow.id, verificationUri: flow.verificationUri, userCode: flow.userCode }; + }, + async getFlowStatus(ctx, flowId) { + return deviceFlowRegistry.get(flowId); + }, + async cancelFlow(ctx, flowId) { + deviceFlowRegistry.cancel(flowId, ctx.originatorClientId); + }, + async getAuthStatus(_ctx) { + return deviceFlowRegistry.getStatus(); + }, + }; +} +``` + +> **Note:** Method names on `DeviceFlowRegistry` (`start`, `get`, `cancel`, `getStatus`) must be verified against `packages/cli/src/serve/auth/deviceFlow.ts`. Adjust signatures as needed. + +- [ ] **Step 5: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/authService.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/authService.ts packages/cli/src/serve/workspace-service/__tests__/authService.test.ts +git commit -m "feat(serve): add AuthService wrapping DeviceFlowRegistry (TDD)" +``` + +--- + +## Task 4: AgentsService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/agentsService.ts` + +- [ ] **Step 1: Read existing agent logic** + +Read: `packages/cli/src/serve/workspaceAgents.ts` — extract the business logic (validation, SubagentManager calls, event publishing). Note: this file is ~700+ lines with route handling mixed in. + +- [ ] **Step 2: Write failing test — list + clientId validation** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createAgentsService } from '../agentsService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'GET /workspace/agents', workspaceCwd: '/w', originatorClientId: 'c1' }; + +describe('AgentsService', () => { + it('list returns agents from subagentManager', async () => { + const subagentManager = { list: vi.fn().mockResolvedValue([{ agentType: 'reviewer' }]) }; + const deps = { + subagentManager, + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['c1']), + }; + const service = createAgentsService(deps as any); + + const result = await service.list(ctx); + + expect(result).toEqual([{ agentType: 'reviewer' }]); + }); + + it('create publishes workspace event after success', async () => { + const subagentManager = { create: vi.fn().mockResolvedValue({ agentType: 'helper', content: '...' }) }; + const publishWorkspaceEvent = vi.fn(); + const deps = { + subagentManager, + publishWorkspaceEvent, + knownClientIds: () => new Set(['c1']), + }; + const service = createAgentsService(deps as any); + + await service.create(ctx, { agentType: 'helper', content: 'prompt' }); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'agent_created' })); + }); + + it('rejects unknown clientId on mutation', async () => { + const deps = { + subagentManager: { create: vi.fn() }, + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['c2']), // c1 not in set + }; + const service = createAgentsService(deps as any); + + await expect(service.create(ctx, { agentType: 'x', content: '' })) + .rejects.toThrow(/not registered/); + }); +}); +``` + +- [ ] **Step 3: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts` +Expected: FAIL + +- [ ] **Step 4: Implement AgentsService** + +Extract business logic from `packages/cli/src/serve/workspaceAgents.ts` into: +```ts +// packages/cli/src/serve/workspace-service/agentsService.ts +import type { AgentsService, WorkspaceRequestContext, WorkspaceEvent } from './types.js'; + +export interface AgentsServiceDeps { + subagentManager: any; // refine type from workspaceAgents.ts + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; +} + +function validateClientId(deps: AgentsServiceDeps, ctx: WorkspaceRequestContext): void { + if (ctx.originatorClientId && !deps.knownClientIds().has(ctx.originatorClientId)) { + throw new Error(`Client id "${ctx.originatorClientId}" is not registered for this workspace`); + } +} + +export function createAgentsService(deps: AgentsServiceDeps): AgentsService { + return { + async list(_ctx) { + return deps.subagentManager.list(); + }, + async get(_ctx, agentType) { + return deps.subagentManager.get(agentType); + }, + async create(ctx, spec) { + validateClientId(deps, ctx); + const result = await deps.subagentManager.create(spec); + deps.publishWorkspaceEvent({ + type: 'agent_created', + data: { agentType: spec.agentType }, + originatorClientId: ctx.originatorClientId, + }); + return result; + }, + async update(ctx, agentType, spec) { + validateClientId(deps, ctx); + const result = await deps.subagentManager.update(agentType, spec); + deps.publishWorkspaceEvent({ + type: 'agent_updated', + data: { agentType }, + originatorClientId: ctx.originatorClientId, + }); + return result; + }, + async delete(ctx, agentType, opts) { + validateClientId(deps, ctx); + await deps.subagentManager.delete(agentType, opts); + deps.publishWorkspaceEvent({ + type: 'agent_deleted', + data: { agentType }, + originatorClientId: ctx.originatorClientId, + }); + }, + }; +} +``` + +> **Important:** The actual SubagentManager interface and event types must be extracted from `workspaceAgents.ts` during implementation. The above is the pattern; exact method names/params will differ. + +- [ ] **Step 5: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/agentsService.test.ts` +Expected: PASS + +- [ ] **Step 6: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/agentsService.ts packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts +git commit -m "feat(serve): add AgentsService with clientId validation and event publish (TDD)" +``` + +--- + +## Task 5: MemoryService (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts` +- Create: `packages/cli/src/serve/workspace-service/memoryService.ts` + +- [ ] **Step 1: Read existing memory logic** + +Read: `packages/cli/src/serve/workspaceMemory.ts` — understand how memory CRUD works (likely file-based with `writeWorkspaceContextFile` or similar). + +- [ ] **Step 2: Write failing test** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createMemoryService } from '../memoryService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/memory', workspaceCwd: '/w', originatorClientId: 'c1' }; + +describe('MemoryService', () => { + it('write publishes workspace event', async () => { + const publishWorkspaceEvent = vi.fn(); + const deps = { + // mock whatever memory backend is used + publishWorkspaceEvent, + knownClientIds: () => new Set(['c1']), + boundWorkspace: '/w', + }; + const service = createMemoryService(deps as any); + + await service.write(ctx, 'user-prefs', 'dark mode'); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'memory_written' })); + }); + + it('rejects unknown clientId on write', async () => { + const deps = { + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['other']), + boundWorkspace: '/w', + }; + const service = createMemoryService(deps as any); + + await expect(service.write(ctx, 'key', 'val')).rejects.toThrow(/not registered/); + }); +}); +``` + +- [ ] **Step 3: Implement MemoryService** + +Extract logic from `packages/cli/src/serve/workspaceMemory.ts`. Pattern identical to AgentsService: validate clientId on mutations, delegate to backend, publish event. + +- [ ] **Step 4: Run tests — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/memoryService.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/memoryService.ts packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts +git commit -m "feat(serve): add MemoryService with event publish (TDD)" +``` + +--- + +## Task 6: Facade + Workspace-Scoped Methods (TDD) + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/facade.test.ts` +- Create: `packages/cli/src/serve/workspace-service/index.ts` + +- [ ] **Step 1: Write failing test for facade construction + status delegation** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +import { describe, it, expect, vi } from 'vitest'; +import { createDaemonWorkspaceService } from '../index.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +const ctx: WorkspaceRequestContext = { route: 'POST /workspace/init', workspaceCwd: '/w' }; + +describe('DaemonWorkspaceService', () => { + function makeDeps(overrides = {}) { + return { + fsFactory: { forRequest: vi.fn().mockReturnValue({}) }, + deviceFlowRegistry: {}, + subagentManager: {}, + boundWorkspace: '/w', + contextFilename: 'QWEN.md', + persistDisabledTools: vi.fn(), + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(), + queryWorkspaceStatus: vi.fn().mockImplementation((_m, idle) => Promise.resolve(idle())), + invokeWorkspaceCommand: vi.fn(), + ...overrides, + }; + } + + it('exposes file, auth, agents, memory sub-services', () => { + const service = createDaemonWorkspaceService(makeDeps()); + expect(service.file).toBeDefined(); + expect(service.auth).toBeDefined(); + expect(service.agents).toBeDefined(); + expect(service.memory).toBeDefined(); + }); + + it('getMcpStatus delegates to queryWorkspaceStatus callback', async () => { + const idle = { servers: [] }; + const queryWorkspaceStatus = vi.fn().mockResolvedValue(idle); + const service = createDaemonWorkspaceService(makeDeps({ queryWorkspaceStatus })); + + const result = await service.getMcpStatus(); + + expect(queryWorkspaceStatus).toHaveBeenCalled(); + expect(result).toBe(idle); + }); + + it('setToolEnabled calls persistDisabledTools + publishes event', async () => { + const persistDisabledTools = vi.fn().mockResolvedValue(undefined); + const publishWorkspaceEvent = vi.fn(); + const service = createDaemonWorkspaceService(makeDeps({ persistDisabledTools, publishWorkspaceEvent })); + + const result = await service.setToolEnabled('Bash', false, ctx); + + expect(persistDisabledTools).toHaveBeenCalledWith('/w', 'Bash', false); + expect(publishWorkspaceEvent).toHaveBeenCalledWith(expect.objectContaining({ + type: 'tool_toggled', + data: { toolName: 'Bash', enabled: false }, + })); + expect(result).toEqual({ toolName: 'Bash', enabled: false }); + }); +}); +``` + +- [ ] **Step 2: Run test — verify fail** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts` +Expected: FAIL + +- [ ] **Step 3: Implement facade factory** + +```ts +// packages/cli/src/serve/workspace-service/index.ts +import type { DaemonWorkspaceService, DaemonWorkspaceServiceDeps } from './types.js'; +import { createFileService } from './fileService.js'; +import { createAuthService } from './authService.js'; +import { createAgentsService } from './agentsService.js'; +import { createMemoryService } from './memoryService.js'; +import { SERVE_STATUS_EXT_METHODS } from '@qwen-code/acp-bridge'; + +export { type DaemonWorkspaceService, type DaemonWorkspaceServiceDeps, type WorkspaceRequestContext } from './types.js'; + +export function createDaemonWorkspaceService(deps: DaemonWorkspaceServiceDeps): DaemonWorkspaceService { + const file = createFileService({ fsFactory: deps.fsFactory, boundWorkspace: deps.boundWorkspace }); + const auth = createAuthService({ deviceFlowRegistry: deps.deviceFlowRegistry }); + const agents = createAgentsService({ + subagentManager: deps.subagentManager, + publishWorkspaceEvent: deps.publishWorkspaceEvent, + knownClientIds: deps.knownClientIds, + }); + const memory = createMemoryService({ + publishWorkspaceEvent: deps.publishWorkspaceEvent, + knownClientIds: deps.knownClientIds, + boundWorkspace: deps.boundWorkspace, + }); + + return { + file, + auth, + agents, + memory, + + async initWorkspace(opts, ctx) { + // Migrate logic from bridge.ts:3256 — local file creation via fsFactory + const fs = deps.fsFactory.forRequest({ originatorClientId: ctx.originatorClientId, route: ctx.route }); + // ... path validation + file creation (copy from bridge.ts:3256-3350) + }, + + async setToolEnabled(toolName, enabled, ctx) { + await deps.persistDisabledTools(deps.boundWorkspace, toolName, enabled); + deps.publishWorkspaceEvent({ + type: 'tool_toggled', + data: { toolName, enabled }, + ...(ctx.originatorClientId ? { originatorClientId: ctx.originatorClientId } : {}), + }); + return { toolName, enabled }; + }, + + async getMcpStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => createIdleMcpStatus(deps.boundWorkspace)); + }, + async getSkillsStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceSkills, () => ({ skills: [] })); + }, + async getProvidersStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceProviders, () => ({ providers: [] })); + }, + async getEnvStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceEnv, () => ({ env: [] })); + }, + async getPreflightStatus() { + return deps.queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspacePreflight, () => ({ checks: [] })); + }, + + async restartMcpServer(serverName, ctx, opts) { + const params: Record = { serverName }; + if (opts?.entryIndex !== undefined) params['entryIndex'] = opts.entryIndex; + const result = await deps.invokeWorkspaceCommand( + SERVE_STATUS_EXT_METHODS.workspaceMcpRestart ?? 'qwen/control/workspace/mcp/restart', + params, + ); + deps.publishWorkspaceEvent({ + type: 'mcp_server_restarted', + data: { serverName, ...(result as object) }, + ...(ctx.originatorClientId ? { originatorClientId: ctx.originatorClientId } : {}), + }); + return result as any; + }, + }; +} +``` + +> **Critical:** `initWorkspace` implementation must be copied from `bridge.ts:3256-3350` (path validation, symlink checks, file creation). Use `fsFactory.forRequest(ctx)` instead of raw `node:fs/promises` — this fixes the existing FIXME. + +- [ ] **Step 4: Run test — verify pass** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/facade.test.ts` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/index.ts packages/cli/src/serve/workspace-service/__tests__/facade.test.ts +git commit -m "feat(serve): add DaemonWorkspaceService facade with status/tool/init/restart (TDD)" +``` + +--- + +## Task 7: Bridge — Expose Child Delegation + Remove Workspace Methods + +**Files:** +- Modify: `packages/acp-bridge/src/bridge.ts` +- Modify: `packages/acp-bridge/src/bridgeTypes.ts` + +- [ ] **Step 1: Add `queryWorkspaceStatus` and `invokeWorkspaceCommand` to bridge interface** + +In `packages/acp-bridge/src/bridgeTypes.ts`, add to the interface (which is still named `HttpAcpBridge` at this point): + +```ts + queryWorkspaceStatus(method: string, idle: () => T): Promise; + invokeWorkspaceCommand(method: string, params?: Record, opts?: { timeoutMs?: number }): Promise; +``` + +- [ ] **Step 2: Implement them in bridge.ts** + +In `packages/acp-bridge/src/bridge.ts`, add to the returned object (near the existing `requestWorkspaceStatus` usage): + +```ts + queryWorkspaceStatus(method, idle) { + return requestWorkspaceStatus(method, idle); + }, + invokeWorkspaceCommand(method, params, opts) { + const info = liveChannelInfo(); + if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); + const timeout = opts?.timeoutMs ?? initTimeoutMs; + return withTimeout( + Promise.race([ + info.connection.extMethod(method, { ...params, cwd: boundWorkspace }), + getChannelClosedReject(info), + ]), + timeout, + method, + ) as Promise; + }, +``` + +- [ ] **Step 3: Remove the 8 workspace methods from bridge** + +Remove from bridge.ts: +- `initWorkspace` (lines ~3256-3550) +- `setWorkspaceToolEnabled` (lines ~3071-3093) +- `getWorkspaceMcpStatus` / `getWorkspaceSkillsStatus` / `getWorkspaceProvidersStatus` / `getWorkspaceEnvStatus` / `getWorkspacePreflightStatus` (lines ~2665-2790) +- `restartMcpServer` (lines ~3093-3256) + +Remove their signatures from `bridgeTypes.ts`. + +- [ ] **Step 4: Run bridge tests to verify nothing is broken** + +Run: `cd packages/acp-bridge && npx vitest run` +Expected: Some tests may reference removed methods — fix those (they should now test via the facade in integration). + +- [ ] **Step 5: Commit** + +```bash +git add packages/acp-bridge/src/bridge.ts packages/acp-bridge/src/bridgeTypes.ts +git commit -m "refactor(bridge): extract workspace methods, expose queryWorkspaceStatus + invokeWorkspaceCommand" +``` + +--- + +## Task 8: Bridge Rename (HttpAcpBridge → AcpSessionBridge) + +**Files:** +- Modify: `packages/acp-bridge/src/bridgeTypes.ts` +- Modify: `packages/acp-bridge/src/bridge.ts` +- Modify: `packages/acp-bridge/src/bridgeOptions.ts` +- Modify: `packages/acp-bridge/src/status.ts` +- Modify: `packages/acp-bridge/src/index.ts` +- Rename: `packages/cli/src/serve/httpAcpBridge.ts` → `packages/cli/src/serve/acpSessionBridge.ts` +- Modify: `packages/cli/src/serve/runQwenServe.ts` (import paths) +- Modify: all files importing `HttpAcpBridge` or `createHttpAcpBridge` + +- [ ] **Step 1: Rename interface + factory function in acp-bridge package** + +In `bridgeTypes.ts`: +```ts +// Before: export interface HttpAcpBridge { +// After: +export interface AcpSessionBridge { +``` + +In `bridge.ts`: +```ts +// Before: export function createHttpAcpBridge( +// After: +export function createAcpSessionBridge( +``` + +Add deprecated re-export for safety: +```ts +/** @deprecated Use AcpSessionBridge */ +export type HttpAcpBridge = AcpSessionBridge; +/** @deprecated Use createAcpSessionBridge */ +export const createHttpAcpBridge = createAcpSessionBridge; +``` + +- [ ] **Step 2: Rename file in cli package** + +```bash +git mv packages/cli/src/serve/httpAcpBridge.ts packages/cli/src/serve/acpSessionBridge.ts +``` + +- [ ] **Step 3: Update all imports project-wide** + +```bash +# Find and fix all references +grep -rn "HttpAcpBridge\|createHttpAcpBridge\|httpAcpBridge" packages/ --include="*.ts" | grep -v node_modules | grep -v ".test.ts" +``` + +Update each file to use new names. Key files: +- `packages/cli/src/serve/runQwenServe.ts` +- `packages/cli/src/serve/workspaceAgents.ts` +- `packages/cli/src/serve/workspaceMemory.ts` +- `packages/cli/src/serve/server.ts` +- `packages/acp-bridge/src/status.ts` (error message string) +- `packages/acp-bridge/src/bridgeOptions.ts` (JSDoc) + +- [ ] **Step 4: Run typecheck** + +Run: `cd packages/cli && npx tsc --noEmit && cd ../acp-bridge && npx tsc --noEmit` +Expected: No type errors + +- [ ] **Step 5: Run full test suites** + +Run: `cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run` +Expected: All pass (tests still use deprecated alias or are updated) + +- [ ] **Step 6: Commit** + +```bash +git add -A +git commit -m "refactor(bridge): rename HttpAcpBridge → AcpSessionBridge" +``` + +--- + +## Task 9: Wire Service into runQwenServe + REST Routes + +**Files:** +- Modify: `packages/cli/src/serve/runQwenServe.ts` +- Modify: `packages/cli/src/serve/server.ts` +- Modify: `packages/cli/src/serve/workspaceAgents.ts` +- Modify: `packages/cli/src/serve/workspaceMemory.ts` +- Modify: `packages/cli/src/serve/routes/workspaceFileRead.ts` +- Modify: `packages/cli/src/serve/routes/workspaceFileWrite.ts` + +- [ ] **Step 1: Construct service in runQwenServe.ts** + +Add after bridge construction: +```ts +import { createDaemonWorkspaceService } from './workspace-service/index.js'; + +// After bridge is created: +const workspace = createDaemonWorkspaceService({ + fsFactory, + deviceFlowRegistry, + subagentManager, // from existing construction + boundWorkspace, + contextFilename, + persistDisabledTools, + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, opts) => bridge.invokeWorkspaceCommand(method, params, opts), +}); +``` + +Pass `workspace` to `createServeApp`. + +- [ ] **Step 2: Rewire workspace status routes in server.ts** + +Replace direct bridge calls with service calls: +```ts +// Before: +app.get('/workspace/mcp', async (_req, res) => { + res.status(200).json(await bridge.getWorkspaceMcpStatus()); +}); + +// After: +app.get('/workspace/mcp', async (_req, res) => { + res.status(200).json(await workspace.getMcpStatus()); +}); +``` + +Repeat for `/workspace/skills`, `/workspace/providers`, `/workspace/env`, `/workspace/preflight`, `/workspace/init`, tool toggle route. + +- [ ] **Step 3: Rewire workspaceAgents.ts route shell** + +Change `mountWorkspaceAgentsRoutes` to receive `workspace.agents` instead of `bridge`: +```ts +// deps.bridge.publishWorkspaceEvent → service handles internally +// deps.bridge.knownClientIds() → service handles internally +// Route handler becomes thin: parse request → build ctx → call service → send response +``` + +- [ ] **Step 4: Rewire workspaceMemory.ts route shell** + +Same pattern as agents. + +- [ ] **Step 5: Rewire file routes** + +`workspaceFileRead.ts` and `workspaceFileWrite.ts` — change from calling `fsFactory.forRequest` directly to calling `workspace.file.*`: +```ts +// Before: +const fs = getFsFactory(req, res); +const result = await fs.readFile(path, maxBytes); + +// After: +const ctx = buildRequestContext(req); +const result = await workspace.file.read(ctx, path, { maxBytes }); +``` + +- [ ] **Step 6: Run full test suite** + +Run: `cd packages/cli && npx vitest run` +Expected: All existing route tests pass (HTTP surface unchanged) + +- [ ] **Step 7: Commit** + +```bash +git add -A +git commit -m "refactor(serve): wire DaemonWorkspaceService into REST routes" +``` + +--- + +## Task 10: /acp Northbound Method Dispatch + +**Files:** +- Modify: relevant `/acp` handler file (locate via `grep -rn "extMethod\|acpHttp\|acp-integration" packages/cli/src/`) +- Create or modify: northbound method dispatcher + +- [ ] **Step 1: Locate the /acp method dispatch entry point** + +```bash +grep -rn "method.*dispatch\|handleMethod\|jsonrpc.*method" packages/cli/src/acp-integration/ packages/cli/src/serve/ --include="*.ts" | grep -v test | head -20 +``` + +- [ ] **Step 2: Add workspace method dispatch** + +In the /acp handler that routes JSON-RPC methods, add a switch/map for `qwen/workspace/*`: + +```ts +// Pattern (exact location depends on codebase structure): +case 'qwen/workspace/fs/read': { + const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/read'); + const { path } = params; + return workspace.file.read(ctx, path); +} +case 'qwen/workspace/fs/write': { + const ctx = buildAcpRequestContext(connection, 'qwen/workspace/fs/write'); + const { path, content, mode } = params; + return workspace.file.write(ctx, path, content, { mode }); +} +// ... all 27 methods +``` + +> Build a helper `buildAcpRequestContext` that extracts clientId from the ACP connection and constructs `WorkspaceRequestContext`. + +- [ ] **Step 3: Add capabilities advertisement** + +Ensure `_meta.qwen.methods` includes all `qwen/workspace/*` methods in the `initialize` response. + +- [ ] **Step 4: Run typecheck** + +Run: `cd packages/cli && npx tsc --noEmit` +Expected: No errors + +- [ ] **Step 5: Commit** + +```bash +git add -A +git commit -m "feat(serve): add /acp northbound workspace methods (27 qwen/workspace/* endpoints)" +``` + +--- + +## Task 11: E2e Equivalence Tests + +**Files:** +- Create: `packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts` + +- [ ] **Step 1: Build /acp test harness helper** + +```ts +// Helper for sending JSON-RPC to /acp endpoint via supertest +import request from 'supertest'; + +async function acpCall(app: any, method: string, params: Record = {}, token = 'test-token') { + const res = await request(app) + .post('/acp') + .set('Authorization', `Bearer ${token}`) + .set('Content-Type', 'application/json') + .send({ jsonrpc: '2.0', id: 1, method, params }); + return res.body; +} +``` + +- [ ] **Step 2: Write equivalence tests** + +```ts +// packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import request from 'supertest'; +import { createServeApp } from '../../server.js'; +// ... setup with mocked bridge + workspace + +describe('REST ↔ /acp equivalence', () => { + let app: any; + + beforeAll(() => { + // Create app with both REST and /acp wired to same workspace service + app = createServeApp({ /* ... test deps */ }); + }); + + describe('file read', () => { + it('returns same content via both transports', async () => { + const restRes = await request(app).get('/file?path=README.md').set('Authorization', 'Bearer tok'); + const acpRes = await acpCall(app, 'qwen/workspace/fs/read', { path: 'README.md' }); + + expect(restRes.body.content).toBe(acpRes.result.content); + }); + }); + + describe('trust gate rejection', () => { + it('rejects invalid clientId via REST (400)', async () => { + const res = await request(app) + .post('/file/write') + .set('Authorization', 'Bearer tok') + .set('X-Qwen-Client-Id', 'unknown-client') + .send({ path: 'x.ts', content: 'y' }); + expect(res.status).toBe(400); + expect(res.body.code).toBe('invalid_client_id'); + }); + + it('rejects invalid clientId via /acp (JSON-RPC error)', async () => { + const res = await acpCall(app, 'qwen/workspace/fs/write', { path: 'x.ts', content: 'y' }); + expect(res.error.code).toBe(-32602); + expect(res.error.message).toContain('invalid_client_id'); + }); + }); +}); +``` + +- [ ] **Step 3: Run e2e tests** + +Run: `cd packages/cli && npx vitest run src/serve/workspace-service/__tests__/e2e.test.ts` +Expected: PASS + +- [ ] **Step 4: Commit** + +```bash +git add packages/cli/src/serve/workspace-service/__tests__/e2e.test.ts +git commit -m "test(serve): add REST ↔ /acp equivalence e2e tests" +``` + +--- + +## Task 12: Final Verification + +- [ ] **Step 1: Run full typecheck across all packages** + +```bash +cd packages/acp-bridge && npx tsc --noEmit && cd ../cli && npx tsc --noEmit && cd ../sdk-typescript && npx tsc --noEmit +``` +Expected: No errors + +- [ ] **Step 2: Run full test suites** + +```bash +cd packages/acp-bridge && npx vitest run && cd ../cli && npx vitest run +``` +Expected: All pass. SDK tests should pass WITHOUT modification (REST surface unchanged). + +- [ ] **Step 3: Verify SDK tests pass unmodified** + +```bash +cd packages/sdk-typescript && npx vitest run +``` +Expected: All pass — confirms backward compatibility. + +- [ ] **Step 4: Run lint** + +```bash +cd packages/cli && npm run lint && cd ../acp-bridge && npm run lint +``` +Expected: No errors + +- [ ] **Step 5: Final commit (if any cleanup needed)** + +```bash +git status +# If clean, no commit needed. If lint fixes: +git add -A && git commit -m "chore: lint fixes" +``` + +- [ ] **Step 6: Verify git log is clean** + +```bash +git log --oneline -15 +``` + +Confirm commits tell a coherent story for the single-PR reviewer. diff --git a/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md b/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md new file mode 100644 index 00000000000..84076d15a8d --- /dev/null +++ b/docs/superpowers/specs/2026-05-27-daemon-workspace-service-design.md @@ -0,0 +1,393 @@ +# DaemonWorkspaceService 实施设计(方案 C) + +> 关联:issue #4542, PR #4472, #3803, #4175 +> 分支:`daemon_mode_b_main` +> 日期:2026-05-27 +> 性质:实施设计文档(面向落地),非 RFC + +--- + +## 1. 架构与边界 + +### 1.1 终态分层 + +``` + CLIENTS + webui SDK/channels(via REST) Zed/Goose(/acp) future + │ │ │ +═════╪═════════════╪═══════════════════════╪═════════════ L1 transport (薄) + REST+SSE REST+SSE /acp (jsonrpc/sse) + server.ts acpHttp/ + └─────────────┴───────────────────────┘ + │ 业务/trust/audit 一律下沉 L2 +═════════════════════════╪═══════════════════════════════ L2 应用层 + ┌──────────────────────────┐ ┌─────────────────────────────────┐ + │ AcpSessionBridge │ │ DaemonWorkspaceService (facade) │ + │ (← HttpAcpBridge 改名) │ │ ┌──────────────────────────┐ │ + │ • channel/session 生命周期 │ │ │ FileService │ │ + │ • prompt / cancel / close │ │ │ AuthService │ │ + │ • EventBus / 权限仲裁 │ │ │ AgentsService │ │ + │ • 依赖 child 的状态内省 │ │ │ MemoryService │ │ + │ (mcp/skills/preflight) │ │ └──────────────────────────┘ │ + └──────────┬───────────────┘ │ 统一 WorkspaceRequestContext │ + │ └──────────┬──────────────────────┘ + │ L3 → child │ + ▼ │ (纯本地,不碰 child) +══════════════════════════════════════════════════════════ L3 ACP-client +══════════════════════════════════════════════════════════ L4 agent +``` + +### 1.2 拆分判定函数 + +**唯一规则:操作的 scope 是 session 还是 workspace?** + +- **session-scoped**(操作特定 sessionId:prompt/cancel/close/model/approval/metadata/heartbeat)**→ 留 `AcpSessionBridge`** +- **workspace-scoped**(操作工作区整体:file/auth/agents/memory/mcp-status/skills/env/preflight/tool-toggle/init)**→ 进 `DaemonWorkspaceService`** + +workspace 方法中部分需要查询 child(status getters、restartMcpServer),通过 **injected callback** 委托 bridge 的 channel 完成,service 本身不持有 connection。 + +### 1.3 跨切依赖:callback 注入(非共享 infra) + +当前 `publishWorkspaceEvent` 和 `knownClientIds` 由 bridge 持有(per-session bus fan-out / session-derived)。service 通过 **单向 callback 注入** 使用它们,不引入共享基础设施层。 + +**理由:** +1. EventBus 是 per-session bus(`bridge.ts:1457`),workspace-level bus 在代码注释中已挂在 PR 24(`bridge.ts:2611`) +2. `knownClientIds` 同样是派生自 session-attach state,注释明确 "PR 24 will replace it"(`bridge.ts:2658`) +3. 这两件是已立项独立工作,硬绑进本 PR 等于叠加额外 refactor +4. callback 注入对 service 是单向依赖(只持函数引用,不知道来自 bridge);PR 24 落地后换注入源即可,service 接口不变 + +**硬规则:** +1. `DaemonWorkspaceServiceDeps` 中不得出现 `AcpSessionBridge` 类型引用——只用函数签名。 +2. bridge 对外新暴露 `queryWorkspaceStatus` 和 `invokeWorkspaceCommand` 两个方法,供 service 通过 callback 调用。内部仍使用现有的 `requestWorkspaceStatus` / `liveChannelInfo` + timeout 逻辑,不新建抽象。 + +--- + +## 2. 构造时序与依赖注入 + +```ts +// runQwenServe.ts 中的构造顺序 + +// 1. fsFactory 先构造(两者共享) +const fsFactory = resolveBridgeFsFactory({ ... }); + +// 2. bridge 先构造(它是 session/channel/EventBus 的 owner) +const bridge = createAcpSessionBridge({ + eventRingSize, + boundWorkspace, + fileSystem: createBridgeFileSystemAdapter(fsFactory), + // ... 其他现有参数不变 +}); + +// 3. service 后构造,接收 bridge 的 callback 集 +const workspace = createDaemonWorkspaceService({ + fsFactory, + deviceFlowRegistry, + subagentManager, + boundWorkspace, + contextFilename, + // 跨切 callback — service 不知道它们来自 bridge + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + // child 委托 callback — workspace-scoped ext method 通过 bridge 的 channel 到达 agent + queryWorkspaceStatus: (method, idle) => bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, opts) => bridge.invokeWorkspaceCommand(method, params, opts), +}); + +// 4. 两者传给 server routes + /acp handler +createServeApp({ bridge, workspace, ... }); +``` + +**构造顺序 bridge → service 是硬依赖**(service 需要 bridge 实例上的方法作为 callback 源)。 + +--- + +## 3. DaemonWorkspaceService 内部结构 + +### 3.1 目录布局 + +``` +packages/cli/src/serve/workspace-service/ +├── types.ts ← WorkspaceRequestContext + sub-service interfaces +├── index.ts ← facade factory (createDaemonWorkspaceService) +├── fileService.ts ← wraps fsFactory +├── authService.ts ← wraps DeviceFlowRegistry +├── agentsService.ts ← wraps SubagentManager +├── memoryService.ts ← wraps memory file ops +└── __tests__/ + ├── fileService.test.ts + ├── authService.test.ts + ├── agentsService.test.ts + ├── memoryService.test.ts + └── e2e.test.ts +``` + +### 3.2 Facade 接口 + +```ts +export interface DaemonWorkspaceService { + file: FileService; + auth: AuthService; + agents: AgentsService; + memory: MemoryService; + + // 纯本地 + initWorkspace(opts: InitWorkspaceOpts, ctx: WorkspaceRequestContext): Promise; + setToolEnabled(toolName: string, enabled: boolean, ctx: WorkspaceRequestContext): Promise; + + // 通过 callback 委托 child + getMcpStatus(): Promise; + getSkillsStatus(): Promise; + getProvidersStatus(): Promise; + getEnvStatus(): Promise; + getPreflightStatus(): Promise; + restartMcpServer(serverName: string, ctx: WorkspaceRequestContext, opts?: RestartOpts): Promise; +} +``` + +> `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `publishWorkspaceEvent` / `knownClientIds` 留在 bridge——它们访问 bridge 内部的 per-session state(`byId` map / session bus),是 session 衍生的基础设施。service 通过 callback 消费,不直接拥有。 + +### 3.3 Facade Factory 签名 + +```ts +export interface DaemonWorkspaceServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + deviceFlowRegistry: DeviceFlowRegistry; + subagentManager: SubagentManager; + boundWorkspace: string; + contextFilename: string; + persistDisabledTools: (workspace: string, tool: string, enabled: boolean) => Promise; + + // 跨切 callback(session 衍生基础设施) + publishWorkspaceEvent: (event: WorkspaceEvent) => void; + knownClientIds: () => Set; + + // child 委托 callback(workspace-scoped ext method 通过 bridge channel 到达 agent) + queryWorkspaceStatus: (method: string, idle: () => T) => Promise; + invokeWorkspaceCommand: (method: string, params?: Record, opts?: { timeoutMs?: number }) => Promise; +} + +export function createDaemonWorkspaceService( + deps: DaemonWorkspaceServiceDeps +): DaemonWorkspaceService; +``` + +### 3.4 各子服务接口 + +| 子服务 | 方法 | 所需 deps | 现有来源 | +|---|---|---|---| +| FileService | `read`, `readBytes`, `write`, `edit`, `glob`, `list`, `stat` | `fsFactory`, `boundWorkspace` | `serve/routes/workspaceFileRead.ts`, `workspaceFileWrite.ts`, `serve/fs/` | +| AuthService | `startFlow`, `getFlowStatus(flowId)`, `cancelFlow(flowId)`, `getAuthStatus` | `deviceFlowRegistry` | `serve/auth/deviceFlow.ts`, `server.ts:794-966` | +| AgentsService | `list`, `get(agentType)`, `create`, `update`, `delete` | `subagentManager`, `publishWorkspaceEvent`, `knownClientIds` | `serve/workspaceAgents.ts` | +| MemoryService | `list`, `read`, `write`, `delete` | `fsFactory` or direct fs, `publishWorkspaceEvent`, `knownClientIds` | `serve/workspaceMemory.ts` | + +每个方法第一个参数都是 `ctx: WorkspaceRequestContext`,trust gate 在方法入口统一执行。 + +--- + +## 4. WorkspaceRequestContext + +```ts +export interface WorkspaceRequestContext { + originatorClientId?: string; // X-Qwen-Client-Id header(只读操作可缺失) + sessionId?: string; // audit 关联(如从 session context 内发起的操作) + route: string; // audit trail(如 "POST /file/write") + workspaceCwd: string; // trust boundary root +} +``` + +> `originatorClientId` 为 optional——当前 file read 等只读路由在 header 缺失时照常工作(`clientId ?? undefined` 传入 `fsFactory.forRequest`)。write 路由在 clientId **存在时**才校验合法性。 + +**构建位置**:L1 route handler / `/acp` method handler 从 request headers/params 提取后传入 L2。L2 只消费,不自行提取 HTTP context。 + +--- + +## 5. AcpSessionBridge 瘦身与改名 + +### 5.1 从 bridge 迁出的方法 + +| 方法 | 去向 | 机制 | 理由 | +|---|---|---|---| +| `initWorkspace` | `workspace.initWorkspace` | 直接迁(纯本地) | 附带修 FIXME(bridge 没接 fsFactory,跳过 trust gate / audit) | +| `setWorkspaceToolEnabled` | `workspace.setToolEnabled` | 直接迁(纯本地) | 纯 file I/O + event fan-out,注释明确 "no ACP roundtrip" | +| `getWorkspaceMcpStatus` | `workspace.getMcpStatus` | via `queryWorkspaceStatus` callback | workspace-scoped status query | +| `getWorkspaceSkillsStatus` | `workspace.getSkillsStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspaceProvidersStatus` | `workspace.getProvidersStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspaceEnvStatus` | `workspace.getEnvStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `getWorkspacePreflightStatus` | `workspace.getPreflightStatus` | via `queryWorkspaceStatus` callback | 同上 | +| `restartMcpServer` | `workspace.restartMcpServer` | via `invokeWorkspaceCommand` callback | workspace-scoped mutation | + +> `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `updateSessionMetadata` 保留在 bridge——它们访问 bridge 内部 `byId` session map,是 session-scoped 操作。 + +### 5.2 留在 bridge 的 + +- 所有 session/channel 生命周期(spawn/load/resume/send/cancel/close/kill/detach) +- EventBus 持有 + `publishWorkspaceEvent` fan-out 实现(供 service callback 消费) +- `knownClientIds`(供 service callback 消费) +- `queryWorkspaceStatus` / `invokeWorkspaceCommand`(新暴露,封装 channel + timeout + error,供 service callback 委托) +- 权限仲裁 mediator +- session 配置变更(model/approvalMode/recap) +- session 状态(context/supportedCommands/metadata/heartbeat/listSessions) + +### 5.3 改名 + +- `HttpAcpBridge` → `AcpSessionBridge` +- `createHttpAcpBridge` → `createAcpSessionBridge` +- 文件 `serve/httpAcpBridge.ts` → `serve/acpSessionBridge.ts` + +无外部包消费者(验证过 `packages/cli/src/serve/` 和 `packages/acp-bridge/src/` 之外无引用),内部安全。 + +--- + +## 6. /acp northbound ext methods + +### 6.1 命名空间 + +`qwen/workspace/...`(与现有 `qwen/control/...` 区分): +- `qwen/control/...` = daemon→child 转发命令(southbound,经 AcpSessionBridge) +- `qwen/workspace/...` = daemon 本地工作区操作(northbound,终止于 DaemonWorkspaceService) + +> 待 chiga0 确认。如改命名空间只需换方法名前缀,不影响架构。 + +### 6.2 方法列表 + +| method | 对应 REST | L2 调用 | +|---|---|---| +| `qwen/workspace/fs/read` | `GET /file?path=...` | `workspace.file.read(ctx, path)` | +| `qwen/workspace/fs/readBytes` | `GET /file/bytes?path=...` | `workspace.file.readBytes(ctx, path)` | +| `qwen/workspace/fs/write` | `POST /file/write` | `workspace.file.write(ctx, path, content)` | +| `qwen/workspace/fs/edit` | `POST /file/edit` | `workspace.file.edit(ctx, path, edits)` | +| `qwen/workspace/fs/glob` | `GET /glob?pattern=...` | `workspace.file.glob(ctx, pattern)` | +| `qwen/workspace/fs/list` | `GET /list?path=...` | `workspace.file.list(ctx, path)` | +| `qwen/workspace/fs/stat` | `GET /stat?path=...` | `workspace.file.stat(ctx, path)` | +| `qwen/workspace/auth/start` | `POST /workspace/auth/device-flow` | `workspace.auth.startFlow(ctx)` | +| `qwen/workspace/auth/status` | `GET /workspace/auth/status` | `workspace.auth.getAuthStatus(ctx)` | +| `qwen/workspace/auth/flow` | `GET /workspace/auth/device-flow/:id` | `workspace.auth.getFlowStatus(ctx, flowId)` | +| `qwen/workspace/auth/cancel` | `POST /workspace/auth/device-flow/:id` (cancel) | `workspace.auth.cancelFlow(ctx, flowId)` | +| `qwen/workspace/agents/list` | `GET /workspace/agents` | `workspace.agents.list(ctx)` | +| `qwen/workspace/agents/get` | `GET /workspace/agents/:agentType` | `workspace.agents.get(ctx, agentType)` | +| `qwen/workspace/agents/create` | `POST /workspace/agents` | `workspace.agents.create(ctx, spec)` | +| `qwen/workspace/agents/update` | `POST /workspace/agents/:agentType` | `workspace.agents.update(ctx, agentType, spec)` | +| `qwen/workspace/agents/delete` | `DELETE /workspace/agents/:agentType` | `workspace.agents.delete(ctx, agentType)` | +| `qwen/workspace/memory/list` | `GET /workspace/memory` | `workspace.memory.list(ctx)` | +| `qwen/workspace/memory/read` | `GET /workspace/memory/:key` | `workspace.memory.read(ctx, key)` | +| `qwen/workspace/memory/write` | `POST /workspace/memory` | `workspace.memory.write(ctx, key, content)` | +| `qwen/workspace/memory/delete` | `DELETE /workspace/memory/:key` | `workspace.memory.delete(ctx, key)` | +| `qwen/workspace/init` | `POST /workspace/init` | `workspace.initWorkspace(ctx, opts)` | +| `qwen/workspace/tool/toggle` | `POST /workspace/tool/toggle` | `workspace.setToolEnabled(ctx, toolName, enabled)` | +| `qwen/workspace/status/mcp` | `GET /workspace/mcp` | `workspace.getMcpStatus()` | +| `qwen/workspace/status/skills` | `GET /workspace/skills` | `workspace.getSkillsStatus()` | +| `qwen/workspace/status/providers` | `GET /workspace/providers` | `workspace.getProvidersStatus()` | +| `qwen/workspace/status/env` | `GET /workspace/env` | `workspace.getEnvStatus()` | +| `qwen/workspace/status/preflight` | `GET /workspace/preflight` | `workspace.getPreflightStatus()` | +| `qwen/workspace/mcp/restart` | `POST /workspace/mcp/restart` | `workspace.restartMcpServer(ctx, serverName, opts)` | + +Capabilities advertise 时在 `_meta.qwen.methods` 中声明这些方法。 + +--- + +## 7. 文件变更清单 + +### 7.1 新增 + +| 文件 | 用途 | +|---|---| +| `serve/workspace-service/types.ts` | `WorkspaceRequestContext` + sub-service interfaces | +| `serve/workspace-service/index.ts` | facade factory | +| `serve/workspace-service/fileService.ts` | FileService 实现 | +| `serve/workspace-service/authService.ts` | AuthService 实现 | +| `serve/workspace-service/agentsService.ts` | AgentsService 实现 | +| `serve/workspace-service/memoryService.ts` | MemoryService 实现 | +| `serve/workspace-service/__tests__/fileService.test.ts` | unit test | +| `serve/workspace-service/__tests__/authService.test.ts` | unit test | +| `serve/workspace-service/__tests__/agentsService.test.ts` | unit test | +| `serve/workspace-service/__tests__/memoryService.test.ts` | unit test | +| `serve/workspace-service/__tests__/e2e.test.ts` | 端到端 REST ↔ /acp 等价验证 | + +### 7.2 修改 + +| 文件 | 变更 | +|---|---| +| `acp-bridge/src/bridge.ts` | 移除 8 个 workspace 方法(initWorkspace / setWorkspaceToolEnabled / 5 status getters / restartMcpServer);新暴露 `queryWorkspaceStatus` + `invokeWorkspaceCommand`;重命名工厂函数 | +| `acp-bridge/src/bridgeTypes.ts` | 接口改名 `HttpAcpBridge` → `AcpSessionBridge`;移除 8 个 workspace 方法签名;新增 `queryWorkspaceStatus` + `invokeWorkspaceCommand` 签名 | +| `acp-bridge/src/bridgeOptions.ts` | 更新 JSDoc 引用 | +| `acp-bridge/src/status.ts` | 更新错误消息中的类名 | +| `cli/src/serve/httpAcpBridge.ts` → 改名 `acpSessionBridge.ts` | re-export 更新 | +| `cli/src/serve/runQwenServe.ts` | 构造 `DaemonWorkspaceService`,注入 callback,传给 routes 和 /acp handler | +| `cli/src/serve/server.ts` | routes 从直连 `fsFactory`/`DeviceFlowRegistry` 改为调 `workspace.file.*` / `workspace.auth.*` | +| `cli/src/serve/workspaceAgents.ts` | 业务逻辑迁入 `agentsService.ts`;原文件变成 route handler 薄壳(构建 ctx → 调 service) | +| `cli/src/serve/workspaceMemory.ts` | 同上 | +| `cli/src/serve/routes/workspaceFileRead.ts` | 同上 | +| `cli/src/serve/routes/workspaceFileWrite.ts` | 同上 | +| `/acp` handler(`acp-integration/` 或 `serve/` 内) | 新增 northbound method dispatch | + +--- + +## 8. SDK 兼容与错误格式 + +### 8.1 SDK backward compat + +REST API surface(路径、HTTP 方法、请求/响应 JSON schema)保持不变。`sdk-typescript` 中的 `DaemonClient` / `DaemonSessionClient` 无需任何改动。 + +验证方式:现有 `packages/sdk-typescript/test/unit/DaemonClient.test.ts` 和 `DaemonSessionClient.test.ts` 在本 PR 中必须零修改通过。 + +### 8.2 /acp trust gate 拒绝的错误格式 + +两传输语义等价但编码不同: + +| 场景 | REST | /acp (JSON-RPC) | +|---|---|---| +| 无效/缺失 bearer token | `401 { error, code: "unauthorized" }` | `{ error: { code: -32001, message: "unauthorized" } }` | +| 无效 clientId | `400 { error, code: "invalid_client_id" }` | `{ error: { code: -32602, message: "invalid_client_id", data: {...} } }` | +| trust gate 拒绝(路径逃逸等) | `403 { error, code: "forbidden" }` | `{ error: { code: -32003, message: "forbidden", data: {...} } }` | + +> JSON-RPC error codes 遵循 [ACP error code registry](https://spec.acpprotocol.org)(标准范围 -32000 ~ -32099 为 server-defined application errors)。具体 code 值在实现时对齐 `/acp` 现有 error 映射逻辑(`acp-integration/errorCodes.ts`)。 + +--- + +## 9. 测试策略 + +| 层 | 测试类型 | 覆盖目标 | +|---|---|---| +| Sub-service unit | Jest,mock fsFactory / DeviceFlowRegistry / SubagentManager / callbacks | 业务逻辑正确性 + trust gate 拒绝非法 clientId | +| Route integration | 现有 route test 改为经 service(验证 HTTP surface 不变) | 回归保障,REST 路径不 break | +| E2e 等价验证 | 启动真实 serve + HTTP 请求 | REST 和 `/acp` 对同一操作返回等价结果;trust gate 两端一致拒绝 | + +### E2e 验证矩阵 + +- File read/write:REST `GET /file` vs `/acp` `qwen/workspace/fs/read` → 同结果 +- Agent CRUD:REST `POST /workspace/agents` vs `/acp` `qwen/workspace/agents/create` → 同行为 +- Trust gate rejection:无效 clientId 两路径都 403 +- Workspace init:验证 fsFactory 走通 + audit trail 产出 + +--- + +## 10. PR 形态 + +单 PR 原子提交,包含: +- DaemonWorkspaceService 全部新建文件 +- REST route handler 改为调 service +- bridge 瘦身(迁出 8 个 workspace 方法)+ 新暴露 2 个 child 委托方法 +- `HttpAcpBridge` → `AcpSessionBridge` 改名 +- `/acp` northbound ext methods 新增(27 个) +- 全量测试(unit + integration + e2e) + +--- + +## 11. 明确不做(scope boundary) + +- workspace-scoped EventBus(PR 24 territory) +- workspace-scoped ClientRegistry(PR 24 territory) +- L2 ↔ L3 拆分(把 `ClientSideConnection` 从 bridge 拆出) +- REST 做成 `/acp` compat shim(长期方向) +- channels standalone 模式统一(独立部署形态问题) +- `listWorkspaceSessions` / `recordHeartbeat` / `getHeartbeatState` / `updateSessionMetadata` 迁移(session-scoped,保留原位) +- `publishWorkspaceEvent` / `knownClientIds` 的 ownership 转移(session 衍生基础设施,保留 bridge 持有,service 通过 callback 消费) + +--- + +## 12. 待 chiga0 确认的决策点 + +1. `/acp` northbound 命名空间:`qwen/workspace/...` vs 其他(如复用 `qwen/control/...`) +2. 改名是否同 PR:倾向同 PR,但可按反馈拆出 + +> 以上两点如需调整,只影响命名和 commit 边界,不影响架构。 diff --git a/docs/users/qwen-serve.md b/docs/users/qwen-serve.md index 25a8ad886b0..9a50e00a14d 100644 --- a/docs/users/qwen-serve.md +++ b/docs/users/qwen-serve.md @@ -77,7 +77,8 @@ The `workspaceCwd` field surfaces the bound workspace so clients can pre-flight The daemon also exposes read-only runtime snapshots for client UIs: `GET /workspace/mcp`, `GET /workspace/skills`, `GET /workspace/providers`, `GET /workspace/env`, `GET /workspace/preflight`, -`GET /session/:id/context`, and `GET /session/:id/supported-commands`. +`GET /session/:id/context`, `GET /session/:id/supported-commands`, and +`GET /session/:id/tasks`. `GET /workspace/mcp`, `GET /workspace/skills`, and `GET /workspace/providers` report the live ACP runtime and do not start the ACP child when idle; an diff --git a/integration-tests/cli/qwen-serve-routes.test.ts b/integration-tests/cli/qwen-serve-routes.test.ts index 2878b96b194..8bdc031b738 100644 --- a/integration-tests/cli/qwen-serve-routes.test.ts +++ b/integration-tests/cli/qwen-serve-routes.test.ts @@ -214,6 +214,7 @@ describe('qwen serve — capabilities envelope', () => { 'workspace_preflight', 'session_context', 'session_supported_commands', + 'session_tasks', 'session_close', 'session_metadata', 'mcp_guardrails', diff --git a/packages/acp-bridge/README.md b/packages/acp-bridge/README.md index 7197ef6e12e..3a441d06c4a 100644 --- a/packages/acp-bridge/README.md +++ b/packages/acp-bridge/README.md @@ -34,7 +34,7 @@ Lift history (#4175 Mode B daemon roadmap): this interface. - `status` (PR 22b/1) — wire-contract status types for `/workspace/{mcp,skills,providers,env,preflight}` and - `/session/:id/{context,supported-commands}` routes, the + `/session/:id/{context,supported-commands,tasks}` routes, the `STATUS_SCHEMA_VERSION` / `SERVE_*_EXT_METHODS` constants, `BridgeTimeoutError` / `MissingCliEntryError` / `BridgeChannelClosedError` typed exceptions, and the diff --git a/packages/acp-bridge/src/bridge.test.ts b/packages/acp-bridge/src/bridge.test.ts index 0f0c93a7498..26632d7b96d 100644 --- a/packages/acp-bridge/src/bridge.test.ts +++ b/packages/acp-bridge/src/bridge.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'; +import { afterEach, describe, it, expect, vi } from 'vitest'; import { randomBytes } from 'node:crypto'; import { promises as fsp } from 'node:fs'; import * as os from 'node:os'; @@ -19,8 +19,10 @@ import type { Agent, InitializeResponse, LoadSessionResponse, + PromptRequest, PromptResponse, ResumeSessionResponse, + RequestPermissionResponse, } from '@agentclientprotocol/sdk'; import { InvalidClientIdError, @@ -30,17 +32,11 @@ import { NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE, RestoreInProgressError, SessionNotFoundError, - McpServerNotFoundError, - McpServerRestartFailedError, - WorkspaceInitConflictError, - WorkspaceInitPathEscapeError, - WorkspaceInitSymlinkError, - WorkspaceInitRaceError, WorkspaceMismatchError, } from './bridgeErrors.js'; import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js'; -import { createHttpAcpBridge } from './bridge.js'; import type { ChannelFactory } from './channel.js'; +import type { BridgeTelemetry } from './bridgeOptions.js'; import { createInMemoryChannel } from './inMemoryChannel.js'; import type { BridgeEvent } from './eventBus.js'; import { ApprovalMode } from '@qwen-code/qwen-code-core'; @@ -54,7 +50,7 @@ import { SESS_A, } from './internal/testUtils.js'; -describe('createHttpAcpBridge', () => { +describe('createAcpSessionBridge', () => { it('accepts a valid BridgeOptions.eventRingSize at construction time', () => { // Smoke: positive finite integers are accepted; the underlying // EventBus ring-size threading is exercised end-to-end in @@ -89,6 +85,60 @@ describe('createHttpAcpBridge', () => { ); }); + it('uses bridge telemetry for channel/session/prompt dispatch and prompt metadata injection', async () => { + const handle = makeChannel(); + const operations: string[] = []; + const telemetry: BridgeTelemetry = { + captureContext: () => ({ captured: true }), + async runWithContext(_captured, fn) { + return await fn(); + }, + async withSpan(operation, _attributes, fn) { + operations.push(operation); + return await fn(); + }, + event() {}, + injectPromptContext(request) { + const meta = + (request as { _meta?: Record })._meta ?? {}; + return { + ...request, + _meta: { + ...meta, + 'qwen.telemetry.traceparent': 'daemon-traceparent', + }, + }; + }, + }; + const bridge = makeBridge({ + channelFactory: async () => handle.channel, + telemetry, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + await bridge.sendPrompt(session.sessionId, { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'hello' }], + _meta: { + keep: 'value', + 'qwen.telemetry.traceparent': 'client-spoof', + }, + } as PromptRequest); + + expect(operations).toEqual( + expect.arrayContaining([ + 'channel.spawn', + 'channel.initialize', + 'session.new', + 'prompt.dispatch', + ]), + ); + expect(handle.agent.promptCalls[0]!._meta).toMatchObject({ + keep: 'value', + 'qwen.telemetry.traceparent': 'daemon-traceparent', + }); + }); + it('forwards childEnvOverrides to the channelFactory at spawn time (#4247 R6 line 216)', async () => { // Round 6 (wenshao R5 line 216): pre-fix `runQwenServe` set // `process.env` globally to pass the MCP budget config to the @@ -182,64 +232,33 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); - it('does not spawn a channel for idle workspace status snapshots', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridge({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; - }, - }); - - await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - servers: [], - }); - await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - skills: [], - }); - await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: false, - providers: [], - }); - expect(handles).toHaveLength(0); - }); - - it('requests workspace status through the existing ACP channel', async () => { + it('requests session status through the existing ACP channel', async () => { const handles: ChannelHandle[] = []; const bridge = makeBridge({ channelFactory: async () => { const h = makeChannel({ - extMethodImpl: (method) => { - if (method === 'qwen/status/workspace/mcp') { + extMethodImpl: (method, params) => { + if (method === 'qwen/status/session/context') { return { v: 1, + sessionId: params['sessionId'], workspaceCwd: WS_A, - initialized: true, - servers: [], + state: {}, }; } - if (method === 'qwen/status/workspace/skills') { + if (method === 'qwen/status/session/tasks') { return { v: 1, - workspaceCwd: WS_A, - initialized: true, - skills: [], + sessionId: params['sessionId'], + now: 1_700_000_000_000, + tasks: [], }; } return { v: 1, - workspaceCwd: WS_A, - initialized: true, - providers: [], + sessionId: params['sessionId'], + availableCommands: [], + availableSkills: [], }; }, }); @@ -247,170 +266,58 @@ describe('createHttpAcpBridge', () => { return h.channel; }, }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - await expect(bridge.getWorkspaceMcpStatus()).resolves.toMatchObject({ - initialized: true, + await expect( + bridge.getSessionContextStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + state: {}, }); - await expect(bridge.getWorkspaceSkillsStatus()).resolves.toMatchObject({ - initialized: true, + await expect( + bridge.getSessionSupportedCommandsStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + availableCommands: [], + availableSkills: [], }); - await expect(bridge.getWorkspaceProvidersStatus()).resolves.toMatchObject({ - initialized: true, + await expect( + bridge.getSessionTasksStatus(session.sessionId), + ).resolves.toMatchObject({ + sessionId: session.sessionId, + tasks: [], }); - - expect(handles).toHaveLength(1); expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ - 'qwen/status/workspace/mcp', - 'qwen/status/workspace/skills', - 'qwen/status/workspace/providers', - ]); - expect(handles[0]?.agent.extMethodCalls.map((c) => c.params)).toEqual([ - { cwd: WS_A }, - { cwd: WS_A }, - { cwd: WS_A }, + 'qwen/status/session/context', + 'qwen/status/session/supported_commands', + 'qwen/status/session/tasks', ]); await bridge.shutdown(); }); - // #4175 F1 test split: the 4 daemon-host integration tests that - // wire real `createDaemonStatusProvider()` moved to - // cli/src/serve/daemonStatusProvider.test.ts (env real / preflight - // idle / preflight merged-live / preflight extMethod-throws). The - // 4 "Mode A fallback" tests below cover no-provider / throwing- - // provider semantics for env + preflight surfaces, which are pure - // bridge resilience logic with no daemon-host coupling. - - it('returns idle env envelope when statusProvider is omitted (Mode A fallback)', async () => { - // PR 22b/2 fold-in: covers the no-provider branch in - // `getWorkspaceEnvStatus`. Production `runQwenServe` and - // `createServeApp` both wire `createDaemonStatusProvider()`, but - // direct embeds (Mode A in-process consumers, future) may omit it. - // The bridge must still answer the route — falling back to the - // shared `createIdleEnvStatus` helper rather than throwing. - const bridge = makeBridge({ statusProvider: undefined }); - - const idle = await bridge.getWorkspaceEnvStatus(); - expect(idle).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - cells: [], - }); - - await bridge.shutdown(); - }); - - it('returns empty daemon preflight cells when statusProvider is omitted (Mode A fallback)', async () => { - // PR 22b/2 fold-in: covers the no-provider branch in - // `getWorkspacePreflightStatus`. ACP-side cells still render - // (idle `not_started` placeholders here since no channel is up); - // only the daemon-host half is empty. - const bridge = makeBridge({ statusProvider: undefined }); - - const status = await bridge.getWorkspacePreflightStatus(); - expect(status).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - - // No daemon cells; only ACP-side `not_started` placeholders. - const daemonCells = status.cells.filter((c) => c.locality === 'daemon'); - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(daemonCells).toHaveLength(0); - expect(acpCells.length).toBeGreaterThan(0); - expect(acpCells.every((c) => c.status === 'not_started')).toBe(true); - - await bridge.shutdown(); - }); - - it('falls back to idle env envelope when statusProvider.getEnvStatus throws', async () => { - // PR 22b/2 wenshao [Critical] fold-in: a custom provider that - // throws would otherwise propagate past the bridge into the route - // handler as a 500. The catch-and-log preserves the - // pre-injection invariant that `/workspace/env` always answers, - // even when the daemon-host helper is sick. - const throwingProvider = { - async getEnvStatus(): Promise { - throw new Error('boom — env collector crashed'); - }, - async getDaemonPreflightCells(): Promise { - return []; - }, - }; - const bridge = makeBridge({ statusProvider: throwingProvider }); - - const env = await bridge.getWorkspaceEnvStatus(); - expect(env).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - cells: [], - }); - - await bridge.shutdown(); - }); - - it('falls back to empty daemon cells when statusProvider.getDaemonPreflightCells throws', async () => { - // PR 22b/2 wenshao [Critical] fold-in: parallel to env — a - // throwing preflight provider must NOT take down the route, so - // the ACP-side cells still render even when the daemon-side - // collector is sick. - const throwingProvider = { - async getEnvStatus(): Promise { - throw new Error('unused'); - }, - async getDaemonPreflightCells(): Promise { - throw new Error('boom — preflight collector crashed'); - }, - }; - const bridge = makeBridge({ statusProvider: throwingProvider }); - - const status = await bridge.getWorkspacePreflightStatus(); - expect(status).toMatchObject({ - v: 1, - workspaceCwd: WS_A, - initialized: true, - acpChannelLive: false, - }); - const daemonCells = status.cells.filter((c) => c.locality === 'daemon'); - const acpCells = status.cells.filter((c) => c.locality === 'acp'); - expect(daemonCells).toHaveLength(0); - expect(acpCells.length).toBeGreaterThan(0); - - await bridge.shutdown(); - }); - - // #4175 F1 test split (continued): the 3 preflight integration tests - // (idle / merged-live / extMethod-throws) that moved to - // cli/src/serve/daemonStatusProvider.test.ts originally sat here, - // between the Mode A fallback tests and the session status tests. - - it('requests session status through the existing ACP channel', async () => { + it('requests session tasks status without waiting for the prompt queue', async () => { + let releasePrompt: (() => void) | undefined; const handles: ChannelHandle[] = []; const bridge = makeBridge({ channelFactory: async () => { const h = makeChannel({ + promptImpl: async () => { + await new Promise((resolve) => { + releasePrompt = resolve; + }); + return { stopReason: 'end_turn' }; + }, extMethodImpl: (method, params) => { - if (method === 'qwen/status/session/context') { + if (method === 'qwen/status/session/tasks') { return { v: 1, sessionId: params['sessionId'], - workspaceCwd: WS_A, - state: {}, + now: 1_700_000_000_000, + tasks: [], }; } - return { - v: 1, - sessionId: params['sessionId'], - availableCommands: [], - availableSkills: [], - }; + throw new Error(`unexpected extMethod ${method}`); }, }); handles.push(h); @@ -418,25 +325,28 @@ describe('createHttpAcpBridge', () => { }, }); const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - - await expect( - bridge.getSessionContextStatus(session.sessionId), - ).resolves.toMatchObject({ + const prompt = bridge.sendPrompt(session.sessionId, { sessionId: session.sessionId, - state: {}, + prompt: [{ type: 'text', text: 'never resolves until released' }], + }); + + await vi.waitFor(() => { + expect(handles[0]?.agent.promptCalls).toHaveLength(1); }); + await expect( - bridge.getSessionSupportedCommandsStatus(session.sessionId), + bridge.getSessionTasksStatus(session.sessionId), ).resolves.toMatchObject({ sessionId: session.sessionId, - availableCommands: [], - availableSkills: [], + tasks: [], }); + expect(handles[0]?.agent.promptCalls).toHaveLength(1); expect(handles[0]?.agent.extMethodCalls.map((c) => c.method)).toEqual([ - 'qwen/status/session/context', - 'qwen/status/session/supported_commands', + 'qwen/status/session/tasks', ]); + releasePrompt?.(); + await prompt; await bridge.shutdown(); }); @@ -451,6 +361,9 @@ describe('createHttpAcpBridge', () => { await expect( bridge.getSessionSupportedCommandsStatus('missing'), ).rejects.toBeInstanceOf(SessionNotFoundError); + await expect( + bridge.getSessionTasksStatus('missing'), + ).rejects.toBeInstanceOf(SessionNotFoundError); }); it('reuses an echoed daemon-issued client id on attach', async () => { @@ -2269,6 +2182,177 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('emits prompt_cancelled at most once when cancelSession races the SSE abort (D2)', async () => { + // doudouOUC #4484 post-merge review (D2): a client that POSTs + // /cancel and then immediately drops its socket triggers BOTH + // `cancelSession` and the `sendPrompt` abort path for the same turn. + // The `cancelBroadcast` latch must dedup so peers see exactly one + // `prompt_cancelled`. + let releasePrompt: (() => void) | undefined; + const factory: ChannelFactory = async () => + makeChannel({ + promptImpl: async () => { + await new Promise((res) => { + releasePrompt = res; + }); + return { stopReason: 'cancelled' }; + }, + }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const cancelEvents: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') cancelEvents.push(e); + } + })(); + + const promptAbort = new AbortController(); + const promptPromise = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'long running' }], + }, + promptAbort.signal, + { clientId: session.clientId }, + ) + .catch(() => {}); + + await new Promise((r) => setTimeout(r, 10)); + // Both cancel routes fire for the same active prompt. + await bridge.cancelSession( + session.sessionId, + { sessionId: session.sessionId }, + { clientId: session.clientId }, + ); + promptAbort.abort(); + + releasePrompt?.(); + await promptPromise; + await new Promise((r) => setTimeout(r, 10)); + peerAbort.abort(); + await collecting; + // Exactly one broadcast despite two cancel triggers. + expect(cancelEvents).toHaveLength(1); + await bridge.shutdown(); + }); + + it('resets the cancel-broadcast latch per prompt (a second prompt re-broadcasts)', async () => { + // Guards the `entry.cancelBroadcast = false` reset at prompt start: if it + // were removed, every cancel after the first deduped turn would be + // silently suppressed. Cancel prompt 1 (latch sets), then cancel prompt 2 + // — peers must see a SECOND prompt_cancelled. + const releasers: Array<() => void> = []; + const factory: ChannelFactory = async () => + makeChannel({ + promptImpl: async () => + new Promise<{ stopReason: 'cancelled' }>((res) => { + releasers.push(() => res({ stopReason: 'cancelled' })); + }), + }).channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const cancelEvents: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') cancelEvents.push(e); + } + })(); + + const runTurn = async () => { + const p = bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'x' }], + }, + undefined, + { clientId: session.clientId }, + ) + .catch(() => {}); + await new Promise((r) => setTimeout(r, 10)); + await bridge.cancelSession( + session.sessionId, + { sessionId: session.sessionId }, + { clientId: session.clientId }, + ); + releasers.shift()?.(); + await p; + await new Promise((r) => setTimeout(r, 5)); + }; + + await runTurn(); // prompt 1: latch sets, 1 broadcast + await runTurn(); // prompt 2: latch was reset at start → re-broadcasts + peerAbort.abort(); + await collecting; + expect(cancelEvents).toHaveLength(2); + await bridge.shutdown(); + }); + + it('emits a compensating prompt_cancelled{forward_failed} when the prompt forward rejects (C3)', async () => { + // doudouOUC #4484 post-merge review (C3): the user echo is published + // before the forward. If the forward itself rejects (transport died / + // ACP error) without a user cancel, peers must still see the turn end + // — otherwise they sit forever on the echoed input with no response. + const h = makeChannel({ + promptImpl: async () => { + throw new Error('forward boom'); + }, + }); + const cancelSpy = vi.spyOn(h.agent, 'cancel'); + const factory: ChannelFactory = async () => h.channel; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const peerAbort = new AbortController(); + const peerIter = bridge.subscribeEvents(session.sessionId, { + signal: peerAbort.signal, + }); + const peerCancel = (async () => { + for await (const e of peerIter) { + if (e.type === 'prompt_cancelled') return e; + } + throw new Error('peer never saw prompt_cancelled'); + })(); + + await bridge + .sendPrompt( + session.sessionId, + { + sessionId: session.sessionId, + prompt: [{ type: 'text', text: 'will fail to forward' }], + }, + undefined, + { clientId: session.clientId }, + ) + .catch(() => { + // forward rejection surfaces to the caller too. + }); + + const evt = await peerCancel; + expect(evt.type).toBe('prompt_cancelled'); + expect((evt.data as { reason?: string }).reason).toBe('forward_failed'); + await vi.waitFor(() => { + expect(cancelSpy).toHaveBeenCalledWith({ + sessionId: session.sessionId, + }); + }); + peerAbort.abort(); + await bridge.shutdown(); + }); + it('stamps envelope originatorClientId on session_closed', async () => { const factory: ChannelFactory = async () => makeChannel().channel; const bridge = makeBridge({ channelFactory: factory }); @@ -2649,20 +2733,140 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); - it('returns false (not InvalidClientIdError) when session exists but requestId is unknown and clientId is unregistered (#4335 / 3271978329 / 3272493792 / 3273077272)', async () => { - // Wenshao review #4335 / 3271978329 (Critical) — error - // precedence regression: the session-scoped vote route must - // return `false` (→ 404) when the requestId isn't known to - // the mediator, BEFORE validating `context.clientId`. - // Without this guard a probe could fabricate a requestId, - // supply an arbitrary `X-Qwen-Client-Id`, and distinguish - // "this clientId is registered to this session" (proceeds - // past resolveTrustedClientId then returns false → 404) from - // "this clientId is not registered" (InvalidClientIdError → - // 400) — a session-membership oracle. - // - // Wenshao review #4335 / 3272493792 — explicit test for the - // fix from Round 7 so a future refactor can't silently + it('forwards permission vote metadata back to the agent response', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { + toolCallId: 'tc-ask', + title: 'AskUserQuestion: Ask user 1 question', + }, + options: [ + { optionId: 'proceed_once', name: 'Submit', kind: 'allow_once' }, + { optionId: 'cancel', name: 'Cancel', kind: 'reject_once' }, + ], + }); + + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + const payload = next.value!.data as { requestId: string }; + + const responseWithAnswers = { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + grade: 'Primary', + }, + ignored: 'not forwarded', + } satisfies RequestPermissionResponse & { + answers: Record; + ignored: string; + }; + const accepted = bridge.respondToPermission( + payload.requestId, + responseWithAnswers, + ); + expect(accepted).toBe(true); + + const response = await respPromise; + expect(response).toMatchObject({ + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + grade: 'Primary', + }, + }); + expect(response).not.toHaveProperty('ignored'); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('forwards session-scoped permission answers without arbitrary metadata', async () => { + const { bridge, session, conn } = await setupForPermission(); + + const subAbort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: subAbort.signal, + }); + + const respPromise = ( + conn as unknown as { + requestPermission(p: unknown): Promise; + } + ).requestPermission({ + sessionId: session.sessionId, + toolCall: { + toolCallId: 'tc-ask-scoped', + title: 'AskUserQuestion: Ask user 1 question', + }, + options: [ + { optionId: 'proceed_once', name: 'Submit', kind: 'allow_once' }, + { optionId: 'cancel', name: 'Cancel', kind: 'reject_once' }, + ], + }); + + const it = iter[Symbol.asyncIterator](); + const next = await it.next(); + expect(next.done).toBe(false); + const payload = next.value!.data as { requestId: string }; + + const responseWithAnswers = { + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + }, + ignored: 'not forwarded', + } satisfies RequestPermissionResponse & { + answers: Record; + ignored: string; + }; + const accepted = bridge.respondToSessionPermission( + session.sessionId, + payload.requestId, + responseWithAnswers, + { clientId: session.clientId }, + ); + expect(accepted).toBe(true); + + const response = await respPromise; + expect(response).toMatchObject({ + outcome: { outcome: 'selected', optionId: 'proceed_once' }, + answers: { + name: 'Alice', + }, + }); + expect(response).not.toHaveProperty('ignored'); + + subAbort.abort(); + await bridge.shutdown(); + }); + + it('returns false (not InvalidClientIdError) when session exists but requestId is unknown and clientId is unregistered (#4335 / 3271978329 / 3272493792 / 3273077272)', async () => { + // Wenshao review #4335 / 3271978329 (Critical) — error + // precedence regression: the session-scoped vote route must + // return `false` (→ 404) when the requestId isn't known to + // the mediator, BEFORE validating `context.clientId`. + // Without this guard a probe could fabricate a requestId, + // supply an arbitrary `X-Qwen-Client-Id`, and distinguish + // "this clientId is registered to this session" (proceeds + // past resolveTrustedClientId then returns false → 404) from + // "this clientId is not registered" (InvalidClientIdError → + // 400) — a session-membership oracle. + // + // Wenshao review #4335 / 3272493792 — explicit test for the + // fix from Round 7 so a future refactor can't silently // remove the short-circuit. // // Wenshao review #4335 / 3273077272 — also assert the stderr @@ -4558,6 +4762,224 @@ describe('createHttpAcpBridge', () => { await bridge.shutdown(); }); + it('serializes concurrent approval-mode changes through the per-session queue (A3)', async () => { + // doudouOUC #4484 post-merge review (A3): two concurrent + // `setSessionApprovalMode` calls must not interleave their ACP + // roundtrips, otherwise the last `approval_mode_changed` published + // can disagree with the mode the child actually settled on. The + // `approvalModeQueue` enforces FIFO. Detect by tracking concurrent + // in-flight ext calls (must never exceed 1) and the start/end order. + let inFlight = 0; + let maxInFlight = 0; + const order: string[] = []; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: async (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + const mode = (params as { mode: string }).mode; + inFlight += 1; + maxInFlight = Math.max(maxInFlight, inFlight); + order.push(`start:${mode}`); + await new Promise((r) => setTimeout(r, 10)); + order.push(`end:${mode}`); + inFlight -= 1; + return { previous: 'default', current: mode }; + } + return {}; + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await Promise.all([ + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ), + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: false }, + undefined, + ), + ]); + // Never overlapped, and the second roundtrip began only after the + // first fully completed. + expect(maxInFlight).toBe(1); + expect(order).toEqual([ + 'start:yolo', + 'end:yolo', + 'start:default', + 'end:default', + ]); + await bridge.shutdown(); + }); + + it('serializes persist + publish too, not just the extMethod (A3, persist:true)', async () => { + // Regression for the wenshao Critical: covering only the extMethod left + // persist+publish outside the queue, so two concurrent persist:true + // changes could interleave their persist phases and publish out of + // order. Make persist slow + inversely ordered to the calls; assert the + // published approval_mode_changed events still come out in call order + // (A then B), proving persist+publish run inside the serialized work. + const { factory } = approvalModeFactoryWithCallTracker(); + // persist for 'yolo' is SLOWER than for 'default' — if persist ran + // outside the queue, 'default' would publish before 'yolo'. + const persistDelay: Record = { yolo: 30, default: 1 }; + const bridge = makeBridge({ + channelFactory: factory, + persistApprovalMode: async (_ws: string, mode: string) => { + await new Promise((r) => setTimeout(r, persistDelay[mode] ?? 1)); + }, + }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const published: string[] = []; + const collecting = (async () => { + for await (const e of iter) { + if (e.type === 'approval_mode_changed') { + published.push((e.data as { next: string }).next); + } + } + })(); + + await Promise.all([ + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: true }, + undefined, + ), + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: true }, + undefined, + ), + ]); + await new Promise((r) => setTimeout(r, 20)); + abort.abort(); + await collecting; + // In call order despite yolo's slower persist — persist+publish are + // serialized inside the queue, so default can't overtake yolo. + expect(published).toEqual(['yolo', 'default']); + await bridge.shutdown(); + }); + + it('a failed approval-mode change does not poison the queue (A3 tail-swallow)', async () => { + // The approvalModeQueue tail-swallows failures so a rejected change + // can't wedge every subsequent one. First call rejects; the second + // must still run and succeed. + let call = 0; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const agent = new FakeAgent({ + extMethodImpl: async (method, params) => { + if (method === 'qwen/control/session/approval_mode') { + call += 1; + if (call === 1) throw new Error('approval boom'); + return { + previous: 'default', + current: (params as { mode: string }).mode, + }; + } + return {}; + }, + }); + new AgentSideConnection(() => agent as Agent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + // The ACP layer wraps the agent-side throw as a generic JSON-RPC + // error; we only care that the first change rejects. + await expect( + bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.YOLO, + { persist: false }, + undefined, + ), + ).rejects.toThrow(); + + // Queue not poisoned — the next change still resolves. + const res = await bridge.setSessionApprovalMode( + session.sessionId, + ApprovalMode.DEFAULT, + { persist: false }, + undefined, + ); + expect(res.mode).toBe('default'); + await bridge.shutdown(); + }); + + it('echoPromptToSessionBus tolerates a non-array prompt (D6 guard)', async () => { + // The Array.isArray guard means a malformed body that slips past the + // type contract degrades to "no echo" rather than throwing mid-send. + const { factory } = approvalModeFactoryWithCallTracker(); + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const userChunks: BridgeEvent[] = []; + const collecting = (async () => { + for await (const e of iter) { + const u = (e.data as { update?: { sessionUpdate?: string } })?.update; + if (u?.sessionUpdate === 'user_message_chunk') userChunks.push(e); + } + })(); + + // prompt is not an array → the Array.isArray guard returns early. + // Capture the outcome rather than swallowing it: if the guard were + // removed, echoPromptToSessionBus would throw a TypeError on + // `undefined.length` and sendPrompt would reject WITH that TypeError — + // so asserting the error (if any) is NOT a TypeError makes the test + // fail when the guard is gone (the previous `.catch(() => {})` passed + // regardless — dead-code-safe, wenshao). + const caught: unknown = await bridge + .sendPrompt( + session.sessionId, + { sessionId: session.sessionId, prompt: undefined as never }, + undefined, + { clientId: session.clientId }, + ) + .catch((e) => e); + + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(caught).not.toBeInstanceOf(TypeError); + expect(userChunks).toHaveLength(0); + await bridge.shutdown(); + }); + it('broadcasts approval_mode_changed to peer sessions when persisted (#4282 fold-in 4 S2)', async () => { // When `persist:true` succeeds the change becomes the workspace // default, so a peer session needs to know its next ACP child @@ -4745,743 +5167,26 @@ describe('createHttpAcpBridge', () => { }); }); - describe('setWorkspaceToolEnabled (#4175 Wave 4 PR 17)', () => { - it('throws when no persistDisabledTools callback is wired', async () => { - const bridge = makeBridge(); - await expect( - bridge.setWorkspaceToolEnabled('Bash', false, undefined), - ).rejects.toThrow(/persistDisabledTools/); - }); - - it('invokes the persist callback with the workspace + name + enabled flag', async () => { - const calls: Array<{ - workspace: string; - toolName: string; - enabled: boolean; - }> = []; - const bridge = makeBridge({ - persistDisabledTools: async (workspace, toolName, enabled) => { - calls.push({ workspace, toolName, enabled }); - }, - }); - const result = await bridge.setWorkspaceToolEnabled( - 'Bash', - false, - undefined, - ); - expect(result).toEqual({ toolName: 'Bash', enabled: false }); - expect(calls).toEqual([ - { workspace: WS_A, toolName: 'Bash', enabled: false }, - ]); - }); - - it('does NOT spawn an ACP child even when called repeatedly', async () => { - let factoryCalls = 0; + describe('subscribeEvents', () => { + it('throws SessionNotFoundError for unknown session ids', () => { const bridge = makeBridge({ channelFactory: async () => { - factoryCalls += 1; - throw new Error('channel factory should not be invoked'); + throw new Error('factory should not be called'); }, - persistDisabledTools: async () => {}, }); - await bridge.setWorkspaceToolEnabled('Bash', false, undefined); - await bridge.setWorkspaceToolEnabled('Read', true, undefined); - expect(factoryCalls).toBe(0); + expect(() => bridge.subscribeEvents('unknown')).toThrow( + SessionNotFoundError, + ); }); - it('fan-outs tool_toggled events to every live session bus', async () => { + it('publishes session_update events to subscribers when the agent sends them', async () => { + let capturedConn: AgentSideConnection | undefined; const factory: ChannelFactory = async () => { + // Build a channel pair where we capture the agent-side connection + // so we can drive sessionUpdate notifications from the test. const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); - return { - stream: clientStream, - exited: new Promise< - | { exitCode: number | null; signalCode: NodeJS.Signals | null } - | undefined - >(() => {}), - kill: async () => {}, - killSync: () => {}, - }; - }; - const bridge = makeBridge({ - channelFactory: factory, - persistDisabledTools: async () => {}, - }); - // Two thread-scope sessions on the same workspace, so both - // entries live in the byId map and both should observe the - // workspace-scoped fan-out. - const a = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const b = await bridge.spawnOrAttach({ - workspaceCwd: WS_A, - sessionScope: 'thread', - }); - const aborts = [new AbortController(), new AbortController()]; - const itA = bridge - .subscribeEvents(a.sessionId, { signal: aborts[0]!.signal }) - [Symbol.asyncIterator](); - const itB = bridge - .subscribeEvents(b.sessionId, { signal: aborts[1]!.signal }) - [Symbol.asyncIterator](); - await bridge.setWorkspaceToolEnabled('Bash', false, undefined); - const [evA, evB] = await Promise.all([itA.next(), itB.next()]); - expect(evA.value?.type).toBe('tool_toggled'); - expect(evB.value?.type).toBe('tool_toggled'); - expect(evA.value?.data).toEqual({ toolName: 'Bash', enabled: false }); - expect(evB.value?.data).toEqual({ toolName: 'Bash', enabled: false }); - aborts.forEach((a) => a.abort()); - await bridge.shutdown(); - }); - - it('stamps tool_toggled with the originator clientId when supplied', async () => { - const factory: ChannelFactory = async () => { - const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); - return { - stream: clientStream, - exited: new Promise< - | { exitCode: number | null; signalCode: NodeJS.Signals | null } - | undefined - >(() => {}), - kill: async () => {}, - killSync: () => {}, - }; - }; - const bridge = makeBridge({ - channelFactory: factory, - persistDisabledTools: async () => {}, - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const abort = new AbortController(); - const it = bridge - .subscribeEvents(session.sessionId, { signal: abort.signal }) - [Symbol.asyncIterator](); - await bridge.setWorkspaceToolEnabled('Bash', false, session.clientId); - const next = await it.next(); - expect(next.value?.originatorClientId).toBe(session.clientId); - abort.abort(); - await bridge.shutdown(); - }); - }); - - describe('restartMcpServer (#4297 fold-in 1, addresses #3260501141)', () => { - /** - * Build a channel factory whose ACP `extMethod` handler returns a - * configurable response for `qwen/control/workspace/mcp/restart`. - * Reusable across happy-path / soft-skip / hard-error tests so each - * test's intent is the response shape, not the boilerplate. - */ - function restartFactory( - respond: ( - params: Record, - ) => - | Record - | Promise> - | Promise, - ): ChannelFactory { - return async () => { - const { clientStream, agentStream } = createInMemoryChannel(); - const agent = new FakeAgent({ - extMethodImpl: (method, params) => { - if (method === 'qwen/control/workspace/mcp/restart') { - 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('returns the restarted shape on success and broadcasts mcp_server_restarted', async () => { - const bridge = makeBridge({ - channelFactory: restartFactory(() => ({ - serverName: 'docs', - restarted: true, - durationMs: 1234, - })), - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const abort = new AbortController(); - const it = bridge - .subscribeEvents(session.sessionId, { signal: abort.signal }) - [Symbol.asyncIterator](); - const result = await bridge.restartMcpServer('docs', undefined); - expect(result).toEqual({ - serverName: 'docs', - restarted: true, - durationMs: 1234, - }); - const next = await it.next(); - expect(next.value?.type).toBe('mcp_server_restarted'); - expect(next.value?.data).toMatchObject({ - serverName: 'docs', - durationMs: 1234, - }); - abort.abort(); - await bridge.shutdown(); - }); - - it('returns the soft-skip shape and broadcasts mcp_server_restart_refused', async () => { - const bridge = makeBridge({ - channelFactory: restartFactory(() => ({ - serverName: 'docs', - restarted: false, - skipped: true, - reason: 'budget_would_exceed', - })), - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const abort = new AbortController(); - const it = bridge - .subscribeEvents(session.sessionId, { signal: abort.signal }) - [Symbol.asyncIterator](); - const result = await bridge.restartMcpServer('docs', undefined); - expect(result).toEqual({ - serverName: 'docs', - restarted: false, - skipped: true, - reason: 'budget_would_exceed', - }); - const next = await it.next(); - expect(next.value?.type).toBe('mcp_server_restart_refused'); - expect(next.value?.data).toMatchObject({ - serverName: 'docs', - reason: 'budget_would_exceed', - }); - abort.abort(); - await bridge.shutdown(); - }); - - it('translates ACP mcp_server_not_found into McpServerNotFoundError', async () => { - // The ACP child raises a JSON-RPC error whose `data.errorKind` is - // `'mcp_server_not_found'` for unknown server names. The bridge - // re-instantiates the typed class so `sendBridgeError` can map - // it to a stable HTTP 404 — without this the route would fall - // through to the generic 500 handler. - const bridge = makeBridge({ - channelFactory: restartFactory( - () => - Promise.reject( - new RequestError(-32004, 'MCP server not configured: "ghost"', { - errorKind: 'mcp_server_not_found', - serverName: 'ghost', - }), - ) as Promise, - ), - }); - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const err = await bridge - .restartMcpServer('ghost', undefined) - .catch((e) => e); - expect(err).toBeInstanceOf(McpServerNotFoundError); - expect((err as McpServerNotFoundError).serverName).toBe('ghost'); - await bridge.shutdown(); - }); - - it('translates ACP mcp_restart_failed into McpServerRestartFailedError', async () => { - // Post-discover, the ACP child checks the live MCP server status - // and raises a JSON-RPC error with `errorKind: - // 'mcp_restart_failed'` when the server didn't reach CONNECTED. - // The bridge re-instantiates the typed class so the route maps - // it to HTTP 502 + `errorKind: 'protocol_error'`. - const bridge = makeBridge({ - channelFactory: restartFactory( - () => - Promise.reject( - new RequestError( - -32099, - 'MCP server "docs" did not reach a connected state', - { - errorKind: 'mcp_restart_failed', - serverName: 'docs', - mcpStatus: 'DISCONNECTED', - }, - ), - ) as Promise, - ), - }); - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const err = await bridge - .restartMcpServer('docs', undefined) - .catch((e) => e); - expect(err).toBeInstanceOf(McpServerRestartFailedError); - expect((err as McpServerRestartFailedError).serverName).toBe('docs'); - expect((err as McpServerRestartFailedError).mcpStatus).toBe( - 'DISCONNECTED', - ); - await bridge.shutdown(); - }); - - it('stamps mcp_server_restarted with the originator clientId when supplied', async () => { - const bridge = makeBridge({ - channelFactory: restartFactory(() => ({ - serverName: 'docs', - restarted: true, - durationMs: 0, - })), - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const abort = new AbortController(); - const it = bridge - .subscribeEvents(session.sessionId, { signal: abort.signal }) - [Symbol.asyncIterator](); - await bridge.restartMcpServer('docs', session.clientId); - const next = await it.next(); - expect(next.value?.originatorClientId).toBe(session.clientId); - abort.abort(); - await bridge.shutdown(); - }); - }); - - describe('initWorkspace (#4175 Wave 4 PR 17)', () => { - /** - * Per-test workspace temp dir so the bridge's writeFile lands on a - * real path the tests can stat. Cleaned up by `afterEach`. - */ - let tmpWs: string; - - beforeEach(async () => { - tmpWs = await fsp.mkdtemp(path.join(os.tmpdir(), 'qwen-init-workspace-')); - }); - - afterEach(async () => { - await fsp.rm(tmpWs, { recursive: true, force: true }); - }); - - it('creates an empty QWEN.md on a fresh workspace', async () => { - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('created'); - expect(res.path).toBe(path.join(tmpWs, 'QWEN.md')); - const written = await fsp.readFile(res.path, 'utf8'); - expect(written).toBe(''); - }); - - it('treats whitespace-only file as a noop without force (no 409, no write)', async () => { - // #4282 fold-in 1 (wenshao H4): whitespace-only existing file is - // a no-op rather than a silent overwrite. Original whitespace - // content is preserved; the response surface signals `'noop'` - // so the SSE event accurately reflects "no on-disk change." - const target = path.join(tmpWs, 'QWEN.md'); - const original = ' \n\t\n'; - await fsp.writeFile(target, original, 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('noop'); - const onDisk = await fsp.readFile(target, 'utf8'); - expect(onDisk).toBe(original); - }); - - it('throws WorkspaceInitConflictError when content exists and force is omitted', async () => { - const target = path.join(tmpWs, 'QWEN.md'); - const original = '# Project notes\n\nimportant stuff'; - await fsp.writeFile(target, original, 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceInitConflictError); - expect((err as WorkspaceInitConflictError).path).toBe(target); - expect((err as WorkspaceInitConflictError).existingSize).toBe( - Buffer.byteLength(original, 'utf8'), - ); - // Original content must be preserved on conflict. - expect(await fsp.readFile(target, 'utf8')).toBe(original); - }); - - it('overwrites with action:overwrote when force is true', async () => { - const target = path.join(tmpWs, 'QWEN.md'); - await fsp.writeFile(target, '# Old', 'utf8'); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const res = await bridge.initWorkspace({ force: true }, undefined); - expect(res.action).toBe('overwrote'); - expect(await fsp.readFile(target, 'utf8')).toBe(''); - }); - - it('force:true overwrite refuses an O_NOFOLLOW ELOOP race (#4297 fold-in 7 TOCTOU)', async () => { - // Pin the new `O_WRONLY|O_TRUNC|O_NOFOLLOW` open path on the - // overwrote branch. Pre-fold-in-7 the overwrite used plain - // `fs.writeFile` and could be redirected outside `boundWorkspace` - // by a local writer racing a symlink between the lstat/readFile - // checks and the write. Now `O_NOFOLLOW` causes open() to fail - // with ELOOP on a symlink — translated to - // `WorkspaceInitSymlinkError(kind: 'target')`. - // - // Test strategy: pre-write a regular file so action lands on - // `overwrote`, then mock `fs.lstat` and `fs.readFile` to lie - // about the file (pretend it's still a regular file with non- - // whitespace content), but pre-replace the on-disk file with a - // symlink so `fs.open(..., O_NOFOLLOW)` fails. - const escapeTarget = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-init-overwrite-toctou-'), - ); - try { - const externalFile = path.join(escapeTarget, 'EXTERNAL.md'); - await fsp.writeFile(externalFile, '# external', 'utf8'); - const targetPath = path.join(tmpWs, 'QWEN.md'); - // Real on-disk state: a symlink pointing outside the workspace. - await fsp.symlink(externalFile, targetPath); - // Stubbed lstat/readFile: pretend it's a regular file with - // existing content, so we land on the `overwrote` action. - const fakeStats = { - isSymbolicLink: () => false, - isFile: () => true, - isDirectory: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - } as unknown as import('node:fs').Stats; - const lstatSpy = vi - .spyOn(fsp, 'lstat') - .mockResolvedValueOnce(fakeStats); - const readFileSpy = vi - .spyOn(fsp, 'readFile') - .mockResolvedValueOnce('# Old non-whitespace content' as never); - try { - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge - .initWorkspace({ force: true }, undefined) - .catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceInitSymlinkError); - expect((err as WorkspaceInitSymlinkError).kind).toBe('target'); - expect((err as Error).message).toMatch( - /could not be opened with O_NOFOLLOW \(ELOOP\)/, - ); - // External file untouched — the boundary held. - expect(await fsp.readFile(externalFile, 'utf8')).toBe('# external'); - } finally { - lstatSpy.mockRestore(); - readFileSpy.mockRestore(); - } - } finally { - await fsp.rm(escapeTarget, { recursive: true, force: true }); - } - }); - - it('force:true overwrite distinguishes ENOENT race-delete from ELOOP symlink (#4297 fold-in 8)', async () => { - // Pin the diagnostic split. ENOENT in the overwrite open path - // means the file was DELETED between the readFile content - // check and the open (concurrent writer — git checkout, - // editor save) — NOT a symlink swap. The error message must - // not say "swapped to a symlink"; otherwise an operator - // diagnosing a benign race wastes time hunting a symlink - // attack that didn't happen. - // Pretend lstat says it's a regular file with content (so we - // land on the overwrote action), but the actual on-disk path - // doesn't exist when fs.open runs — forcing ENOENT. - const fakeStats = { - isSymbolicLink: () => false, - isFile: () => true, - isDirectory: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - } as unknown as import('node:fs').Stats; - const lstatSpy = vi.spyOn(fsp, 'lstat').mockResolvedValueOnce(fakeStats); - const readFileSpy = vi - .spyOn(fsp, 'readFile') - .mockResolvedValueOnce('# Old content' as never); - try { - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge - .initWorkspace({ force: true }, undefined) - .catch((e) => e); - // #4297 fold-in 10 (qwen-latest S2): ENOENT race-delete now - // surfaces as `WorkspaceInitRaceError(kind: 'enoent')` (HTTP - // code `workspace_init_race`), not `WorkspaceInitSymlinkError` - // — distinguishes a benign concurrent-modification window - // from a symlink attack vector at the dashboard level. - expect(err).toBeInstanceOf(WorkspaceInitRaceError); - expect((err as WorkspaceInitRaceError).kind).toBe('enoent'); - // Message must reference deletion / concurrent writer — NOT - // "swapped to a symlink" (which is only accurate for ELOOP). - expect((err as Error).message).toMatch( - /was deleted between the content check and the overwrite/, - ); - expect((err as Error).message).not.toMatch(/swapped to a symlink/); - } finally { - lstatSpy.mockRestore(); - readFileSpy.mockRestore(); - } - }); - - it('honors `contextFilename` from BridgeOptions (#4282 fold-in 5 P2-1)', async () => { - // The daemon parent never goes through `loadCliConfig`, so the - // process-global `getCurrentGeminiMdFilename()` stays on the - // default `QWEN.md`. `runQwenServe` snapshots the workspace's - // `context.fileName` setting at boot and forwards it via the - // `contextFilename` option so init writes the same file the - // ACP child reads. - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - contextFilename: 'AGENTS.md', - }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('created'); - expect(res.path).toBe(path.join(tmpWs, 'AGENTS.md')); - // Default name must NOT have been written; otherwise observers - // would see two files appear and clients would race over which - // is canonical. - expect( - await fsp - .stat(path.join(tmpWs, 'QWEN.md')) - .then(() => true) - .catch(() => false), - ).toBe(false); - }); - - it('rejects writes when a parent directory symlinks outside the workspace (#4282 fold-in 5 P2-4)', async () => { - // `lstat(target)` only checks the final component. A symlink at - // any parent level — e.g. `docs -> /tmp` with `context.fileName: - // 'docs/AGENTS.md'` — would let `writeFile` follow the parent - // link and create or truncate outside `boundWorkspace`. The - // canonical-parent check resolves the chain via `realpath` - // before any read or write. - const escapeTarget = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-init-escape-'), - ); - try { - await fsp.symlink(escapeTarget, path.join(tmpWs, 'docs')); - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - contextFilename: 'docs/AGENTS.md', - }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - // #4297 fold-in 1 (16:32:44-round S1): typed class so - // `sendBridgeError` can map to 400 rather than 500. - expect(err).toBeInstanceOf(WorkspaceInitSymlinkError); - expect((err as WorkspaceInitSymlinkError).kind).toBe('parent'); - expect((err as Error).message).toMatch( - /parent path that resolves outside the bound workspace/, - ); - // Confirm nothing was written outside the workspace. - expect( - await fsp - .stat(path.join(escapeTarget, 'AGENTS.md')) - .then(() => true) - .catch(() => false), - ).toBe(false); - } finally { - await fsp.rm(escapeTarget, { recursive: true, force: true }); - } - }); - - it('rejects writes when the target file itself is a symlink (#4297 fold-in 1)', async () => { - // The original PR 17 boundary guard: `lstat(target)` should - // refuse to follow a symlink at the QWEN.md path. Without this - // test, a future refactor that drops the `lstat` could silently - // re-open path traversal through this strict-gated mutation - // route. Pairs with the parent-symlink test above to lock both - // boundary checks under unit coverage. - const escapeTarget = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-init-target-symlink-'), - ); - try { - const externalFile = path.join(escapeTarget, 'EXTERNAL.md'); - await fsp.writeFile(externalFile, '# external', 'utf8'); - await fsp.symlink(externalFile, path.join(tmpWs, 'QWEN.md')); - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceInitSymlinkError); - expect((err as WorkspaceInitSymlinkError).kind).toBe('target'); - expect((err as Error).message).toMatch(/is a symlink/); - // External file untouched. - expect(await fsp.readFile(externalFile, 'utf8')).toBe('# external'); - } finally { - await fsp.rm(escapeTarget, { recursive: true, force: true }); - } - }); - - it('atomic create refuses an EEXIST race after lstat passed (#4297 fold-in 5 TOCTOU)', async () => { - // The PR 17 `lstat` check is point-in-time: a local attacker - // with workspace write access could replace the target with a - // symlink between the check and the write, and the previous - // `fs.writeFile` would have followed the link out of the - // workspace. The fold-in 5 fix uses `fs.open(target, 'wx')` — - // O_WRONLY|O_CREAT|O_EXCL — which atomically refuses any - // pre-existing inode at the path. - // - // This test simulates the race by pre-creating a symlink AND - // stubbing `fs.lstat` to lie about it (return a regular-file - // shape). Without the `'wx'` guard the bridge would proceed to - // `writeFile` and follow the symlink. With it, the open call - // fails with EEXIST and we throw `WorkspaceInitSymlinkError`. - const escapeTarget = await fsp.mkdtemp( - path.join(os.tmpdir(), 'qwen-init-toctou-'), - ); - try { - const externalFile = path.join(escapeTarget, 'EXTERNAL.md'); - await fsp.writeFile(externalFile, '# external', 'utf8'); - const targetPath = path.join(tmpWs, 'QWEN.md'); - await fsp.symlink(externalFile, targetPath); - // Pretend `lstat` reports a regular file (the race window - // where the symlink was placed AFTER our stat). The 'wx' - // open should still atomically refuse. - const fakeStats = { - isSymbolicLink: () => false, - isFile: () => true, - isDirectory: () => false, - isCharacterDevice: () => false, - isBlockDevice: () => false, - isFIFO: () => false, - isSocket: () => false, - } as unknown as import('node:fs').Stats; - const lstatSpy = vi - .spyOn(fsp, 'lstat') - .mockResolvedValueOnce(fakeStats); - // ALSO stub readFile so the existence check above doesn't - // short-circuit into the conflict path; we want the create - // branch to attempt `fs.open(target, 'wx')`. - const readFileSpy = vi - .spyOn(fsp, 'readFile') - .mockRejectedValueOnce( - Object.assign(new Error('ENOENT'), { code: 'ENOENT' }), - ); - try { - const bridge = createHttpAcpBridge({ boundWorkspace: tmpWs }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - // #4297 fold-in 10 (qwen-latest S2): EEXIST race now surfaces - // as `WorkspaceInitRaceError(kind: 'eexist')` — the inode - // could be a regular file OR symlink, we don't know which, - // so the dedicated race class is more accurate than the - // symlink-implying one. - expect(err).toBeInstanceOf(WorkspaceInitRaceError); - expect((err as WorkspaceInitRaceError).kind).toBe('eexist'); - // External file must not have been touched. - expect(await fsp.readFile(externalFile, 'utf8')).toBe('# external'); - } finally { - lstatSpy.mockRestore(); - readFileSpy.mockRestore(); - } - } finally { - await fsp.rm(escapeTarget, { recursive: true, force: true }); - } - }); - - it('rejects writes when contextFilename resolves outside the workspace (#4297 fold-in 1)', async () => { - // The pre-canonicalize textual `withinWorkspace` check at - // `httpAcpBridge.ts:~4045`: a `context.fileName: '../outside.md'` - // is the simplest config-error escape. Locking it under a typed - // assertion means a future path-arithmetic refactor that - // weakens the check fails this test instead of silently - // re-opening the boundary. - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - contextFilename: '../outside.md', - }); - const err = await bridge.initWorkspace({}, undefined).catch((e) => e); - expect(err).toBeInstanceOf(WorkspaceInitPathEscapeError); - expect((err as WorkspaceInitPathEscapeError).filename).toBe( - '../outside.md', - ); - expect((err as Error).message).toMatch( - /resolves outside the bound workspace/, - ); - // Confirm no file was created at the escape target. - const sibling = path.resolve(tmpWs, '..', 'outside.md'); - expect( - await fsp - .stat(sibling) - .then(() => true) - .catch(() => false), - ).toBe(false); - }); - - it('accepts writes when a parent directory is a real subdir (#4282 fold-in 5 P2-4)', async () => { - // Symmetric coverage for the parent-realpath check: when `docs` - // is a real directory (not a symlink), the write must succeed - // and land at the nested target. - await fsp.mkdir(path.join(tmpWs, 'docs')); - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - contextFilename: 'docs/AGENTS.md', - }); - const res = await bridge.initWorkspace({}, undefined); - expect(res.action).toBe('created'); - expect(res.path).toBe(path.join(tmpWs, 'docs', 'AGENTS.md')); - }); - - it('does NOT spawn an ACP child', async () => { - let factoryCalls = 0; - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - channelFactory: async () => { - factoryCalls += 1; - throw new Error('channel factory should not be invoked'); - }, - }); - await bridge.initWorkspace({}, undefined); - expect(factoryCalls).toBe(0); - }); - - it('fan-outs workspace_initialized to live session buses', async () => { - const factory: ChannelFactory = async () => { - const { clientStream, agentStream } = createInMemoryChannel(); - new AgentSideConnection(() => new FakeAgent() as Agent, agentStream); - return { - stream: clientStream, - exited: new Promise< - | { exitCode: number | null; signalCode: NodeJS.Signals | null } - | undefined - >(() => {}), - kill: async () => {}, - killSync: () => {}, - }; - }; - const bridge = createHttpAcpBridge({ - boundWorkspace: tmpWs, - channelFactory: factory, - }); - const session = await bridge.spawnOrAttach({ workspaceCwd: tmpWs }); - const abort = new AbortController(); - const it = bridge - .subscribeEvents(session.sessionId, { signal: abort.signal }) - [Symbol.asyncIterator](); - const res = await bridge.initWorkspace({}, session.clientId); - const next = await it.next(); - expect(next.value?.type).toBe('workspace_initialized'); - expect(next.value?.data).toEqual({ - path: res.path, - action: 'created', - }); - expect(next.value?.originatorClientId).toBe(session.clientId); - abort.abort(); - await bridge.shutdown(); - }); - }); - - describe('subscribeEvents', () => { - it('throws SessionNotFoundError for unknown session ids', () => { - const bridge = makeBridge({ - channelFactory: async () => { - throw new Error('factory should not be called'); - }, - }); - expect(() => bridge.subscribeEvents('unknown')).toThrow( - SessionNotFoundError, - ); - }); - - it('publishes session_update events to subscribers when the agent sends them', async () => { - let capturedConn: AgentSideConnection | undefined; - const factory: ChannelFactory = async () => { - // Build a channel pair where we capture the agent-side connection - // so we can drive sessionUpdate notifications from the test. - const { clientStream, agentStream } = createInMemoryChannel(); - const fakeAgent = new FakeAgent(); - capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); return { stream: clientStream, exited: new Promise< @@ -6344,6 +6049,149 @@ describe('createHttpAcpBridge', () => { }); }); + describe('extNotification — followup_suggestion', () => { + it('publishes followup_suggestion when the child fires a prompt-suggestion notification', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + const fakeAgent = new FakeAgent(); + capturedConn = new AgentSideConnection(() => fakeAgent, agentStream); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 'Run the tests?', + promptId: `${session.sessionId}########3`, + }, + ); + + const collected: Array<{ type: string; data: unknown }> = []; + for await (const e of iter) { + collected.push({ type: e.type, data: e.data }); + if (collected.length === 1) break; + } + expect(collected[0]?.type).toBe('followup_suggestion'); + expect(collected[0]?.data).toMatchObject({ + sessionId: session.sessionId, + suggestion: 'Run the tests?', + promptId: `${session.sessionId}########3`, + }); + abort.abort(); + await bridge.shutdown(); + }); + + it('drops malformed prompt-suggestion payloads', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + const abort = new AbortController(); + const iter = bridge.subscribeEvents(session.sessionId, { + signal: abort.signal, + }); + const seen: string[] = []; + const collecting = (async () => { + for await (const e of iter) seen.push(e.type); + })(); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1, sessionId: session.sessionId, suggestion: '' }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1, sessionId: session.sessionId, promptId: 'p1' }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { v: 1 }, + ); + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 123 as unknown as string, + promptId: 'p1', + }, + ); + await new Promise((r) => setTimeout(r, 10)); + abort.abort(); + await collecting; + expect(seen.filter((t) => t === 'followup_suggestion')).toEqual([]); + await bridge.shutdown(); + }); + + it('drops prompt-suggestion after session is closed', async () => { + let capturedConn: AgentSideConnection | undefined; + const factory: ChannelFactory = async () => { + const { clientStream, agentStream } = createInMemoryChannel(); + capturedConn = new AgentSideConnection( + () => new FakeAgent(), + agentStream, + ); + return { + stream: clientStream, + exited: new Promise< + | { exitCode: number | null; signalCode: NodeJS.Signals | null } + | undefined + >(() => {}), + kill: async () => {}, + killSync: () => {}, + }; + }; + const bridge = makeBridge({ channelFactory: factory }); + const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A }); + await bridge.closeSession(session.sessionId); + + void capturedConn!.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: session.sessionId, + suggestion: 'stale', + promptId: 'p1', + }, + ); + // No throw — silently dropped. + await bridge.shutdown(); + }); + }); + describe('maxSessions cap (chiga0 Rec 3)', () => { it('refuses NEW spawns past the cap with SessionLimitExceededError', async () => { let n = 0; @@ -7135,7 +6983,7 @@ describe('createHttpAcpBridge', () => { // - `bridge.permissionPolicy` accessor wired through the mediator // - F3 BridgeOptions validation (positive-integer quorum) // ============================================================ -describe('createHttpAcpBridge — F3 multi-client permission coordination', () => { +describe('createAcpSessionBridge — F3 multi-client permission coordination', () => { it('exposes the active permission policy through bridge.permissionPolicy (default first-responder)', () => { const bridge = makeBridge({}); expect(bridge.permissionPolicy).toBe('first-responder'); diff --git a/packages/acp-bridge/src/bridge.ts b/packages/acp-bridge/src/bridge.ts index 3324ed7a3b3..ed76ede605a 100644 --- a/packages/acp-bridge/src/bridge.ts +++ b/packages/acp-bridge/src/bridge.ts @@ -5,7 +5,6 @@ */ import { randomUUID } from 'node:crypto'; -import { promises as fs, constants as fsConstants } from 'node:fs'; import * as path from 'node:path'; import { ClientSideConnection, @@ -19,9 +18,13 @@ import type { } from '@agentclientprotocol/sdk'; import type { ApprovalMode } from '@qwen-code/qwen-code-core'; import { + DAEMON_TRACEPARENT_META_KEY, + DAEMON_TRACESTATE_META_KEY, TrustGateError, - getCurrentGeminiMdFilename, + ShellExecutionService, + type ShellOutputEvent, } from '@qwen-code/qwen-code-core'; +import type { ShellCommandResult } from './bridgeTypes.js'; import type { AcpChannel } from './channel.js'; import { EventBus, DEFAULT_RING_SIZE, type BridgeEvent } from './eventBus.js'; import { @@ -30,14 +33,7 @@ import { SERVE_CONTROL_EXT_METHODS, SERVE_STATUS_EXT_METHODS, STATUS_SCHEMA_VERSION, - createIdleAcpPreflightCells, - createIdleEnvStatus, - createIdleWorkspaceMcpStatus, - createIdleWorkspaceProvidersStatus, - createIdleWorkspaceSkillsStatus, - mapDomainErrorToErrorKind, - type ServePreflightCell, - type ServeStatusCell, + type ServeSessionTasksStatus, } from './status.js'; import { SessionNotFoundError, @@ -55,12 +51,6 @@ import { // policy dispatch). InvalidPermissionOptionError, InvalidSessionMetadataError, - WorkspaceInitConflictError, - WorkspaceInitPathEscapeError, - WorkspaceInitSymlinkError, - WorkspaceInitRaceError, - McpServerNotFoundError, - McpServerRestartFailedError, isNotCurrentlyGeneratingCancelError, } from './bridgeErrors.js'; import { canonicalizeWorkspace } from './workspacePaths.js'; @@ -70,9 +60,9 @@ import type { BridgeSessionState, BridgeRestoredSession, BridgeSessionSummary, - HttpAcpBridge, + AcpSessionBridge, } from './bridgeTypes.js'; -import type { BridgeOptions } from './bridgeOptions.js'; +import type { BridgeOptions, BridgeTelemetry } from './bridgeOptions.js'; import { defaultSpawnChannelFactory } from './spawnChannel.js'; import { writeStderrLine } from './internal/stderrLine.js'; import { BridgeClient } from './bridgeClient.js'; @@ -84,6 +74,34 @@ import { } from './permissionMediator.js'; import { PermissionForbiddenError } from './bridgeErrors.js'; +const NOOP_BRIDGE_TELEMETRY: BridgeTelemetry = { + captureContext: () => undefined, + runWithContext(_captured, fn) { + return fn(); + }, + withSpan(_operation, _attributes, fn) { + return fn(); + }, + event() {}, + injectPromptContext(request) { + const meta = (request as { _meta?: unknown })._meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return request; + } + const record = meta as Record; + if ( + !(DAEMON_TRACEPARENT_META_KEY in record) && + !(DAEMON_TRACESTATE_META_KEY in record) + ) { + return request; + } + const nextMeta = { ...record }; + delete nextMeta[DAEMON_TRACEPARENT_META_KEY]; + delete nextMeta[DAEMON_TRACESTATE_META_KEY]; + return { ...request, _meta: nextMeta }; + }, +}; + /** * Stage 1 HTTP→ACP bridge factory + supporting helpers, lifted from * `cli/src/serve/httpAcpBridge.ts` to `@qwen-code/acp-bridge/bridge` @@ -113,7 +131,7 @@ import { PermissionForbiddenError } from './bridgeErrors.js'; * the ACP layer demultiplexes by sessionId. * * Stage 2 replaces the spawn step with an in-process call into core's - * ACP-equivalent API. The `HttpAcpBridge` interface stays the same so HTTP + * ACP-equivalent API. The `AcpSessionBridge` interface stays the same so HTTP * route handlers don't need to change. */ @@ -216,6 +234,16 @@ interface SessionEntry { * `/model` (no bridge roundtrip) sees this false and IS promoted. */ modelRoundtripInFlight?: boolean; + /** + * Per-session approval-mode FIFO (doudouOUC #4484 post-merge review, + * A3). Mirrors `modelChangeQueue`: serializes concurrent + * `setSessionApprovalMode` calls so two `POST /session/:id/approval-mode` + * can't race their ACP roundtrip + persist and publish an + * `approval_mode_changed` event whose `next` mode disagrees with the + * mode the ACP child actually settled on. Always resolves — failures + * swallowed at the tail like `modelChangeQueue`. + */ + approvalModeQueue: Promise; /** * Cached "transport closed" promise. The first `sendPrompt` on a * session lazy-builds this from `channel.exited.then(throw)`; every @@ -243,6 +271,19 @@ interface SessionEntry { * inline session updates / permission requests can safely inherit this id. */ activePromptOriginatorClientId?: string; + /** + * Per-prompt "already broadcast `prompt_cancelled`" latch (doudouOUC + * #4484 post-merge review, D2). The explicit `cancelSession` route and + * the `sendPrompt` abort path (originator SSE drop) can both fire for + * the same active prompt — e.g. a client POSTs /cancel then immediately + * closes its socket. Without dedup, peers receive two `prompt_cancelled` + * frames for one turn. Reset to `false` when the **next prompt starts** + * (the latch is per-prompt); set `true` on the first broadcast. A cancel + * against an already-settled / idle session may be suppressed until the + * next prompt starts — acceptable since an idle-session cancel is a + * harmless no-op (see `cancelSession`). + */ + cancelBroadcast?: boolean; /** * Count of times `spawnOrAttach` has returned `attached: true` for * this entry — i.e. a second-or-subsequent client claimed this @@ -318,6 +359,26 @@ const MAX_DISPLAY_NAME_LENGTH = 256; */ const MAX_ECHO_CONTENT_BLOCKS = 256; +function extractPermissionResponseMetadata( + response: unknown, +): Readonly> | undefined { + if (response === null || typeof response !== 'object') return undefined; + // Keep this extension deliberately narrow. Today the only non-ACP field + // expected by the agent is AskUserQuestion's `answers` payload. + const answers = (response as { readonly answers?: unknown }).answers; + if ( + answers !== null && + typeof answers === 'object' && + !Array.isArray(answers) + ) { + const entries = Object.entries(answers as Record); + if (entries.every(([, v]) => typeof v === 'string')) { + return { answers }; + } + } + return undefined; +} + /** * Echo a user prompt to the session bus so multi-client SSE subscribers * see the input alongside the agent response. Iterates content blocks @@ -331,6 +392,14 @@ const MAX_ECHO_CONTENT_BLOCKS = 256; * `suppressOwnUserEcho: true` skip the echo for the originator (the * envelope-level `originatorClientId` matches their own clientId). * + * Anonymous-prompt caveat (D2-review D5): a stable `X-Qwen-Client-Id` is a + * PRECONDITION for that dedup. A prompt with no clientId (curl smoke / + * pre-registration script) produces an envelope without + * `originatorClientId`, so `suppressOwnUserEcho` has nothing to match and + * the originating connection sees its own input echoed back. This is an + * accepted edge for headless/anonymous callers; interactive multi-client + * UIs always carry a clientId and are unaffected. + * * Source marker: `_meta.source: 'bridge-echo'` lets downstream tooling * distinguish bridge-synthesized echoes from agent-emitted content if * needed (e.g., for replay-deduplication when the agent later catches @@ -345,8 +414,12 @@ function echoPromptToSessionBus( // ACP type contract — read it directly so a future SDK bump that // makes it optional surfaces as a TypeScript error rather than being // silently swallowed by an `unknown` cast. + // `PromptRequest.prompt` is typed as a non-optional `ContentBlock[]`, so + // TS guarantees the shape. The runtime `Array.isArray` guard (D6) is pure + // defense-in-depth for a malformed HTTP body that slips past the type + // contract — cheaper than a thrown `TypeError` mid-echo. const prompt = req.prompt; - if (prompt.length === 0) return; + if (!Array.isArray(prompt) || prompt.length === 0) return; const serverTimestamp = Date.now(); const blockCount = Math.min(prompt.length, MAX_ECHO_CONTENT_BLOCKS); for (let i = 0; i < blockCount; i += 1) { @@ -366,6 +439,14 @@ function echoPromptToSessionBus( update: { sessionUpdate: 'user_message_chunk', content: part, + // D3 (doudouOUC #4484 post-merge review): `_meta` lives inside + // the `update` object rather than at envelope level. Kept here + // deliberately — `_meta` is a standard JSON-RPC/MCP extension + // field permitted alongside spec fields, the SDK normalizer + // reads it from `update._meta`/`data._meta`, and every other + // agent-emitted session_update carries `_meta` the same way. + // Relocating to the envelope would be a coordinated wire change + // across all emitters + the SDK for no functional gain. _meta: { serverTimestamp, source: 'bridge-echo' }, }, }, @@ -399,11 +480,12 @@ function broadcastPromptCancelled( entry: SessionEntry, sessionId: string, originatorClientId: string | undefined, + reason?: 'forward_failed', ): void { try { entry.events.publish({ type: 'prompt_cancelled', - data: { sessionId }, + data: { sessionId, ...(reason ? { reason } : {}) }, ...(originatorClientId ? { originatorClientId } : {}), }); } catch { @@ -411,6 +493,73 @@ function broadcastPromptCancelled( } } +/** + * D2 dedup wrapper around {@link broadcastPromptCancelled}. Broadcasts at + * most once per active prompt by latching `entry.cancelBroadcast`, so the + * `cancelSession` route and the `sendPrompt` abort path can't both emit a + * `prompt_cancelled` for a single turn (POST /cancel then socket close). + * The latch is reset when the next prompt starts. + */ +function broadcastPromptCancelledOnce( + entry: SessionEntry, + sessionId: string, + originatorClientId: string | undefined, + reason?: 'forward_failed', +): void { + if (entry.cancelBroadcast) { + writeStderrLine( + `broadcastPromptCancelledOnce: suppressed duplicate cancel for session ${sessionId} (latch already set)`, + ); + return; + } + entry.cancelBroadcast = true; + broadcastPromptCancelled(entry, sessionId, originatorClientId, reason); +} + +function broadcastTurnComplete( + entry: SessionEntry, + sessionId: string, + promptResult: { stopReason?: string; [k: string]: unknown }, + promptId: string | undefined, + originatorClientId: string | undefined, +): void { + entry.events.publish({ + type: 'turn_complete', + data: { + sessionId, + stopReason: promptResult.stopReason ?? 'end_turn', + ...(promptId ? { promptId } : {}), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); +} + +function broadcastTurnError( + entry: SessionEntry, + sessionId: string, + err: unknown, + promptId: string | undefined, + originatorClientId: string | undefined, +): void { + const message = err instanceof Error ? err.message : String(err); + const code = + err instanceof Error && + 'code' in err && + typeof (err as Error & { code?: unknown }).code === 'string' + ? (err as Error & { code: string }).code + : undefined; + entry.events.publish({ + type: 'turn_error', + data: { + sessionId, + message, + ...(code ? { code } : {}), + ...(promptId ? { promptId } : {}), + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); +} + function hasControlCharacter(value: string): boolean { for (let i = 0; i < value.length; i += 1) { const code = value.charCodeAt(i); @@ -422,18 +571,7 @@ function hasControlCharacter(value: string): boolean { } const DEFAULT_INIT_TIMEOUT_MS = 10_000; -/** - * #4282 fold-in 2 (gpt-5.5 CV2). Bridge-race deadline for the - * `workspace/mcp/:server/restart` ACP extMethod. The MCP manager's - * per-server discovery deadline can be up to 5 minutes - * (`McpClientManager.MAX_DISCOVERY_TIMEOUT_MS`), so reusing - * `initTimeoutMs` (10s) here produced a guaranteed false-timeout for - * any stdio MCP server slower than 10s while the ACP child kept - * reconnecting in the background. The bridge race is purely a safety - * net against a completely wedged ACP channel; it should be at least - * as long as the slowest legitimate per-server discovery. - */ -const MCP_RESTART_TIMEOUT_MS = 300_000; +const PERSIST_TIMEOUT_MS = 5_000; /** * Backstop timeout for `qwen/control/session/recap`. The underlying * side-query is single-attempt with `maxOutputTokens: 300`, so a @@ -444,6 +582,8 @@ const MCP_RESTART_TIMEOUT_MS = 300_000; * disconnect cancellation in v1 (see server.ts route comment). */ const SESSION_RECAP_TIMEOUT_MS = 60_000; +const SHELL_COMMAND_TIMEOUT_MS = 120_000; +const MAX_SHELL_OUTPUT_FOR_HISTORY = 10_000; const DEFAULT_MAX_SESSIONS = 20; /** * Soft upper bound on `BridgeOptions.eventRingSize` to catch operator @@ -471,7 +611,7 @@ const DEFAULT_PERMISSION_TIMEOUT_MS = 5 * 60 * 1000; // `BridgeOptions.maxPendingPermissionsPerSession`. const DEFAULT_MAX_PENDING_PER_SESSION = 64; -export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { +export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge { const defaultSessionScope = opts.sessionScope ?? 'single'; // `undefined` → default 20 (intentionally tight per #3803 N≈50 cliff). // `0` → explicitly unlimited (operator opt-out). @@ -587,13 +727,9 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { const boundWorkspace = opts.boundWorkspace; // #4282 fold-in 5 (Codex P2-1). Snapshot the configured context // filename at construction time. The daemon parent never updates - // the process-global through `loadCliConfig`, so `runQwenServe` - // reads the workspace's merged settings and forwards the value - // here. Falling back to `getCurrentGeminiMdFilename()` keeps - // bridge tests + embedded callers working without explicit setup. - const contextFilename = opts.contextFilename ?? getCurrentGeminiMdFilename(); const persistApprovalMode = opts.persistApprovalMode; const persistDisabledTools = opts.persistDisabledTools; + const telemetry = opts.telemetry ?? NOOP_BRIDGE_TELEMETRY; // #3803 §02 single-workspace model: the bridge hosts AT MOST one // ATTACH-AVAILABLE channel and one default attach-target entry. @@ -830,7 +966,14 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { if (inFlightChannelSpawn) return await inFlightChannelSpawn; const promise = (async () => { - const channel = await channelFactory(boundWorkspace, childEnvOverrides); + const channel = await telemetry.withSpan( + 'channel.spawn', + { + 'qwen-code.daemon.bridge.operation': 'channel.spawn', + 'qwen-code.daemon.channel.reused': false, + }, + async () => await channelFactory(boundWorkspace, childEnvOverrides), + ); const client = new BridgeClient( // BfFut: ACP today carries a sessionId on every per-session // notification / request, so the no-sessionId branch is @@ -947,6 +1090,13 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // context line in that flow, and the message confirms the // cleanup actually ran. if (!shuttingDown) { + telemetry.event('channel.exited', { + 'qwen-code.daemon.channel.exit_code': exitInfo?.exitCode ?? -1, + 'qwen-code.daemon.channel.session_count': sessions.length, + ...(exitInfo?.signalCode + ? { 'qwen-code.daemon.channel.signal': exitInfo.signalCode } + : {}), + }); writeStderrLine( `qwen serve: channel exited (code=${exitInfo?.exitCode ?? 'none'}, signal=${exitInfo?.signalCode ?? 'none'}, ${sessions.length} session(s) torn down)`, ); @@ -986,16 +1136,23 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // shutdown) only need to mark dying + kill — the handler does // the alive-set cleanup when the OS reaps the child. try { - await withTimeout( - connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { - fs: { readTextFile: true, writeTextFile: true }, - }, - clientInfo: { name: 'qwen-serve-bridge', version: '0' }, - }), - initTimeoutMs, - 'initialize', + await telemetry.withSpan( + 'channel.initialize', + { + 'qwen-code.daemon.bridge.operation': 'channel.initialize', + }, + async () => + await withTimeout( + connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + }, + clientInfo: { name: 'qwen-serve-bridge', version: '0' }, + }), + initTimeoutMs, + 'initialize', + ), ); } catch (err) { // Mark the half-initialized channel as dying/unavailable, then @@ -1021,7 +1178,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { if (shuttingDown) { info.isDying = true; await channel.kill().catch(() => {}); - throw new Error('HttpAcpBridge is shutting down'); + throw new Error('AcpSessionBridge is shutting down'); } // Handshake succeeded — now publish the channel as the @@ -1067,13 +1224,21 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { const ci = await ensureChannel(); let newSessionResp: { sessionId: string }; try { - newSessionResp = await withTimeout( - ci.connection.newSession({ - cwd: boundWorkspace, - mcpServers: [], - }), - initTimeoutMs, - 'newSession', + newSessionResp = await telemetry.withSpan( + 'session.new', + { + 'qwen-code.daemon.bridge.operation': 'session.new', + 'qwen-code.daemon.session_scope': effectiveScope, + }, + async () => + await withTimeout( + ci.connection.newSession({ + cwd: boundWorkspace, + mcpServers: [], + }), + initTimeoutMs, + 'newSession', + ), ); } catch (err) { // Only reap when this newSession was the channel's first/only @@ -1098,7 +1263,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // while we were in `connection.newSession` (~1s on cold start). if (shuttingDown) { // Don't kill the channel — see comment above. Just throw. - throw new Error('HttpAcpBridge is shutting down'); + throw new Error('AcpSessionBridge is shutting down'); } const entry = createSessionEntry( @@ -1354,6 +1519,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { const requestSessionStatus = async ( sessionId: string, method: string, + params: Record = {}, ): Promise => { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -1361,7 +1527,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { if (!info || info.isDying) throw new SessionNotFoundError(sessionId); const response = await Promise.race([ withTimeout( - entry.connection.extMethod(method, { sessionId }), + entry.connection.extMethod(method, { ...params, sessionId }), initTimeoutMs, method, ), @@ -1495,6 +1661,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { events, promptQueue: Promise.resolve(), modelChangeQueue: Promise.resolve(), + approvalModeQueue: Promise.resolve(), pendingPermissionIds: new Set(), clientIds: new Map(), clientLastSeenAt: new Map(), @@ -1547,7 +1714,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { req: BridgeRestoreSessionRequest, ): Promise { if (shuttingDown) { - throw new Error('HttpAcpBridge is shutting down'); + throw new Error('AcpSessionBridge is shutting down'); } const workspaceKey = resolveWorkspaceKey(req.workspaceCwd); @@ -1724,7 +1891,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { if (shuttingDown) { restoreEvents.close(); - throw new Error('HttpAcpBridge is shutting down'); + throw new Error('AcpSessionBridge is shutting down'); } if (ci.isDying || !aliveChannels.has(ci)) { restoreEvents.close(); @@ -1843,7 +2010,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // connections can still hit `POST /session`. Refuse here so // late-arrivers don't spawn children the shutdown path won't // see — they'd otherwise leak past `process.exit(0)`. - throw new Error('HttpAcpBridge is shutting down'); + throw new Error('AcpSessionBridge is shutting down'); } // Fast-path the common §02 case: clients pre-flight `caps.workspaceCwd` // and post back the exact same string, so the equality check @@ -2016,6 +2183,8 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { }, async sendPrompt(sessionId, req, signal, context) { + const capturedContext = telemetry.captureContext(); + const queuedAt = Date.now(); const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); const originatorClientId = resolveTrustedClientId( @@ -2033,121 +2202,161 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // Force the body's sessionId to match the routing id — a client that // sent a stale id in the body would otherwise be dispatched to the // wrong agent process. - const normalized: PromptRequest = { ...req, sessionId }; - const result = entry.promptQueue.then(() => { - // If the caller aborted while we were queued behind earlier - // prompts, don't even start this one. - if (signal?.aborted) { - throw new DOMException('Prompt aborted', 'AbortError'); - } - if (originatorClientId === undefined) { - delete entry.activePromptOriginatorClientId; - } else { - entry.activePromptOriginatorClientId = originatorClientId; - } - // Echo the user prompt to the session bus so other SSE-subscribed - // clients see the input alongside the agent response. - // - // The interactive prompt path was the only one not emitting - // `user_message_chunk` — `Session#executePrompt` (the agent - // side) forwards the prompt directly to the LLM; the cron path - // (Session.ts:1402) and `HistoryReplayer` (line 65) emit it - // explicitly. Without this echo, multi-client UIs only saw - // assistant text from peer prompts — no record of who said what. - // - // Originator dedup: SDK consumers' `normalizeDaemonEvent` with - // `suppressOwnUserEcho: true` filters the echo when - // `event.originatorClientId === opts.clientId`. So the - // originator's local UI doesn't double-render its own input. - // - // Multi-modal: one envelope per content block. Non-text blocks - // pass through verbatim (the agent's Core multimodal echo is a - // separate follow-up tracked in PR #4353 §D); for now the - // common text path is the immediate fix. - echoPromptToSessionBus(entry, normalized, originatorClientId); - const promptPromise = entry.connection - .prompt(normalized) - .finally(() => { - delete entry.activePromptOriginatorClientId; - }); - - // Race against channel termination: if the underlying transport - // dies (child crashed, stream torn down) WHILE the prompt is in - // flight, the SDK's pending-request promise can hang because the - // wire never delivers a response. Make the prompt fail-fast in - // that case so the per-session FIFO doesn't poison the next - // queued prompt with an unbounded await. See - // `getTransportClosedReject` for the single-listener invariant. - // - // FIXME(stage-2): no absolute prompt deadline. A buggy agent - // that ignores `cancel()` while keeping the channel alive can - // hold this race open indefinitely — the abort path fires - // `cancel()` and resolves pending permissions, but the - // `promptPromise` itself only settles when the agent - // cooperates. Stage 2 should add a configurable per-prompt - // wall clock (e.g. `--prompt-deadline 30m`) into this race so - // a wedged agent can't slow-leak prompt promises. Tracked - // under #3803 follow-ups. - const racedPromise = Promise.race([ - promptPromise, - getTransportClosedReject(entry), - ]); - - if (!signal) return racedPromise; - // Wire the abort: when the signal fires (e.g. SSE route's - // req.on('close')), tell the agent to wind down. ACP cancel is a - // notification — the active prompt resolves with - // stopReason: 'cancelled', then the next queued prompt can run. - // - // Also resolve any pending permission requests as `cancelled`. - // ACP spec requires `cancel` to settle outstanding - // `requestPermission` calls — `cancelSession()` already does - // this; the abort path here was missing the call. Without it, - // a client disconnecting while the agent is inside - // `requestPermission` leaves the permission promise unresolved - // forever (the agent is stuck waiting on a vote that no SSE - // subscriber will ever cast). - const onAbort = () => { - // Broadcast the cancel on the abort path too — client - // disconnect (SSE drop / tab close / laptop sleep) is the most - // common cancel trigger in production, and previously this path - // resolved permissions + forwarded ACP cancel WITHOUT telling - // peer SSE subscribers, leaving them in the exact - // silent-absence-of-chunks state this work set out to fix. - // `originatorClientId` here is the prompt's own originator (the - // client whose connection dropped). - broadcastPromptCancelled(entry, sessionId, originatorClientId); - cancelPendingForSession(sessionId); - entry.connection.cancel({ sessionId }).catch(() => { - // Cancel is fire-and-forget; the agent may already be dead. - }); - }; - if (signal.aborted) { - onAbort(); - } else { - signal.addEventListener('abort', onAbort, { once: true }); - // The aborted state can flip synchronously between the early-exit - // check at the top of `sendPrompt` and addEventListener — re-check - // after registration so a microsecond-window abort still fires - // `cancel()` instead of letting the prompt run uncancellable. - if (signal.aborted) onAbort(); - // Detach the listener once the prompt resolves so the - // AbortController can be GC'd. The `.finally()` returns a - // promise chained on `racedPromise`; if `racedPromise` - // rejects, that returned promise rejects too — and we - // never await it, so under Node's default - // unhandled-rejection behavior the daemon could terminate - // even though the route's own catch handles the original - // rejection. Attach `.catch(() => {})` to the - // listener-cleanup chain only — the caller's reference to - // `racedPromise` (via `return racedPromise` below) still - // surfaces failures normally. - racedPromise - .finally(() => signal.removeEventListener('abort', onAbort)) - .catch(() => {}); - } - return racedPromise; - }); + const result = entry.promptQueue.then(() => + telemetry.runWithContext( + capturedContext, + async () => + await telemetry.withSpan( + 'prompt.dispatch', + { + 'qwen-code.daemon.bridge.operation': 'prompt.dispatch', + 'session.id': sessionId, + 'qwen-code.daemon.prompt.queue_wait_ms': Date.now() - queuedAt, + }, + async () => { + const normalized: PromptRequest = telemetry.injectPromptContext( + { + ...req, + sessionId, + }, + ); + // If the caller aborted while we were queued behind earlier + // prompts, don't even start this one. + if (signal?.aborted) { + throw new DOMException('Prompt aborted', 'AbortError'); + } + if (originatorClientId === undefined) { + delete entry.activePromptOriginatorClientId; + } else { + entry.activePromptOriginatorClientId = originatorClientId; + } + // Echo the user prompt to the session bus so other SSE-subscribed + // clients see the input alongside the agent response. + // + // The interactive prompt path was the only one not emitting + // `user_message_chunk` — `Session#executePrompt` (the agent + // side) forwards the prompt directly to the LLM; the cron path + // (Session.ts:1402) and `HistoryReplayer` (line 65) emit it + // explicitly. Without this echo, multi-client UIs only saw + // assistant text from peer prompts — no record of who said what. + // + // Originator dedup: SDK consumers' `normalizeDaemonEvent` with + // `suppressOwnUserEcho: true` filters the echo when + // `event.originatorClientId === opts.clientId`. So the + // originator's local UI doesn't double-render its own input. + // + // Multi-modal: one envelope per content block. Non-text blocks + // pass through verbatim (the agent's Core multimodal echo is a + // separate follow-up tracked in PR #4353 §D); for now the + // common text path is the immediate fix. + entry.cancelBroadcast = false; + echoPromptToSessionBus(entry, normalized, originatorClientId); + const promptPromise = entry.connection + .prompt(normalized) + .finally(() => { + delete entry.activePromptOriginatorClientId; + }); + + // Race against channel termination: if the underlying transport + // dies (child crashed, stream torn down) WHILE the prompt is in + // flight, the SDK's pending-request promise can hang because the + // wire never delivers a response. Make the prompt fail-fast in + // that case so the per-session FIFO doesn't poison the next + // queued prompt with an unbounded await. See + // `getTransportClosedReject` for the single-listener invariant. + // + // FIXME(stage-2): no absolute prompt deadline. A buggy agent + // that ignores `cancel()` while keeping the channel alive can + // hold this race open indefinitely — the abort path fires + // `cancel()` and resolves pending permissions, but the + // `promptPromise` itself only settles when the agent + // cooperates. Stage 2 should add a configurable per-prompt + // wall clock (e.g. `--prompt-deadline 30m`) into this race so + // a wedged agent can't slow-leak prompt promises. Tracked + // under #3803 follow-ups. + const racedPromise = Promise.race([ + promptPromise, + getTransportClosedReject(entry), + ]); + + // C3 (doudouOUC #4484 post-merge review): the user echo + // (`echoPromptToSessionBus`) was already published BEFORE the + // forward. If the forward itself fails (transport died, ACP child + // error) and it wasn't a user-initiated cancel that already + // broadcast, peers would be stuck having seen the echoed input with + // no response and no terminal signal — permanent silence. Emit a + // compensating `prompt_cancelled{reason:'forward_failed'}` so the + // turn visibly ends. The `…Once` latch dedups against the abort + // path (a normal cancel resolves rather than rejects, so this only + // fires on genuine forward failures). Side-effect only — the + // caller's `racedPromise` reference still surfaces the rejection. + void racedPromise + .then( + () => {}, + (err) => { + writeStderrLine( + `sendPrompt: forward failed for session ${sessionId}: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + broadcastPromptCancelledOnce( + entry, + sessionId, + originatorClientId, + 'forward_failed', + ); + cancelPendingForSession(sessionId); + entry.connection.cancel({ sessionId }).catch(() => {}); + }, + ) + .catch(() => {}); + + if (!signal) return racedPromise; + const onAbort = () => { + broadcastPromptCancelledOnce( + entry, + sessionId, + originatorClientId, + ); + cancelPendingForSession(sessionId); + entry.connection.cancel({ sessionId }).catch(() => {}); + }; + if (signal.aborted) { + onAbort(); + } else { + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + racedPromise + .finally(() => signal.removeEventListener('abort', onAbort)) + .catch(() => {}); + } + return racedPromise; + }, + ), + ), + ); + const promptId = context?.promptId; + result.then( + (promptResult) => { + broadcastTurnComplete( + entry, + sessionId, + promptResult, + promptId, + originatorClientId, + ); + }, + (err) => { + if (err instanceof DOMException && err.name === 'AbortError') return; + broadcastTurnError( + entry, + sessionId, + err, + promptId, + originatorClientId, + ); + }, + ); // Tail swallows failures so subsequent prompts still run. The caller // still sees rejections on its own `result` reference. entry.promptQueue = result.then( @@ -2182,7 +2391,12 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // originator stamp (those resolutions are system-initiated, not // user-voted); this top-level `prompt_cancelled` carries the // cancelling client so peer UIs can attribute it. - broadcastPromptCancelled(entry, sessionId, cancelOriginatorClientId); + // + // `…Once` (D2): dedups against the `sendPrompt` abort path so a + // client that POSTs /cancel and then drops its socket doesn't emit + // two `prompt_cancelled` frames for the same turn. The latch resets + // at the next prompt start, so a later turn still broadcasts. + broadcastPromptCancelledOnce(entry, sessionId, cancelOriginatorClientId); // ACP spec: cancelling a prompt MUST resolve outstanding // requestPermission calls with outcome.cancelled. Do this *before* // forwarding the notification so the agent's wind-down sees the @@ -2205,12 +2419,21 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { const notif: CancelNotification = req ? { ...req, sessionId } : { sessionId }; - try { - await entry.connection.cancel(notif); - } catch (err) { - if (isNotCurrentlyGeneratingCancelError(err)) return; - throw err; - } + await telemetry.withSpan( + 'session.cancel', + { + 'qwen-code.daemon.bridge.operation': 'session.cancel', + 'session.id': sessionId, + }, + async () => { + try { + await entry.connection.cancel(notif); + } catch (err) { + if (isNotCurrentlyGeneratingCancelError(err)) return; + throw err; + } + }, + ); }, subscribeEvents(sessionId, subOpts) { @@ -2219,6 +2442,12 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return entry.events.subscribe(subOpts); }, + getSessionLastEventId(sessionId) { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + return entry.events.lastEventId; + }, + respondToPermission(requestId, response, context) { // F3 Commit 3 — legacy workspace-level vote route. Look up the // session via mediator's resolved+pending peek, forward to @@ -2342,6 +2571,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { response.outcome.outcome === 'selected' ? response.outcome.optionId : CANCEL_VOTE_SENTINEL; + const voterMetadata = extractPermissionResponseMetadata(response); const outcome = permissionMediator.vote({ requestId, sessionId, @@ -2349,6 +2579,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { optionId, receivedAtMs: Date.now(), fromLoopback: context?.fromLoopback ?? false, + ...(voterMetadata ? { metadata: voterMetadata } : {}), }); switch (outcome.kind) { case 'resolved': @@ -2394,6 +2625,10 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ? ` by client ${JSON.stringify(originatorClientId)}` : ''), ); + telemetry.event('session.close', { + 'qwen-code.daemon.bridge.operation': 'session.close', + 'session.id': sessionId, + }); if (defaultEntry === entry) defaultEntry = undefined; // #4325 fix: resolve the channel via `channelInfoForEntry(entry)` // (search `aliveChannels` for the entry's actual channel) instead @@ -2472,7 +2707,15 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // late cancellation frames from the agent are intentionally dropped. entry.events.close(); try { - await entry.connection.cancel({ sessionId }); + await telemetry.withSpan( + 'session.close.cancel_active_prompt', + { + 'qwen-code.daemon.bridge.operation': + 'session.close.cancel_active_prompt', + 'session.id': sessionId, + }, + async () => await entry.connection.cancel({ sessionId }), + ); } catch { /* no active prompt or session already torn down */ } @@ -2639,8 +2882,7 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { // - the local `broadcastWorkspaceEvent` closure declared above // in this factory body (PR 17 mutation surface) — used by // `setSessionApprovalMode` - // / `setWorkspaceToolEnabled` / `restartMcpServer` / `initWorkspace` - // because their call sites run inside the factory closure + // because its call site runs inside the factory closure // where `this` isn't yet the proxy. The closure also takes // an optional `skipSessionId` for the persisted approval-mode // mirror; this member doesn't. @@ -2697,10 +2939,27 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { return out; }, - async getWorkspaceMcpStatus() { - return requestWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => - createIdleWorkspaceMcpStatus(boundWorkspace), + async queryWorkspaceStatus(method, idle) { + return requestWorkspaceStatus(method, idle); + }, + + async invokeWorkspaceCommand( + method: string, + params?: Record, + invokeOpts?: { timeoutMs?: number }, + ) { + const info = liveChannelInfo(); + if (!info) throw new SessionNotFoundError(`workspace-command:${method}`); + const timeout = invokeOpts?.timeoutMs ?? initTimeoutMs; + const response = await withTimeout( + Promise.race([ + info.connection.extMethod(method, params ?? {}), + getChannelClosedReject(info), + ]), + timeout, + method, ); + return response as T; }, async getWorkspaceMcpToolsStatus(serverName) { @@ -2725,13 +2984,6 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ); }, - async getWorkspaceSkillsStatus() { - return requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceSkills, - () => createIdleWorkspaceSkillsStatus(boundWorkspace), - ); - }, - async getWorkspaceToolsStatus() { return requestWorkspaceStatus( SERVE_STATUS_EXT_METHODS.workspaceTools, @@ -2752,126 +3004,18 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ); }, - async getWorkspaceProvidersStatus() { - return requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspaceProviders, - () => createIdleWorkspaceProvidersStatus(boundWorkspace), + async getSessionContextStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionContext, ); }, - async getWorkspaceEnvStatus() { - const acpChannelLive = !!liveChannelInfo(); - // PR 22b/2: daemon-host env snapshot delegated to - // `BridgeOptions.statusProvider`. When omitted (Mode A in-process - // consumers, tests) the bridge returns an idle envelope — - // matches the "queryable but empty" pattern PR 12 / 13 - // established for diagnostic routes. - // - // Wenshao review fold-in (#4304): a custom provider that throws - // would otherwise propagate past the bridge into `/workspace/env` - // as a 500. Catch + log + fall back to the idle envelope so the - // route still responds — the `daemon cells always answerable` - // invariant the pre-injection `buildEnvStatusFromProcess` carried - // (it never threw because it was synchronous and self-contained) - // is preserved structurally. - if (!opts.statusProvider) { - return createIdleEnvStatus(boundWorkspace, acpChannelLive); - } - try { - return await opts.statusProvider.getEnvStatus( - boundWorkspace, - acpChannelLive, - ); - } catch (err) { - writeStderrLine( - `qwen serve: statusProvider.getEnvStatus failed; ` + - `falling back to idle envelope: ` + - (err instanceof Error ? err.message : String(err)), - ); - return createIdleEnvStatus(boundWorkspace, acpChannelLive); - } - }, - - async getWorkspacePreflightStatus() { - // PR 22b/2: daemon-host preflight cells delegated to - // `BridgeOptions.statusProvider`. Without a provider the daemon - // half is empty `[]`; ACP-side cells are still fetched normally - // when a child is live. - // - // Wenshao review fold-in (#4304): a throwing provider would - // otherwise propagate past the bridge and turn the entire - // preflight envelope into a 500 — losing both daemon cells AND - // the ACP-side cells fetched below. Catch + log + fall back to - // empty so ACP cells still render. Pre-injection - // `buildDaemonPreflightCells` used `Promise.allSettled` and was - // effectively unthrowable; this preserves that route-level - // invariant for custom provider impls that may throw. - let daemonCells: ServePreflightCell[]; - if (!opts.statusProvider) { - // Asymmetric vs `getWorkspaceEnvStatus` (which falls back to a - // full `createIdleEnvStatus` envelope): preflight is the union - // of daemon-locality + ACP-locality cells stitched below, so an - // empty daemon slice IS the right fallback — the ACP slice - // fills in independently from the live channel (or its - // `not_started` placeholders). - daemonCells = []; - } else { - try { - daemonCells = - await opts.statusProvider.getDaemonPreflightCells(boundWorkspace); - } catch (err) { - writeStderrLine( - `qwen serve: statusProvider.getDaemonPreflightCells failed; ` + - `falling back to empty daemon cells: ` + - (err instanceof Error ? err.message : String(err)), - ); - daemonCells = []; - } - } - const acpChannelLive = !!liveChannelInfo(); - - let acpResponse: - | { cells: ServePreflightCell[]; errors?: ServeStatusCell[] } - | undefined; - let envelopeError: ServeStatusCell | undefined; - try { - acpResponse = await requestWorkspaceStatus( - SERVE_STATUS_EXT_METHODS.workspacePreflight, - () => ({ cells: createIdleAcpPreflightCells() }), - ); - } catch (err) { - // Bridge-side timeout / channel close while consulting ACP. Daemon - // cells still render; envelope-level error tells the client which - // surface failed without sinking the whole route. - const errorKind = mapDomainErrorToErrorKind(err); - envelopeError = { - kind: 'preflight', - status: 'error', - error: err instanceof Error ? err.message : String(err), - ...(errorKind ? { errorKind } : {}), - }; - acpResponse = { cells: createIdleAcpPreflightCells() }; - } - - const errors: ServeStatusCell[] = [ - ...(acpResponse.errors ?? []), - ...(envelopeError ? [envelopeError] : []), - ]; - - return { - v: STATUS_SCHEMA_VERSION, - workspaceCwd: boundWorkspace, - initialized: true as const, - acpChannelLive, - cells: [...daemonCells, ...acpResponse.cells], - ...(errors.length > 0 ? { errors } : {}), - }; - }, - - async getSessionContextStatus(sessionId) { + async getSessionContextUsageStatus(sessionId, opts) { return requestSessionStatus( sessionId, - SERVE_STATUS_EXT_METHODS.sessionContext, + SERVE_STATUS_EXT_METHODS.sessionContextUsage, + { detail: opts?.detail === true }, ); }, @@ -2882,6 +3026,13 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ); }, + async getSessionTasksStatus(sessionId) { + return requestSessionStatus( + sessionId, + SERVE_STATUS_EXT_METHODS.sessionTasks, + ); + }, + async setSessionModel(sessionId, req, context) { const entry = byId.get(sessionId); if (!entry) throw new SessionNotFoundError(sessionId); @@ -2943,15 +3094,11 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ), transportClosed, ]); - try { - entry.events.publish({ - type: 'model_switched', - data: { sessionId: entry.sessionId, modelId: req.modelId }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } catch { - /* bus closed */ - } + entry.events.publish({ + type: 'model_switched', + data: { sessionId: entry.sessionId, modelId: req.modelId }, + ...(originatorClientId ? { originatorClientId } : {}), + }); return result; } finally { entry.modelRoundtripInFlight = false; @@ -3022,9 +3169,16 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { 'and tests must opt in or omit `persist`.', ); } - let response: { previous: ApprovalMode; current: ApprovalMode }; - try { - response = (await Promise.race([ + // Serialize the WHOLE change — ACP roundtrip + persist + publish — through + // `entry.approvalModeQueue` (A3). Covering only the `extMethod` call (the + // earlier shape) left persist+publish OUTSIDE the queue: two concurrent + // `persist:true` calls could interleave their persist phases and publish + // out of order, so the bus's last `approval_mode_changed` disagreed with + // the mode the ACP child actually settled on. Keeping persist+publish in + // the queued work means the next change can't start its `extMethod` until + // this change's side effects are fully done. Mirrors `modelChangeQueue`. + const approvalWork = entry.approvalModeQueue.then(async () => { + const response = (await Promise.race([ withTimeout( entry.connection.extMethod( SERVE_CONTROL_EXT_METHODS.sessionApprovalMode, @@ -3035,13 +3189,77 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { ), getTransportClosedReject(entry), ])) as { previous: ApprovalMode; current: ApprovalMode }; + + let persisted = false; + if (opts.persist) { + try { + await withTimeout( + persistApprovalMode?.(boundWorkspace, mode) ?? Promise.resolve(), + PERSIST_TIMEOUT_MS, + 'persistApprovalMode', + ); + persisted = persistApprovalMode !== undefined; + } catch (err) { + // Persist failure is non-fatal — the in-process change already + // took effect inside the ACP child. Log but don't fail the route. + writeStderrLine( + `setSessionApprovalMode: persist failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + try { + entry.events.publish({ + type: 'approval_mode_changed', + data: { + sessionId: entry.sessionId, + previous: response.previous, + next: response.current, + persisted, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } catch { + /* bus closed */ + } + // #4282 fold-in 4 (S2): a persisted change becomes the workspace + // default, so fan out a workspace-scoped mirror for peer sessions. + // #4297 fold-in 1: skip the requesting session (its own bus already + // got the publish above) to avoid double-counting in the reducer. + if (persisted) { + broadcastWorkspaceEvent( + { + type: 'approval_mode_changed', + data: { + sessionId: entry.sessionId, + previous: response.previous, + next: response.current, + persisted, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }, + entry.sessionId, + ); + } + return { + sessionId: entry.sessionId, + mode: response.current, + previous: response.previous, + persisted, + }; + }); + // Tail-swallow so a failed change doesn't poison subsequent ones. + entry.approvalModeQueue = approvalWork.then( + () => undefined, + () => undefined, + ); + try { + return await approvalWork; } catch (err) { - // The ACP child rethrows `TrustGateError` as a JSON-RPC error - // whose `data.errorKind` is the literal `'trust_gate'`. On the - // wire it arrives as a plain `{code, message, data}` object — - // re-instantiate the typed class here so the HTTP route layer - // recognizes it via `instanceof` / `err.name` and maps the - // failure to HTTP 403 with the `auth_env_error` errorKind. + // The ACP child rethrows `TrustGateError` as a JSON-RPC error whose + // `data.errorKind` is `'trust_gate'`; re-instantiate the typed class so + // the HTTP route maps it to 403 with the `auth_env_error` errorKind. const data = (err as { data?: unknown })?.data; if ( data && @@ -3058,71 +3276,6 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { } throw err; } - let persisted = false; - if (opts.persist) { - try { - await persistApprovalMode?.(boundWorkspace, mode); - persisted = persistApprovalMode !== undefined; - } catch (err) { - // Persist failure is non-fatal — the in-process change already - // took effect inside the ACP child. Log to stderr so operators - // notice but don't fail the route (the SDK consumer would have - // no good recovery path; the runtime change is real). - writeStderrLine( - `setSessionApprovalMode: persist failed: ${ - err instanceof Error ? err.message : String(err) - }`, - ); - } - } - try { - entry.events.publish({ - type: 'approval_mode_changed', - data: { - sessionId: entry.sessionId, - previous: response.previous, - next: response.current, - persisted, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } catch { - /* bus closed */ - } - // #4282 fold-in 4 (qwen-latest S2): when the change is persisted to - // workspace settings, the new mode becomes the default for every - // future session in this workspace. Fan out a workspace-scoped - // mirror so peer sessions can update their UI before they next - // spawn an ACP child. The session-scoped publish above remains the - // authoritative signal for the requesting session (and carries the - // sessionId in `data`); the workspace mirror is informational. - // - // #4297 fold-in 1: skip the requesting session in the broadcast. - // The session-scoped publish above already delivered the event on - // its own bus, so a broadcast that included it would double-count - // in the SDK reducer's `approvalModeChangedCount` (peers see 1, - // requester used to see 2 — silent contract violation). - if (persisted) { - broadcastWorkspaceEvent( - { - type: 'approval_mode_changed', - data: { - sessionId: entry.sessionId, - previous: response.previous, - next: response.current, - persisted, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }, - entry.sessionId, - ); - } - return { - sessionId: entry.sessionId, - mode: response.current, - previous: response.previous, - persisted, - }; }, async generateSessionRecap(sessionId, _context) { @@ -3159,483 +3312,134 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { }; }, - 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 - // sessions retain their already-registered tools until the next - // ACP child spawn (when `tools.disabled` is consulted at Config - // construction time). - if (!persistDisabledTools) { - throw new Error( - 'setWorkspaceToolEnabled requires `persistDisabledTools` in ' + - 'BridgeOptions; runQwenServe wires the production callback. ' + - 'Direct embeds and tests must opt in.', - ); + async executeShellCommand( + sessionId, + command, + signal, + context, + ): Promise { + const entry = byId.get(sessionId); + if (!entry) throw new SessionNotFoundError(sessionId); + const originatorClientId = resolveTrustedClientId( + entry, + context?.clientId, + ); + + if (signal?.aborted) { + return { exitCode: null, output: '', aborted: true }; } - await persistDisabledTools(boundWorkspace, toolName, enabled); - broadcastWorkspaceEvent({ - type: 'tool_toggled', - data: { toolName, enabled }, + + const cwd = entry.workspaceCwd; + + entry.events.publish({ + type: 'user_shell_command', + data: { sessionId, command, cwd }, ...(originatorClientId ? { originatorClientId } : {}), }); - return { toolName, enabled }; - }, - async restartMcpServer(serverName, originatorClientId, opts) { - // #4175 Wave 4 PR 17. The restart logic lives inside the ACP - // child (it owns the `McpClientManager`); the bridge's role is - // to (a) pick a live channel to forward through, (b) translate - // the structured response back into the typed result, (c) fan - // out the appropriate event to every session bus. Soft refusals - // (skipped:true) come back as a normal response; hard errors - // (server not configured, manager unavailable, post-discover - // not connected) are translated via `data.errorKind` into typed - // bridge errors that `sendBridgeError` maps to stable HTTP - // responses (#4282 gpt-5.5 C4/C5 fold-in). - // - // F2 (#4175 commit 5): `opts.entryIndex` is forwarded to the - // ACP child for pool-mode restart targeting. The agent's - // handler falls back to legacy single-entry semantics when no - // pool entry matches, so older daemons that don't yet honor - // `entryIndex` keep the pre-F2 response shape — clients - // gated on the `mcp_pool_restart` capability tag are the only - // ones that send `entryIndex`. - const info = liveChannelInfo(); - if (!info) { - throw new SessionNotFoundError(`mcp:${serverName}`); - } - type LegacyOk = { - serverName: string; - restarted: true; - durationMs: number; - }; - type LegacySkip = { - serverName: string; - restarted: false; - skipped: true; - reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; - }; - type PoolEntries = { - serverName: string; - entries: Array<{ - entryIndex: number; - restarted: boolean; - durationMs?: number; - reason?: string; - }>; - }; - let response: LegacyOk | LegacySkip | PoolEntries; - const params: Record = { serverName }; - if (opts?.entryIndex !== undefined) { - params['entryIndex'] = opts.entryIndex; - } + const outputChunks: string[] = []; + const abort = new AbortController(); + const onSignalAbort = () => abort.abort(); + signal?.addEventListener('abort', onSignalAbort, { once: true }); + try { - response = (await Promise.race([ - withTimeout( - info.connection.extMethod( - SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart, - params, - ), - MCP_RESTART_TIMEOUT_MS, - SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart, - ), - getChannelClosedReject(info), - ])) as LegacyOk | LegacySkip | PoolEntries; - } catch (err) { - // Detect structured ACP error payloads and re-instantiate as - // typed bridge errors. JSON-RPC strips class names across the - // wire; the agent attaches `data.errorKind` as the - // reconstruction signal. - const data = (err as { data?: unknown })?.data; - if (data && typeof data === 'object') { - const kind = (data as { errorKind?: unknown }).errorKind; - const sn = (data as { serverName?: unknown }).serverName; - if (kind === 'mcp_server_not_found' && typeof sn === 'string') { - throw new McpServerNotFoundError(sn); - } - if (kind === 'mcp_restart_failed' && typeof sn === 'string') { - const status = (data as { mcpStatus?: unknown }).mcpStatus; - throw new McpServerRestartFailedError( - sn, - typeof status === 'string' ? status : 'unknown', - ); - } - } - throw err; - } - // F2 (#4175 commit 5): pool-mode `entries[]` shape fans out one - // typed event per entry so SDK reducers see a stable per-entry - // count regardless of whether the underlying restart was - // single-entry (legacy) or multi-entry (pool-mode). Reusing the - // existing `mcp_server_restarted` / `mcp_server_restart_refused` - // event types keeps `KnownDaemonEvent` schema additive — clients - // gated only on `entryCount > 1` get accurate per-entry signals - // without a new event type. - // F2 (#4175 commit 6 review fix — wenshao W15): the response - // arrives as untyped JSON from `info.connection.extMethod(...)` - // — a buggy/out-of-sync ACP child returning a malformed shape - // (e.g. `entries` is a string, or per-entry objects miss - // `entryIndex`) would otherwise crash this route with a - // TypeError. Add a runtime shape check and degrade-with-error - // for entries that don't match the typed wire contract. - if ('entries' in response) { - const entries = Array.isArray(response.entries) ? response.entries : []; - if (!Array.isArray(response.entries)) { - writeStderrLine( - `qwen serve: pool restart response carried 'entries' field ` + - `but it is not an array (server=${response.serverName}); ` + - `treating as empty.`, - ); - } - for (const entry of entries) { - if ( - typeof entry !== 'object' || - entry === null || - typeof (entry as { entryIndex?: unknown }).entryIndex !== 'number' - ) { - writeStderrLine( - `qwen serve: skipping malformed pool restart entry ` + - `(server=${response.serverName}): ${JSON.stringify(entry)}`, - ); - continue; - } - if (entry.restarted) { - broadcastWorkspaceEvent({ - type: 'mcp_server_restarted', - data: { - serverName: response.serverName, - durationMs: entry.durationMs ?? 0, - entryIndex: entry.entryIndex, - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } else { - broadcastWorkspaceEvent({ - type: 'mcp_server_restart_refused', - data: { - serverName: response.serverName, - reason: 'restart_failed', - entryIndex: entry.entryIndex, - ...(entry.reason ? { details: entry.reason } : {}), - }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } - } - } else if (response.restarted === true) { - broadcastWorkspaceEvent({ - type: 'mcp_server_restarted', - data: { - serverName: response.serverName, - durationMs: response.durationMs, + const handle = await ShellExecutionService.execute( + command, + cwd, + (event: ShellOutputEvent) => { + if (event.type === 'data') { + const chunk = + typeof event.chunk === 'string' + ? event.chunk + : event.chunk + .map((line: Array<{ text: string }>) => + line.map((t) => t.text).join(''), + ) + .join('\n'); + outputChunks.push(chunk); + entry.events.publish({ + type: 'session_update', + data: { + sessionId, + update: { + sessionUpdate: 'shell_output', + output: chunk, + _meta: { + serverTimestamp: Date.now(), + source: 'user-shell', + }, + }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + } }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - } else { - broadcastWorkspaceEvent({ - type: 'mcp_server_restart_refused', + abort.signal, + false, + { terminalWidth: 120, terminalHeight: 40 }, + { streamStdout: true }, + ); + + const timeoutId = setTimeout( + () => abort.abort(), + SHELL_COMMAND_TIMEOUT_MS, + ); + timeoutId.unref(); + + const result = await handle.result; + clearTimeout(timeoutId); + + const exitCode = result.exitCode; + const aborted = result.aborted; + const output = outputChunks.join('') || result.output; + + entry.events.publish({ + type: 'user_shell_result', data: { - serverName: response.serverName, - reason: response.reason, + sessionId, + exitCode, + signal: result.signal, + aborted, + _meta: { serverTimestamp: Date.now() }, }, ...(originatorClientId ? { originatorClientId } : {}), }); - } - return response; - }, - async initWorkspace(initOpts, originatorClientId) { - // #4175 Wave 4 PR 17. Mechanical scaffold of an empty `QWEN.md` - // (or whatever `getCurrentGeminiMdFilename()` returns under - // `--memory-file-name` overrides). No ACP roundtrip, no LLM - // call — clients that want AI-fill follow up with - // `POST /session/:id/prompt`. - // - // FIXME(#4282 fold-in 2 — deepseek SV2): this route uses - // `node:fs/promises` directly instead of routing through - // `WorkspaceFileSystem` (PR 18 boundary), so it produces no - // `fs.access`/`fs.denied` audit trail and skips - // `assertTrustedForIntent`. The bridge doesn't have an - // `fsFactory` plumbed at the bridge layer today — the boundary - // is constructed per-request inside `createServeApp` for PR 19+ - // routes. A follow-up will hoist the factory into - // `BridgeOptions` so daemon-level routes (init, future - // workspace ops) can share the same trust + audit posture. - // Impact today is low: the daemon binds to a workspace the - // operator chose and the trust dialog flow doesn't yet exist - // for the daemon. The CV1 symlink reject below covers the - // immediate boundary-escape concern. - // #4282 fold-in 5 (Codex P2-1). Use the snapshot from - // `BridgeOptions.contextFilename` (sourced from the workspace's - // merged settings at daemon boot) instead of the process-global - // `getCurrentGeminiMdFilename()` — the daemon parent never goes - // through `loadCliConfig`, so a workspace configured with - // `context.fileName: 'AGENTS.md'` would otherwise see init - // create `QWEN.md` while the rest of the workspace reads a - // different file. - const filename = contextFilename; - // #4282 gpt-5.5 C1 fold-in: `context.fileName` is settings- - // controlled. A daemon configured with `context.fileName: - // "../outside.md"` would otherwise resolve outside - // `boundWorkspace` and let this strict-gated mutation create - // or truncate a file outside the workspace boundary. Resolve - // the joined path and reject anything that escapes. - const target = path.resolve(boundWorkspace, filename); - const withinWorkspace = - target === boundWorkspace || - target.startsWith(boundWorkspace + path.sep); - if (!withinWorkspace) { - throw new WorkspaceInitPathEscapeError(filename, boundWorkspace); - } - // #4282 fold-in 5 (Codex P2-4). The textual `withinWorkspace` - // and final-component `lstat` checks miss intermediate symlinks - // — e.g. `context.fileName: "docs/QWEN.md"` with `docs` a - // symlink to `/tmp` would let the later `writeFile(target)` - // follow the parent symlink and create or truncate outside - // `boundWorkspace`. Resolve the parent chain via - // `realpath`, walking up through any not-yet-existing ancestors, - // and verify the canonical parent stays within the canonical - // workspace. - const wsCanonical = await fs.realpath(boundWorkspace); - const parentCanonical = await canonicalizeExistingAncestor( - path.dirname(target), - ); - const parentWithinWorkspace = - parentCanonical === wsCanonical || - parentCanonical.startsWith(wsCanonical + path.sep); - if (!parentWithinWorkspace) { - throw new WorkspaceInitSymlinkError( - target, - 'parent', - `Configured workspace context filename ${JSON.stringify(filename)} ` + - `has a parent path that resolves outside the bound workspace ` + - `(parent canonicalizes to ${JSON.stringify(parentCanonical)}, ` + - `workspace canonicalizes to ${JSON.stringify(wsCanonical)}). ` + - `Refusing to write — replace any symlinked parent directory ` + - `with a real directory before re-running init.`, - ); - } - // #4282 fold-in 2 (gpt-5.5 CV1): the textual `withinWorkspace` - // check above only validates the JOINED path, but a file at - // `target` that's a symlink can still point outside the - // workspace. Without an explicit `lstat` reject, `force: true` - // would follow the link and truncate the external target; a - // dangling-symlink pointing outside would also let `writeFile` - // create the external target. Reject symlinks at the boundary - // — PR 18's `WorkspaceFileSystem` will provide the proper - // chain-aware resolution + audit hooks once `initWorkspace` - // routes through that boundary (tracked as a follow-up). - try { - const lst = await fs.lstat(target); - if (lst.isSymbolicLink()) { - throw new WorkspaceInitSymlinkError( - target, - 'target', - `Workspace context file ${JSON.stringify(target)} is a symlink. ` + - `Refusing to follow it for write — replace the symlink with a ` + - `regular file (or remove it) before re-running init.`, - ); - } - } catch (err) { - if (err instanceof WorkspaceInitSymlinkError) throw err; - const code = (err as { code?: unknown } | null | undefined)?.code; - if (code !== 'ENOENT') throw err; - // ENOENT — target doesn't exist; fresh create is fine. - } - let existingSize: number | undefined; - let action: 'created' | 'overwrote' | 'noop' = 'created'; - try { - const existing = await fs.readFile(target, 'utf8'); - if (existing.trim().length > 0) { - existingSize = Buffer.byteLength(existing, 'utf8'); - if (initOpts.force !== true) { - throw new WorkspaceInitConflictError(target, existingSize); - } - action = 'overwrote'; - } else { - // #4282 wenshao H4 fold-in: an existing whitespace-only file - // is treated as a no-op rather than silently overwritten. - // Previously the code would label the response `'created'` - // and unconditionally `writeFile(target, '')`, destroying - // the user's whitespace content (stray template, half- - // written init, intentional newline) without `force: true`. - // The HTTP intent of "init only if absent" is honored by - // skipping the write and surfacing `'noop'` so the SSE - // event accurately reflects that no on-disk change - // occurred. - action = 'noop'; - } - } catch (err) { - if (err instanceof WorkspaceInitConflictError) throw err; - const code = (err as { code?: unknown } | null | undefined)?.code; - if (code !== 'ENOENT') throw err; - // ENOENT — fall through to create. - } - if (action === 'created') { - // #4297 fold-in 5 (wenshao critical, addresses #3260836305). - // Close the TOCTOU window between the `lstat`/`readFile` - // checks above and this write by using `'wx'` - // (O_WRONLY|O_CREAT|O_EXCL): the open atomically refuses - // when ANY inode (regular file, dir, symlink, …) exists at - // the target path, so a local attacker can't slip a symlink - // between our checks and follow it through here. - // - // #4297 fold-in 10 (qwen-latest S2, addresses #3263954690): - // EEXIST bubbles up as `WorkspaceInitRaceError(kind: - // 'eexist')` — a sibling class to `WorkspaceInitSymlinkError` - // so the HTTP code distinguishes a race-created inode (could - // be regular file OR symlink — we don't know which) from the - // symlink-confirmed cases at the lstat / O_NOFOLLOW sites. - let fh: import('node:fs/promises').FileHandle; - try { - fh = await fs.open(target, 'wx'); - } catch (err) { - const code = (err as { code?: unknown } | null | undefined)?.code; - if (code === 'EEXIST') { - throw new WorkspaceInitRaceError( - target, - 'eexist', - `Workspace context file ${JSON.stringify(target)} appeared ` + - `between our absence check and the create — refusing to ` + - `proceed (a regular file or symlink was just placed at the ` + - `target path, and following it could escape the workspace).`, - ); - } - throw err; - } - try { - // #4297 fold-in 10 (qwen-latest S5, addresses #3263954707): - // post-open parent re-verification narrows the parent-symlink - // TOCTOU window between `canonicalizeExistingAncestor` and - // `fs.open`. `O_NOFOLLOW` only protects the final component; - // a local writer could swap a real `docs/` parent for a - // `docs -> /tmp` symlink between our pre-check and this open - // and the kernel would resolve the parent unconditionally. - // Re-canonicalizing the parent post-open and refusing the - // write when it moved out of the workspace catches that race - // (cost: one extra realpath syscall per init). - await verifyParentWithinWorkspace(target, wsCanonical, 'create', fh); - await fh.writeFile('', 'utf8'); - } finally { - await fh.close(); - } - } else if (action === 'overwrote') { - // #4297 fold-in 7 (gpt-5.5 critical, addresses #3262615446): - // close the TOCTOU window on the `force: true` path with - // `O_WRONLY|O_TRUNC|O_NOFOLLOW`. `O_NOFOLLOW` causes - // `open()` to fail with ELOOP if the final component is a - // symlink — even if a local writer races a symlink in - // between our `lstat`/`readFile` checks and this open, the - // open refuses rather than truncating the link target. - // (The symbol exists on Linux/macOS; on Windows the constant - // is 0/no-op since the OS always follows symlinks, which - // is consistent with the documented Stage-1 trust posture - // there — Windows daemon support is best-effort.) - let fh: import('node:fs/promises').FileHandle; + const historyOutput = + output.length > MAX_SHELL_OUTPUT_FOR_HISTORY + ? output.substring(0, MAX_SHELL_OUTPUT_FOR_HISTORY) + + '\n... (truncated)' + : output; + try { - // #4297 post-merge wenshao Critical fold-in (folded into F1 - // #4319): drop `O_TRUNC` from the open flags. The kernel - // applies O_TRUNC AT `open(2)` SYSCALL TIME — before - // `verifyParentWithinWorkspace` (below) gets a chance to - // detect a parent-symlink race. With O_TRUNC, a local user - // who wins the TOCTOU between `canonicalizeExistingAncestor` - // and this `open()` zeros the file at the attacker- - // redirected location (arbitrary-file-truncation primitive - // against any file the daemon UID can open). The pre-fix - // code's own comment on `verifyParentWithinWorkspace` - // acknowledged this as "documented residual risk"; wenshao - // pushed back that this exceeds the Stage-1 trust model. - // - // Truncation now happens AFTER `verifyParentWithinWorkspace` - // succeeds, via `fh.truncate(0)` on the fd we already hold. - // fd-based truncate does NOT re-resolve the path, so an - // attacker swapping the parent symlink after we open can't - // redirect the truncation. - // - // #4297 fold-in 10 (qwen-latest S3, addresses #3263954697): - // `O_NOFOLLOW ?? 0` matches the defensive pattern in - // `core/src/utils/{sessionStorageUtils,gitDiff}.ts` and - // `cli/src/ui/utils/customBanner.ts` for platforms that - // don't expose the constant. Functionally a no-op (JS - // bitwise coerces `undefined` to 0) but keeps the codebase - // consistent for the next greppy refactor. - fh = await fs.open( - target, - fsConstants.O_WRONLY | (fsConstants.O_NOFOLLOW ?? 0), + await entry.connection.extMethod( + SERVE_CONTROL_EXT_METHODS.sessionShellHistory, + { sessionId, command, output: historyOutput, exitCode }, ); } catch (err) { - const code = (err as { code?: unknown } | null | undefined)?.code; - // #4297 fold-in 8 (qwen-latest S1, addresses #3262861754): - // split ELOOP and ENOENT diagnostics so operators don't - // misdiagnose. ELOOP is the genuine `O_NOFOLLOW` rejection - // — a symlink at the final component, possibly an attack - // race. ENOENT here means the file was DELETED between - // the readFile content check and this open — a benign race - // with a concurrent writer (git checkout, editor save). - // Both still surface as `WorkspaceInitSymlinkError(kind: - // 'target')` so the route maps to a structured 400; the - // class doubles as the workspace-init race-condition - // bucket, but the message is now accurate per case. - if (code === 'ELOOP') { - throw new WorkspaceInitSymlinkError( - target, - 'target', - `Workspace context file ${JSON.stringify(target)} could not ` + - `be opened with O_NOFOLLOW (ELOOP); the path may have been ` + - `swapped to a symlink between the content check and the ` + - `overwrite. Refusing to follow it.`, - ); - } - if (code === 'ENOENT') { - // #4297 fold-in 10 (qwen-latest S2, addresses #3263954690): - // ENOENT here means race-deletion, not a symlink — use the - // sibling `WorkspaceInitRaceError` so the HTTP code - // (`workspace_init_race`) doesn't mislead operators into - // hunting a symlink attack on benign concurrent-modification. - throw new WorkspaceInitRaceError( - target, - 'enoent', - `Workspace context file ${JSON.stringify(target)} was deleted ` + - `between the content check and the overwrite (likely a ` + - `concurrent writer — git checkout, editor save, etc.). ` + - `Refusing to recreate blindly; rerun init.`, - ); - } - throw err; - } - try { - // #4297 fold-in 10 (qwen-latest S5, addresses #3263954707): - // same post-open parent re-verification as the create path. - // The overwrite branch is more sensitive — race-substituting - // a parent symlink to redirect TRUNCATE outside the workspace - // is the worst-case escape. Verifying after `O_NOFOLLOW` open - // succeeds catches the parent-only race that O_NOFOLLOW - // doesn't cover (the kernel resolved the parent path). - await verifyParentWithinWorkspace( - target, - wsCanonical, - 'overwrite', - fh, + writeServeDebugLine( + `shell history injection failed for session ${sessionId}: ${err instanceof Error ? err.message : String(err)}`, ); - // #4297 post-merge wenshao Critical fold-in (folded into F1 - // #4319): truncate AFTER verify, using the fd we already - // hold. fd-based truncate doesn't re-resolve the path, so - // an attacker who swaps the parent symlink between - // verifyParentWithinWorkspace and here can't redirect the - // truncation to an external file. See the open-flags - // comment above for the full O_TRUNC race analysis. - await fh.truncate(0); - await fh.writeFile('', 'utf8'); - } finally { - await fh.close(); } + + return { exitCode, output, aborted }; + } catch (err) { + entry.events.publish({ + type: 'user_shell_result', + data: { + sessionId, + exitCode: null, + signal: null, + aborted: false, + error: err instanceof Error ? err.message : String(err), + _meta: { serverTimestamp: Date.now() }, + }, + ...(originatorClientId ? { originatorClientId } : {}), + }); + throw err; + } finally { + signal?.removeEventListener('abort', onSignalAbort); } - broadcastWorkspaceEvent({ - type: 'workspace_initialized', - data: { path: target, action }, - ...(originatorClientId ? { originatorClientId } : {}), - }); - return { path: target, action }; }, async killSession(sessionId, opts) { @@ -3891,129 +3695,6 @@ export function createHttpAcpBridge(opts: BridgeOptions): HttpAcpBridge { }; } -/** - * #4282 fold-in 5 (Codex P2-4). Resolve `inputPath` to its real - * filesystem path, walking up through directory components that - * don't yet exist on disk. Used by `initWorkspace` to make sure - * every parent directory of the target file canonicalizes inside - * the bound workspace — a symlink at any level (e.g. `docs/QWEN.md` - * with `docs -> /tmp`) would otherwise let `writeFile` escape the - * workspace boundary. - * - * The walk-up mirrors what `realpath` would do if it accepted - * non-existent terminal segments: the deepest extant ancestor - * dictates the canonical prefix, and any not-yet-created components - * inherit it. Hitting the filesystem root (`path.dirname(p) === p`) - * without finding anything that exists rethrows the underlying - * ENOENT — by that point the input was unrooted in a way the - * caller's contract can't honor. - */ -async function canonicalizeExistingAncestor( - inputPath: string, -): Promise { - let current = inputPath; - while (true) { - try { - return await fs.realpath(current); - } catch (err) { - const code = (err as NodeJS.ErrnoException | null | undefined)?.code; - // #4297 post-merge wenshao S2 fold-in (folded into F1 #4319): - // also catch ELOOP — a circular symlink in the parent path - // (e.g., `a -> b`, `b -> a`) makes `fs.realpath` fail with - // ELOOP. Without this, that bubbles up as an unstructured - // HTTP 500 instead of the typed `WorkspaceInitSymlinkError` - // (400) the route handler expects from the workspace-init - // race detection family. Walking up the parent chain when - // ELOOP hits at a sub-component preserves the existing - // "walk to the deepest extant ancestor" contract. - if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') { - throw err; - } - const parent = path.dirname(current); - if (parent === current) throw err; - current = parent; - } - } -} - -/** - * #4297 fold-in 10 (qwen-latest S5, addresses #3263954707). Re-verify - * the parent directory canonicalizes inside the workspace AFTER the - * `fs.open()` succeeded. `O_NOFOLLOW` only covers the final path - * component; a local writer with workspace write access could race- - * substitute a parent dir for a symlink between the pre-open - * `canonicalizeExistingAncestor` check and the actual open. The - * kernel resolves parent symlinks unconditionally during open, so - * the fd we just opened may point outside `wsCanonical`. - * - * Catching the race after the fact: re-realpath the parent and - * compare against `wsCanonical`. If the parent moved, the open - * succeeded against an out-of-workspace inode; we throw - * `WorkspaceInitSymlinkError(kind: 'parent')` so the route maps - * to a 400 (config / race / attack ambiguous, but not a daemon - * failure). - * - * The cleanup parameter distinguishes: - * - `'create'`: the open created the file; on race detection we - * unlink it best-effort to avoid leaving an empty file in the - * attacker's redirected location. - * - `'overwrite'`: the open truncated an existing file we'd - * already content-checked. The truncate happened at open time - * so the damage (zero-length file at the redirected path) is - * already done — but the throw at least prevents subsequent - * write content; documented residual risk. - * - * Residual race window: between this `realpath` and the next - * `writeFile` on the fd, the parent could in principle be swapped - * again. The fd we hold is still valid against the inode it was - * opened against, which is what `writeFile` writes to — so this - * remaining window is sub-millisecond and bounded to "fd opened - * against an inode that briefly was under wsCanonical." Acceptable - * residual posture for the Stage-1 trust model. - */ -async function verifyParentWithinWorkspace( - target: string, - wsCanonical: string, - cleanup: 'create' | 'overwrite', - fh: import('node:fs/promises').FileHandle, -): Promise { - const parentCanonical = await canonicalizeExistingAncestor( - path.dirname(target), - ); - const within = - parentCanonical === wsCanonical || - parentCanonical.startsWith(wsCanonical + path.sep); - if (within) return; - // Best-effort cleanup before throwing. We're already in a failure - // path; ignore secondary errors so the original race-detection - // throw isn't shadowed. - // - // #4297 post-merge wenshao Critical fold-in (folded into F1 #4319): - // do NOT `fs.unlink(target)`. After a parent-directory race the - // textual `target` path now resolves through the attacker's freshly- - // planted parent symlink to an external location — `fs.unlink` - // would happily delete whatever file exists at the attacker's - // chosen path, giving any local user with workspace write access - // an arbitrary-file-deletion primitive against the daemon's UID. - // The empty file we created at the pre-race location is harmless - // (0 bytes, inside the workspace we'd just verified). Leaving it - // there over deleting an arbitrary external file is the right - // safety trade. - if (cleanup === 'create') { - await fh.close().catch(() => {}); - } - throw new WorkspaceInitSymlinkError( - target, - 'parent', - `Workspace context file ${JSON.stringify(target)}'s parent moved ` + - `outside the workspace between the pre-open canonicalize and ` + - `the post-open verify (parent canonicalizes to ${JSON.stringify(parentCanonical)}, ` + - `workspace canonicalizes to ${JSON.stringify(wsCanonical)}). ` + - `Refusing to write — investigate the concurrent writer or the ` + - `parent-directory permissions.`, - ); -} - /** * Race `p` against a timeout. The timeout REJECTS the returned * promise but does NOT abort the underlying operation — `p` keeps @@ -4058,3 +3739,6 @@ async function withTimeout( if (timer) clearTimeout(timer); } } + +/** @deprecated Use `createAcpSessionBridge` instead. */ +export const createHttpAcpBridge = createAcpSessionBridge; diff --git a/packages/acp-bridge/src/bridgeClient.ts b/packages/acp-bridge/src/bridgeClient.ts index 5e54ec80a00..95feb6fa0c5 100644 --- a/packages/acp-bridge/src/bridgeClient.ts +++ b/packages/acp-bridge/src/bridgeClient.ts @@ -109,9 +109,12 @@ function preserveFsErrorOverAcp(err: unknown): never { */ function resolutionToAcpResponse( resolution: PermissionResolution, -): RequestPermissionResponse { +): RequestPermissionResponse & Record { if (resolution.kind === 'option') { - return { outcome: { outcome: 'selected', optionId: resolution.optionId } }; + return { + outcome: { outcome: 'selected', optionId: resolution.optionId }, + ...(resolution.metadata ?? {}), + }; } return { outcome: { outcome: 'cancelled' } }; } @@ -146,6 +149,7 @@ function resolutionToAcpResponse( */ const MAX_EARLY_EVENT_SESSIONS = 64; const MAX_EARLY_EVENTS_PER_SESSION = 32; +const MAX_SUGGESTION_LENGTH = 500; const EARLY_EVENT_TTL_MS = 60_000; /** @@ -481,36 +485,45 @@ export class BridgeClient implements Client { private readonly inFlightRestoreIds = new Set(); /** - * PR 14b: handle child→bridge ACP `extNotification` calls. Two methods - * are recognized — `qwen/notify/session/mcp-budget-event` (PR 14b, - * McpClientManager budget events) and `qwen/notify/session/model-update` - * (A1 #4511, in-session model switch) — each translated into a - * session-scoped SSE frame. Unknown methods, unknown event kinds, - * and missing sessionIds are dropped silently for forward-compat - * (a future child can add new notification methods without breaking - * this handler; an older daemon can ignore them cleanly). - * - * Codex review fix #1: when the sessionId IS present but the - * `byId`-resolvable entry is not yet registered (the child fired - * the event during its own `newSession` handler, before - * `connection.newSession` returned to `doSpawn`), buffer the frame - * and replay it on `drainEarlyEvents`. + * Handle child→bridge ACP `extNotification` calls. Three methods are + * recognized — `qwen/notify/session/model-update` (A1 #4511), + * `qwen/notify/session/prompt-suggestion` (followup assist), and + * `qwen/notify/session/mcp-budget-event` (PR 14b) — each translated + * into a session-scoped SSE frame. Unknown methods are dropped + * silently for forward-compat. */ async extNotification( method: string, params: Record, ): Promise { - // A1 (#4511): demux an in-session model switch to a `model_switched` - // bus event. `current_model_update` is not an ACP SessionUpdate variant, - // so the agent emits it over this side-channel; the bridge promotes it - // here — except while the bridge itself is driving the change (the HTTP - // path also flows through Session.setModel), where it publishes - // `model_switched` authoritatively and this notification is suppressed to - // avoid a double publish. if (method === 'qwen/notify/session/model-update') { this.handleInSessionModelUpdate(params); return; } + if (method === 'qwen/notify/session/prompt-suggestion') { + const sessionId = params['sessionId']; + const suggestion = params['suggestion']; + const promptId = params['promptId']; + if ( + typeof sessionId !== 'string' || + typeof suggestion !== 'string' || + suggestion.length === 0 || + suggestion.length > MAX_SUGGESTION_LENGTH || + typeof promptId !== 'string' + ) { + writeStderrLine( + `[demux] session=${typeof sessionId === 'string' ? sessionId : ''} type=prompt_suggestion action=dropped reason=malformed`, + ); + return; + } + const entry = this.resolveEntry(sessionId); + if (!entry) return; + entry.events.publish({ + type: 'followup_suggestion', + data: { sessionId, suggestion, promptId }, + }); + return; + } if (method !== 'qwen/notify/session/mcp-budget-event') return; const sessionId = params['sessionId']; if (typeof sessionId !== 'string') return; diff --git a/packages/acp-bridge/src/bridgeOptions.ts b/packages/acp-bridge/src/bridgeOptions.ts index f237594bf47..685f0a45711 100644 --- a/packages/acp-bridge/src/bridgeOptions.ts +++ b/packages/acp-bridge/src/bridgeOptions.ts @@ -92,8 +92,25 @@ export interface DaemonStatusProvider { ): Promise; } +export type BridgeTelemetryAttributes = Record< + string, + string | number | boolean +>; + +export interface BridgeTelemetry { + captureContext(): unknown; + runWithContext(captured: unknown, fn: () => Promise): Promise; + withSpan( + operation: string, + attributes: BridgeTelemetryAttributes, + fn: () => Promise, + ): Promise; + event(name: string, attributes: BridgeTelemetryAttributes): void; + injectPromptContext(request: T): T; +} + /** - * Construction options for `createHttpAcpBridge`. Most fields are + * Construction options for `createAcpSessionBridge`. Most fields are * tuning knobs with sensible defaults; `boundWorkspace` is the only * strictly-required field. See per-field JSDoc for caller contract. */ @@ -173,7 +190,7 @@ export interface BridgeOptions { * theoretically diverge from the runQwenServe canonicalize on * NFS-transient / mid-rename filesystems, landing the bridge with * one canonical form while `/capabilities` advertises another). - * Direct embeds / tests calling `createHttpAcpBridge` themselves + * Direct embeds / tests calling `createAcpSessionBridge` themselves * MUST canonicalize before passing. */ boundWorkspace: string; @@ -231,18 +248,6 @@ export interface BridgeOptions { toolName: string, enabled: boolean, ) => Promise; - /** - * #4282 fold-in 5 (Codex P2-1). Optional override for the basename - * (or single relative path) of the workspace context file written - * by `POST /workspace/init`. When omitted, falls back to - * `getCurrentGeminiMdFilename()` — the process-global value, which - * the daemon parent never updates because it doesn't go through - * `loadCliConfig`. Production callers (`runQwenServe`) snapshot the - * resolved filename from the workspace's merged settings at boot - * and pass it here so init writes the same file the ACP child - * reads. Bridge tests can pass any literal. - */ - contextFilename?: string; /** * #4175 Wave 5 PR 22b/2 — optional injection seam for daemon-host * status cells (env snapshot, daemon preflight). Production @@ -266,6 +271,8 @@ export interface BridgeOptions { * still query the routes; they'll see empty/idle cells. */ statusProvider?: DaemonStatusProvider; + /** Optional daemon telemetry seam. Omitted callers get no-op spans/logs. */ + telemetry?: BridgeTelemetry; /** * Optional fs injection seam (#4175 PR F1 step 5, originally the diff --git a/packages/acp-bridge/src/bridgeTypes.ts b/packages/acp-bridge/src/bridgeTypes.ts index 8a46e82852d..23ea01cc1be 100644 --- a/packages/acp-bridge/src/bridgeTypes.ts +++ b/packages/acp-bridge/src/bridgeTypes.ts @@ -20,13 +20,10 @@ import type { PermissionPolicy } from './permission.js'; import type { ServeSessionContextStatus, ServeSessionSupportedCommandsStatus, - ServeWorkspaceEnvStatus, + ServeSessionTasksStatus, ServeWorkspaceMcpToolsStatus, - ServeWorkspaceMcpStatus, - ServeWorkspacePreflightStatus, - ServeWorkspaceProvidersStatus, - ServeWorkspaceSkillsStatus, ServeWorkspaceToolsStatus, + ServeSessionContextUsageStatus, } from './status.js'; export interface BridgeSpawnRequest { @@ -110,6 +107,13 @@ export interface BridgeClientRequestContext { * dedicated daemon or `designated` policy instead). */ fromLoopback?: boolean; + /** + * Caller-generated correlation id for non-blocking prompt mode. + * When present, the bridge stamps `turn_complete` / `turn_error` events + * with this id so the SDK's `prompt()` can match the SSE event to the + * pending HTTP 202 request. + */ + promptId?: string; } /** @@ -137,7 +141,7 @@ export interface BridgeHeartbeatState { clientLastSeenAt: ReadonlyMap; } -export interface HttpAcpBridge { +export interface AcpSessionBridge { /** * Create a new session, or — under `sessionScope: 'single'` — attach to an * existing session for the same workspace. @@ -191,6 +195,13 @@ export interface HttpAcpBridge { opts?: SubscribeOptions, ): AsyncIterable; + /** + * Return the most recent monotonic event id for this session's bus. + * Used by non-blocking prompt responses to tell the client where to + * start SSE replay so no events are missed. + */ + getSessionLastEventId(sessionId: string): number; + /** * Explicitly close a live session. Force-closes even when other clients * are attached. Throws `SessionNotFoundError` for unknown ids. @@ -265,56 +276,57 @@ export interface HttpAcpBridge { knownClientIds(): ReadonlySet; /** - * Read daemon-runtime MCP status for the bound workspace. Does not spawn - * an ACP child when the daemon is idle. + * Generic workspace-status query delegated through the live ACP channel. + * Returns `idle()` when no child is running. Used by DaemonWorkspaceService + * to forward status methods without coupling to their concrete shapes. */ - getWorkspaceMcpStatus(): Promise; + queryWorkspaceStatus(method: string, idle: () => T): Promise; + + /** + * Generic workspace command invocation delegated through the live ACP + * channel. Throws `SessionNotFoundError` when no child is running (no + * idle fallback). Used by DaemonWorkspaceService for mutations that + * require an active channel (e.g. MCP restart). + */ + invokeWorkspaceCommand( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, + ): Promise; /** * Read discovered MCP tools for one server from the live ACP registry. + * (New in upstream — kept in bridge pending workspace service migration.) */ getWorkspaceMcpToolsStatus( serverName: string, ): Promise; - /** - * Read daemon-runtime skill status for the bound workspace. - */ - getWorkspaceSkillsStatus(): Promise; - /** * Read the live built-in tool registry for the bound workspace. + * (New in upstream — kept in bridge pending workspace service migration.) */ getWorkspaceToolsStatus(): Promise; - /** - * Read daemon-runtime model-provider status for the bound workspace. - */ - getWorkspaceProvidersStatus(): Promise; - - /** - * Read the daemon-process environment snapshot for the bound workspace. - * Answered entirely from `process.*` state — does not consult ACP. - */ - getWorkspaceEnvStatus(): Promise; - - /** - * Read daemon-runtime preflight diagnostics. Daemon-level cells are - * always populated; ACP-level cells require a live ACP child — when - * the daemon is idle they are emitted with `status: 'not_started'`. - */ - getWorkspacePreflightStatus(): Promise; - /** Read the current ACP context/config state for a live session. */ getSessionContextStatus( sessionId: string, ): Promise; + /** Read structured context-window usage for a live session. */ + getSessionContextUsageStatus( + sessionId: string, + opts?: { detail?: boolean }, + ): Promise; + /** Read slash-command/skill command availability for a live session. */ getSessionSupportedCommandsStatus( sessionId: string, ): Promise; + /** Read the live background task snapshot for a live session. */ + getSessionTasksStatus(sessionId: string): Promise; + /** * Switch the active model service for a session. Throws * `SessionNotFoundError` for unknown ids. @@ -359,69 +371,17 @@ export interface HttpAcpBridge { ): 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 - * session SSE bus. - */ - setWorkspaceToolEnabled( - toolName: string, - enabled: boolean, - originatorClientId: string | undefined, - ): Promise<{ toolName: string; enabled: boolean }>; - - /** - * Scaffold an empty `QWEN.md` (or whatever - * `getCurrentGeminiMdFilename()` returns) at the bound workspace - * root. Default refuses to overwrite via - * `WorkspaceInitConflictError`; `opts.force === true` overwrites. + * Execute a shell command directly on the daemon (no LLM involvement). + * Streams output through the session's SSE bus and injects the + * command+result into the LLM's chat history via extMethod. + * Throws `SessionNotFoundError` for unknown ids. */ - initWorkspace( - opts: { force?: boolean }, - originatorClientId: string | undefined, - ): Promise<{ - path: string; - action: 'created' | 'overwrote' | 'noop'; - }>; - - /** - * Restart a configured MCP server through the ACP child's - * `McpClientManager` (pre-F2) or transport pool (F2 #4175 commit 5). - * Pre-checks the live budget snapshot and returns a structured - * "skipped" response (200 OK) for soft refusals. - * - * F2 commit 5: under pool mode, a single `serverName` may map to - * multiple `PoolEntry` instances (different fingerprints from - * per-session OAuth/env divergence). When `opts.entryIndex` is - * undefined, the pool restarts ALL matching entries in parallel via - * `Promise.allSettled` and returns the new `{entries: RestartResult[]}` - * shape. When `opts.entryIndex` is set, only that entry restarts - * (404 / not-found surfaces as `entries: []`). Pre-F2 daemons and - * single-entry pool-mode responses keep the legacy - * `{restarted, durationMs}` shape so SDK clients that pre-date the - * `mcp_pool_restart` capability tag observe no diff. - */ - restartMcpServer( - serverName: string, - originatorClientId: string | undefined, - opts?: { entryIndex?: number }, - ): Promise< - | { serverName: string; restarted: true; durationMs: number } - | { - serverName: string; - restarted: false; - skipped: true; - reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; - } - | { - serverName: string; - entries: Array<{ - entryIndex: number; - restarted: boolean; - durationMs?: number; - reason?: string; - }>; - } - >; + executeShellCommand( + sessionId: string, + command: string, + signal?: AbortSignal, + context?: BridgeClientRequestContext, + ): Promise; /** * Tear down a session — kill the child, drop from maps, publish @@ -469,3 +429,12 @@ export interface HttpAcpBridge { /** Close all live child processes; called on daemon shutdown. */ shutdown(): Promise; } + +export interface ShellCommandResult { + exitCode: number | null; + output: string; + aborted: boolean; +} + +/** @deprecated Use `AcpSessionBridge` instead. */ +export type HttpAcpBridge = AcpSessionBridge; diff --git a/packages/acp-bridge/src/eventBus.test.ts b/packages/acp-bridge/src/eventBus.test.ts index 1850108be57..4029d65345e 100644 --- a/packages/acp-bridge/src/eventBus.test.ts +++ b/packages/acp-bridge/src/eventBus.test.ts @@ -90,7 +90,12 @@ describe('EventBus', () => { expect(events.map((e) => e.data)).toEqual([ 'a', 'b', - expect.objectContaining({ lastEventId: 2, replayedCount: 2 }), + // D4: canonical `lastReplayedEventId` + deprecated `lastEventId` alias. + expect.objectContaining({ + lastReplayedEventId: 2, + lastEventId: 2, + replayedCount: 2, + }), 'c', ]); abort.abort(); @@ -619,9 +624,15 @@ describe('EventBus', () => { abort.abort(); }); - it('does NOT emit state_resync_required when ring is empty', async () => { - // No publishes yet → earliestInRing is undefined → resync check - // skipped. Subscriber waits for live events. + it('emits epoch_reset resync when lastEventId is past the bus high-water (D1)', async () => { + // doudouOUC #4484 post-merge review (D1): a fresh bus (nextId=1, + // empty ring) that receives a consumer presenting `lastEventId: 5` + // means the consumer's cursor is from a PREVIOUS bus epoch (daemon + // restart rebuilt the EventBus). Pre-fix this slid past the + // `ring_evicted` check (empty ring) and emitted a bare + // `replay_complete{replayedCount:0}` — a false "you're caught up" + // while the consumer's reducer still held dead-epoch state. Now it + // must emit `state_resync_required{reason:'epoch_reset'}` first. const bus = new EventBus(10); const abort = new AbortController(); const iter = bus.subscribe({ @@ -633,18 +644,75 @@ describe('EventBus', () => { const out: BridgeEvent[] = []; for await (const e of iter) { out.push(e); - // The empty-ring case still emits `replay_complete` (zero - // frames replayed) so consumers always see the catch-up signal - // — then the one live event. 2 total. + // resync + replay_complete (0 frames) + 1 live = 3 total. + if (out.length === 3) break; + } + expect(out[0]?.type).toBe('state_resync_required'); + expect(out[0]?.id).toBeUndefined(); + const data = out[0]?.data as { + reason: string; + lastDeliveredId: number; + earliestAvailableId: number; + }; + expect(data.reason).toBe('epoch_reset'); + expect(data.lastDeliveredId).toBe(5); + expect(data.earliestAvailableId).toBe(1); + expect(out[1]?.type).toBe('replay_complete'); + expect(out[1]?.data).toMatchObject({ replayedCount: 0 }); + expect(out[2]?.type).toBe('foo'); + expect(out[2]?.id).toBe(1); + abort.abort(); + }); + + it('epoch_reset replays the WHOLE fresh ring (stale cursor must not filter new low ids)', async () => { + // After a restart the new epoch starts ids at 1 again. A consumer + // reconnecting with `lastEventId: 50` (dead epoch) must still receive + // the fresh ring's low-id events — filtering replay by 50 would drop + // ids 1..3 entirely, leaving the consumer permanently behind. + const bus = new EventBus(10); + for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i }); + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 50, + signal: abort.signal, + }); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // resync + 3 replay frames + replay_complete = 5. + if (out.length === 5) break; + } + expect(out[0]?.type).toBe('state_resync_required'); + expect((out[0]?.data as { reason: string }).reason).toBe('epoch_reset'); + // All three fresh events replay despite ids < stale cursor. + expect(out.slice(1, 4).map((e) => e.id)).toEqual([1, 2, 3]); + expect(out[4]?.type).toBe('replay_complete'); + expect(out[4]?.data).toMatchObject({ replayedCount: 3 }); + abort.abort(); + }); + + it('does NOT emit epoch_reset at the caught-up boundary (lastEventId === high-water)', async () => { + // Consumer fully caught up: lastEventId equals the bus high-water + // (nextId - 1). nextId is one past it, so `lastEventId >= nextId` is + // false — no epoch reset. Off-by-one guard for D1. + const bus = new EventBus(10); + for (let i = 1; i <= 3; i++) bus.publish({ type: 'foo', data: i }); + // high-water is 3; nextId is 4. lastEventId: 3 is the caught-up case. + const abort = new AbortController(); + const iter = bus.subscribe({ + lastEventId: 3, + signal: abort.signal, + }); + setTimeout(() => bus.publish({ type: 'foo', data: 99 }), 0); + const out: BridgeEvent[] = []; + for await (const e of iter) { + out.push(e); + // replay_complete (0 frames) + 1 live = 2. if (out.length === 2) break; } - // No resync frame — but replay_complete (id-less sentinel) + - // the live event. expect(out.some((e) => e.type === 'state_resync_required')).toBe(false); expect(out[0]?.type).toBe('replay_complete'); - expect(out[0]?.data).toMatchObject({ replayedCount: 0 }); - expect(out[1]?.type).toBe('foo'); - expect(out[1]?.id).toBe(1); + expect(out[1]?.id).toBe(4); abort.abort(); }); diff --git a/packages/acp-bridge/src/eventBus.ts b/packages/acp-bridge/src/eventBus.ts index 7916947d6a4..6a17c0a57ea 100644 --- a/packages/acp-bridge/src/eventBus.ts +++ b/packages/acp-bridge/src/eventBus.ts @@ -386,21 +386,54 @@ export class EventBus { // loadSession clears the flag, but the frames stay on the // wire so SDK has the option to compute a "what you missed" // diff later. This is network-friendly (no extra reconnect). - const earliestInRing = this.ring[0]?.id; - if ( - earliestInRing !== undefined && - earliestInRing > opts.lastEventId + 1 - ) { + // Epoch-reset detection (doudouOUC #4484 post-merge review, D1). + // `this.nextId` is the next id this bus will assign, so the bus has + // only ever emitted ids `< nextId` THIS epoch. A consumer presenting + // `lastEventId >= nextId` therefore saw an id this epoch never + // produced — the only way that happens is a previous bus epoch + // (daemon restart / EventBus rebuild resets `nextId` to 1 and clears + // the ring). The `ring_evicted` check below is structurally blind to + // this: after a restart the ring is empty (`earliestInRing === + // undefined`), so it is skipped and the consumer would otherwise get + // a bare `replay_complete{replayedCount:0}` — a false "you're caught + // up" while its accumulated reducer state is stale data from the dead + // epoch. Emit `state_resync_required` (reason `epoch_reset`) first. + const epochReset = opts.lastEventId >= this.nextId; + if (epochReset) { queue.forcePush({ v: EVENT_SCHEMA_VERSION, type: 'state_resync_required', data: { - reason: 'ring_evicted', + reason: 'epoch_reset', lastDeliveredId: opts.lastEventId, - earliestAvailableId: earliestInRing, + // Ring is typically empty right after a restart; fall back to + // `nextId` (the first id this epoch will assign) so the field + // stays meaningful ("fresh sequence starts here"). + earliestAvailableId: this.ring[0]?.id ?? this.nextId, }, }); + } else { + const earliestInRing = this.ring[0]?.id; + if ( + earliestInRing !== undefined && + earliestInRing > opts.lastEventId + 1 + ) { + queue.forcePush({ + v: EVENT_SCHEMA_VERSION, + type: 'state_resync_required', + data: { + reason: 'ring_evicted', + lastDeliveredId: opts.lastEventId, + earliestAvailableId: earliestInRing, + }, + }); + } } + // After an epoch reset the consumer's cursor belongs to a dead epoch, + // so every current-epoch event is "new" to it. Filtering replay by the + // stale `lastEventId` (e.g. 50) would drop the fresh low-id events + // (1,2,3…) entirely. Replay the whole current ring in that case. + const replayFrom = epochReset ? 0 : opts.lastEventId; // Force-push replay frames so they bypass the per-subscriber size // cap. The cap protects against a slow live consumer; replay is // already historical and silently dropping it would undermine the @@ -415,7 +448,7 @@ export class EventBus { // undefined here — but the type system can't see that since // BridgeEvent.id is optional for synthetic terminal frames. // Guard explicitly to keep narrow typing without runtime cost. - if (e.id !== undefined && e.id > opts.lastEventId) { + if (e.id !== undefined && e.id > replayFrom) { queue.forcePush(e); replayedCount += 1; lastReplayedId = e.id; @@ -443,8 +476,17 @@ export class EventBus { v: EVENT_SCHEMA_VERSION, type: 'replay_complete', data: { + // D4 (doudouOUC #4484 post-merge review): `lastReplayedEventId` + // is the canonical wire name — the old `lastEventId` collided + // semantically with the SSE protocol's `Last-Event-ID` (envelope + // `id`) in raw daemon traces. Emit both: `lastReplayedEventId` + // for current SDKs and `lastEventId` as a deprecated alias so + // pre-rename consumers keep working (additive, non-breaking). ...(lastReplayedId !== undefined - ? { lastEventId: lastReplayedId } + ? { + lastReplayedEventId: lastReplayedId, + lastEventId: lastReplayedId, + } : {}), replayedCount, }, diff --git a/packages/acp-bridge/src/internal/testUtils.ts b/packages/acp-bridge/src/internal/testUtils.ts index 65035715388..288ec0e0b54 100644 --- a/packages/acp-bridge/src/internal/testUtils.ts +++ b/packages/acp-bridge/src/internal/testUtils.ts @@ -61,9 +61,9 @@ import type { SetSessionModeRequest, SetSessionModeResponse, } from '@agentclientprotocol/sdk'; -import { createHttpAcpBridge } from '../bridge.js'; +import { createAcpSessionBridge } from '../bridge.js'; import type { BridgeOptions } from '../bridgeOptions.js'; -import type { HttpAcpBridge } from '../bridgeTypes.js'; +import type { AcpSessionBridge } from '../bridgeTypes.js'; import type { AcpChannel } from '../channel.js'; // Workspace fixtures must round-trip through `path.resolve` so the @@ -77,7 +77,7 @@ export const WS_B = path.resolve(path.sep, 'work', 'b'); export const SESS_A = `sess:${WS_A}`; /** - * Convenience wrapper: `createHttpAcpBridge` requires `boundWorkspace` + * Convenience wrapper: `createAcpSessionBridge` requires `boundWorkspace` * (per #3803 §02 — 1 daemon = 1 workspace). Tests that only ever talk * to `WS_A` would otherwise repeat `boundWorkspace: WS_A` everywhere; * this helper defaults it. Tests that need a different bind path (e.g. @@ -90,8 +90,8 @@ export const SESS_A = `sess:${WS_A}`; * wires `createDaemonStatusProvider()` for the 4 daemon-host * integration tests. */ -export function makeBridge(opts: Partial = {}): HttpAcpBridge { - return createHttpAcpBridge({ +export function makeBridge(opts: Partial = {}): AcpSessionBridge { + return createAcpSessionBridge({ boundWorkspace: WS_A, ...opts, }); diff --git a/packages/acp-bridge/src/permission.ts b/packages/acp-bridge/src/permission.ts index d03d55b83b4..7f31d45a72c 100644 --- a/packages/acp-bridge/src/permission.ts +++ b/packages/acp-bridge/src/permission.ts @@ -95,6 +95,9 @@ export interface PermissionVote { /** True when the request originated on a loopback connection. * `local-only` requires this. */ readonly fromLoopback: boolean; + /** Opaque metadata forwarded from the voter's response body to + * the resolution (e.g. AskUserQuestion answers). */ + readonly metadata?: Readonly>; } /** @@ -131,7 +134,11 @@ export type PermissionVoteOutcome = * a timeout expires. */ export type PermissionResolution = - | { readonly kind: 'option'; readonly optionId: string } + | { + readonly kind: 'option'; + readonly optionId: string; + readonly metadata?: Readonly>; + } | { readonly kind: 'cancelled'; readonly reason: 'timeout' | 'session_closed' | 'agent_cancelled'; diff --git a/packages/acp-bridge/src/permissionMediator.ts b/packages/acp-bridge/src/permissionMediator.ts index 942547211e9..8327e238234 100644 --- a/packages/acp-bridge/src/permissionMediator.ts +++ b/packages/acp-bridge/src/permissionMediator.ts @@ -715,7 +715,11 @@ export class MultiClientPermissionMediator implements PermissionMediator { ); this.resolveEntry( pending, - { kind: 'option', optionId: vote.optionId }, + { + kind: 'option', + optionId: vote.optionId, + ...(vote.metadata ? { metadata: vote.metadata } : {}), + }, { type: 'first-responder', resolverClientId: vote.clientId, @@ -776,7 +780,11 @@ export class MultiClientPermissionMediator implements PermissionMediator { ); this.resolveEntry( pending, - { kind: 'option', optionId: vote.optionId }, + { + kind: 'option', + optionId: vote.optionId, + ...(vote.metadata ? { metadata: vote.metadata } : {}), + }, { type: 'designated-originator', originatorClientId: pending.originatorClientId, @@ -884,7 +892,11 @@ export class MultiClientPermissionMediator implements PermissionMediator { ); this.resolveEntry( pending, - { kind: 'option', optionId: vote.optionId }, + { + kind: 'option', + optionId: vote.optionId, + ...(vote.metadata ? { metadata: vote.metadata } : {}), + }, { type: 'consensus-quorum', resolvedOptionId: vote.optionId, @@ -979,7 +991,11 @@ export class MultiClientPermissionMediator implements PermissionMediator { ); this.resolveEntry( pending, - { kind: 'option', optionId: vote.optionId }, + { + kind: 'option', + optionId: vote.optionId, + ...(vote.metadata ? { metadata: vote.metadata } : {}), + }, { type: 'local-only-loopback', resolverClientId: vote.clientId, diff --git a/packages/acp-bridge/src/status.test.ts b/packages/acp-bridge/src/status.test.ts index bf89b0c2a27..6d19807d7e0 100644 --- a/packages/acp-bridge/src/status.test.ts +++ b/packages/acp-bridge/src/status.test.ts @@ -46,7 +46,7 @@ describe('BridgeTimeoutError', () => { it('preserves the legacy message format and exposes label/timeoutMs', () => { const err = new BridgeTimeoutError('init', 250); expect(err.name).toBe('BridgeTimeoutError'); - expect(err.message).toBe('HttpAcpBridge init timed out after 250ms'); + expect(err.message).toBe('AcpSessionBridge init timed out after 250ms'); expect(err.label).toBe('init'); expect(err.timeoutMs).toBe(250); expect(err).toBeInstanceOf(Error); diff --git a/packages/acp-bridge/src/status.ts b/packages/acp-bridge/src/status.ts index 5d9d5b5401e..fc5d59fd51e 100644 --- a/packages/acp-bridge/src/status.ts +++ b/packages/acp-bridge/src/status.ts @@ -54,7 +54,7 @@ export class BridgeTimeoutError extends Error { readonly label: string; readonly timeoutMs: number; constructor(label: string, timeoutMs: number) { - super(`HttpAcpBridge ${label} timed out after ${timeoutMs}ms`); + super(`AcpSessionBridge ${label} timed out after ${timeoutMs}ms`); this.name = 'BridgeTimeoutError'; this.label = label; this.timeoutMs = timeoutMs; @@ -109,7 +109,9 @@ export const SERVE_STATUS_EXT_METHODS = { workspaceAgents: 'qwen/status/workspace/agents', workspacePreflight: 'qwen/status/workspace/preflight', sessionContext: 'qwen/status/session/context', + sessionContextUsage: 'qwen/status/session/context_usage', sessionSupportedCommands: 'qwen/status/session/supported_commands', + sessionTasks: 'qwen/status/session/tasks', } as const; /** @@ -123,6 +125,7 @@ export const SERVE_CONTROL_EXT_METHODS = { sessionClose: 'qwen/control/session/close', sessionApprovalMode: 'qwen/control/session/approval_mode', sessionRecap: 'qwen/control/session/recap', + sessionShellHistory: 'qwen/control/session/shell_history', workspaceMcpRestart: 'qwen/control/workspace/mcp/restart', } as const; @@ -360,6 +363,55 @@ export interface ServeSessionContextStatus { }; } +export interface ServeContextCategoryBreakdown { + systemPrompt: number; + builtinTools: number; + mcpTools: number; + memoryFiles: number; + skills: number; + messages: number; + freeSpace: number; + autocompactBuffer: number; +} + +export interface ServeContextToolDetail { + name: string; + tokens: number; +} + +export interface ServeContextMemoryDetail { + path: string; + tokens: number; +} + +export interface ServeContextSkillDetail { + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; +} + +export interface ServeSessionContextUsage { + modelName: string; + totalTokens: number; + contextWindowSize: number; + breakdown: ServeContextCategoryBreakdown; + builtinTools: ServeContextToolDetail[]; + mcpTools: ServeContextToolDetail[]; + memoryFiles: ServeContextMemoryDetail[]; + skills: ServeContextSkillDetail[]; + isEstimated?: boolean; + showDetails?: boolean; +} + +export interface ServeSessionContextUsageStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + workspaceCwd: string; + usage: ServeSessionContextUsage; + formattedText: string; +} + export interface ServeSessionSupportedCommandsStatus { v: typeof STATUS_SCHEMA_VERSION; sessionId: string; @@ -367,6 +419,83 @@ export interface ServeSessionSupportedCommandsStatus { availableSkills: string[]; } +export type ServeSessionTaskLifecycleStatus = + | 'running' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled'; + +export type ServeSessionProcessTaskLifecycleStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface ServeSessionAgentTaskStatus { + kind: 'agent'; + id: string; + label: string; + description: string; + status: ServeSessionTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + subagentType?: string; + isBackgrounded: boolean; + error?: string; + resumeBlockedReason?: string; +} + +export interface ServeSessionShellTaskStatus { + kind: 'shell'; + id: string; + label: string; + description: string; + status: ServeSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + command: string; + cwd: string; + pid?: number; + exitCode?: number; + error?: string; +} + +export interface ServeSessionMonitorTaskStatus { + kind: 'monitor'; + id: string; + label: string; + description: string; + status: ServeSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + command: string; + pid?: number; + eventCount: number; + lastEventTime: number; + droppedLines: number; + exitCode?: number; + error?: string; + ownerAgentId?: string; +} + +export type ServeSessionTaskStatus = + | ServeSessionAgentTaskStatus + | ServeSessionShellTaskStatus + | ServeSessionMonitorTaskStatus; + +export interface ServeSessionTasksStatus { + v: typeof STATUS_SCHEMA_VERSION; + sessionId: string; + now: number; + tasks: ServeSessionTaskStatus[]; +} + /** * Issue #4175 PR 16: workspace memory + agents read surfaces. * diff --git a/packages/channels/base/src/ChannelBase.ts b/packages/channels/base/src/ChannelBase.ts index 1e099ce2961..fa1398e9e9f 100644 --- a/packages/channels/base/src/ChannelBase.ts +++ b/packages/channels/base/src/ChannelBase.ts @@ -270,6 +270,46 @@ export abstract class ChannelBase { this.config.cwd, ); + // 3.5. Bang (!) shell command — direct execution, no LLM + if (envelope.text.startsWith('!')) { + const cmd = envelope.text.slice(1).trim(); + const bridgeShellCommand = (this.bridge as unknown as Record)['shellCommand']; + if (cmd && typeof bridgeShellCommand === 'function') { + try { + const result = (await bridgeShellCommand(sessionId, cmd)) as { + exitCode: number | null; + output: string; + aborted: boolean; + }; + const longestRun = Math.max( + 0, + ...Array.from( + (result.output || '').matchAll(/`+/g), + (m) => m[0].length, + ), + ); + const fence = '`'.repeat(Math.max(3, longestRun + 1)); + const output = result.output + ? `${fence}\n${result.output}\n${fence}` + : '(no output)'; + const exitLine = + result.exitCode !== null && result.exitCode !== 0 + ? `\nExit code: ${result.exitCode}` + : ''; + await this.sendMessage( + envelope.chatId, + `$ ${cmd}\n${output}${exitLine}`, + ); + } catch (error) { + await this.sendMessage( + envelope.chatId, + `Shell command failed: ${error instanceof Error ? error.message : String(error)}`, + ); + } + return; + } + } + // Prepend referenced (quoted) message text for reply context let promptText = envelope.text; if (envelope.referencedText) { diff --git a/packages/channels/base/src/DaemonChannelBridge.ts b/packages/channels/base/src/DaemonChannelBridge.ts index 04c04fe1e91..9700c58f236 100644 --- a/packages/channels/base/src/DaemonChannelBridge.ts +++ b/packages/channels/base/src/DaemonChannelBridge.ts @@ -37,6 +37,10 @@ export interface DaemonChannelSessionClient { requestId: string, response: RequestPermissionResponse, ): Promise; + shellCommand?( + command: string, + signal?: AbortSignal, + ): Promise<{ exitCode: number | null; output: string; aborted: boolean }>; } export interface DaemonChannelSessionFactoryRequest { @@ -313,6 +317,18 @@ export class DaemonChannelBridge extends EventEmitter { } } + async shellCommand( + sessionId: string, + command: string, + signal?: AbortSignal, + ): Promise<{ exitCode: number | null; output: string; aborted: boolean }> { + const session = this.ensureSession(sessionId); + if (!session.shellCommand) { + throw new Error('Shell command not supported by this session client'); + } + return session.shellCommand(command, signal); + } + async cancelSession(sessionId: string): Promise { const session = this.ensureSession(sessionId); await session.cancel(); diff --git a/packages/cli/src/acp-integration/acpAgent.test.ts b/packages/cli/src/acp-integration/acpAgent.test.ts index 5a51482e312..71e6fb7f8e6 100644 --- a/packages/cli/src/acp-integration/acpAgent.test.ts +++ b/packages/cli/src/acp-integration/acpAgent.test.ts @@ -141,6 +141,12 @@ vi.mock('@qwen-code/qwen-code-core', () => ({ SessionService: vi.fn(), SESSION_TITLE_MAX_LENGTH: 200, tokenLimit: vi.fn().mockReturnValue(128_000), + buildBackgroundEntryLabel: vi.fn( + (entry: { description: string; subagentType?: string }) => + entry.subagentType + ? `${entry.subagentType}: ${entry.description}` + : entry.description, + ), SessionStartSource: { Startup: 'startup', Resume: 'resume', @@ -173,6 +179,30 @@ vi.mock('../config/settings.js', () => ({ loadSettings: vi.fn(), })); vi.mock('../config/config.js', () => ({ loadCliConfig: vi.fn() })); +vi.mock('../ui/commands/contextCommand.js', () => ({ + collectContextData: vi.fn().mockResolvedValue({ + modelName: 'm', + showDetails: true, + contextWindowSize: 128000, + apiTotalTokens: 1000, + apiCachedTokens: 200, + systemPromptTokens: 500, + allToolsTokens: 300, + displayBuiltinToolsTokens: 100, + displayMcpToolsTokens: 200, + skillToolDefinitionTokens: 0, + loadedSkillBodiesTokens: 0, + memoryFilesTokens: 50, + categories: [], + builtinTools: [], + mcpTools: [], + memoryFiles: [], + skills: [], + }), + formatContextUsageText: vi + .fn() + .mockReturnValue('## Context Usage\nformatted'), +})); vi.mock('./session/Session.js', () => ({ Session: vi.fn(), buildAvailableCommandsSnapshot: vi.fn().mockResolvedValue({ @@ -1409,6 +1439,76 @@ describe('QwenAgent MCP SSE/HTTP support', () => { it('status ext methods expose live session context and supported commands', async () => { const sessionId = '11111111-1111-1111-1111-111111111111'; const innerConfig = await setupSessionMocks(sessionId); + const dateNowSpy = vi.spyOn(Date, 'now').mockReturnValue(5_000); + Object.assign(innerConfig, { + getBackgroundTaskRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'agent', + id: 'agent-1', + agentId: 'agent-1', + description: 'Investigate streaming', + status: 'paused', + startTime: 1_000, + outputFile: '/tmp/agent-1.jsonl', + outputOffset: 12, + notified: false, + abortController: new AbortController(), + subagentType: 'reviewer', + isBackgrounded: true, + resumeBlockedReason: 'approval required', + pendingMessages: ['secret queue'], + }, + ]), + }), + getBackgroundShellRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'shell', + id: 'shell-1', + shellId: 'shell-1', + description: 'npm test', + status: 'completed', + startTime: 3_000, + endTime: 4_500, + outputFile: '/tmp/shell-1.log', + outputPath: '/tmp/shell-1.log', + outputOffset: 8, + notified: true, + abortController: new AbortController(), + command: 'npm test', + cwd: '/tmp', + pid: 123, + exitCode: 0, + }, + ]), + }), + getMonitorRegistry: vi.fn().mockReturnValue({ + getAll: vi.fn().mockReturnValue([ + { + kind: 'monitor', + id: 'monitor-1', + monitorId: 'monitor-1', + description: 'watch logs', + status: 'failed', + startTime: 2_000, + endTime: 2_500, + outputFile: '/tmp/monitor-1.log', + outputOffset: 0, + notified: false, + abortController: new AbortController(), + command: 'tail -f app.log', + pid: 456, + eventCount: 3, + lastEventTime: 2_400, + droppedLines: 1, + error: 'boom', + ownerAgentId: 'agent-1', + idleTimer: {}, + }, + ]), + }), + }); vi.mocked(buildAvailableCommandsSnapshot).mockResolvedValueOnce({ availableCommands: [ { @@ -1442,6 +1542,13 @@ describe('QwenAgent MCP SSE/HTTP support', () => { SERVE_STATUS_EXT_METHODS.sessionSupportedCommands, { sessionId }, ); + const tasks = await agent.extMethod(SERVE_STATUS_EXT_METHODS.sessionTasks, { + sessionId, + }); + const contextUsage = await agent.extMethod( + SERVE_STATUS_EXT_METHODS.sessionContextUsage, + { sessionId, detail: true }, + ); expect(context).toMatchObject({ v: 1, @@ -1464,8 +1571,75 @@ describe('QwenAgent MCP SSE/HTTP support', () => { ], availableSkills: ['review'], }); + expect(tasks).toEqual({ + v: 1, + sessionId, + now: 5_000, + tasks: [ + { + kind: 'agent', + id: 'agent-1', + label: 'reviewer: Investigate streaming', + description: 'Investigate streaming', + status: 'paused', + startTime: 1_000, + runtimeMs: 4_000, + outputFile: '/tmp/agent-1.jsonl', + subagentType: 'reviewer', + isBackgrounded: true, + resumeBlockedReason: 'approval required', + }, + { + kind: 'monitor', + id: 'monitor-1', + label: 'watch logs', + description: 'watch logs', + status: 'failed', + startTime: 2_000, + endTime: 2_500, + runtimeMs: 500, + command: 'tail -f app.log', + pid: 456, + eventCount: 3, + lastEventTime: 2_400, + droppedLines: 1, + error: 'boom', + ownerAgentId: 'agent-1', + }, + { + kind: 'shell', + id: 'shell-1', + label: 'npm test', + description: 'npm test', + status: 'completed', + startTime: 3_000, + endTime: 4_500, + runtimeMs: 1_500, + outputFile: '/tmp/shell-1.log', + command: 'npm test', + cwd: '/tmp', + pid: 123, + exitCode: 0, + }, + ], + }); + expect(JSON.stringify(tasks)).not.toContain('abortController'); + expect(JSON.stringify(tasks)).not.toContain('outputOffset'); + expect(JSON.stringify(tasks)).not.toContain('pendingMessages'); + expect(JSON.stringify(tasks)).not.toContain('idleTimer'); + expect(contextUsage).toMatchObject({ + v: 1, + sessionId, + workspaceCwd: '/tmp', + usage: { + modelName: 'm', + showDetails: true, + }, + formattedText: expect.stringContaining('## Context Usage'), + }); expect(buildAvailableCommandsSnapshot).toHaveBeenCalledWith(innerConfig); + dateNowSpy.mockRestore(); mockConnectionState.resolve(); await agentPromise; }); diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 91eadd24e27..58180c8cc01 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -88,6 +88,7 @@ import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; import { Session, buildAvailableCommandsSnapshot } from './session/Session.js'; +import { buildSessionTasksStatus } from './session/tasksSnapshot.js'; import { formatAcpModelId, parseAcpBaseModelId, @@ -113,6 +114,7 @@ import { type ServePreflightKind, type ServeSessionContextStatus, type ServeSessionSupportedCommandsStatus, + type ServeSessionTasksStatus, type ServeStatus, type ServeStatusCell, type ServeWorkspaceMcpServerStatus, @@ -124,7 +126,12 @@ import { type ServeWorkspaceSkillsStatus, type ServeWorkspaceToolStatus, type ServeWorkspaceToolsStatus, + type ServeSessionContextUsageStatus, } from '../serve/status.js'; +import { + collectContextData, + formatContextUsageText, +} from '../ui/commands/contextCommand.js'; const debugLogger = createDebugLogger('ACP_AGENT'); @@ -1997,6 +2004,65 @@ class QwenAgent implements Agent { }; } + private async buildSessionContextUsageStatus( + sessionId: string, + showDetails: boolean, + ): Promise { + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + let usage; + try { + usage = await collectContextData(config, showDetails); + } catch (err) { + console.warn('[context-usage] collectContextData failed:', err); + usage = { + type: 'context_usage' as const, + modelName: config.getModel() || 'unknown', + totalTokens: 0, + contextWindowSize: 0, + breakdown: { + systemPrompt: 0, + builtinTools: 0, + mcpTools: 0, + memoryFiles: 0, + skills: 0, + messages: 0, + freeSpace: 0, + autocompactBuffer: 0, + }, + builtinTools: [] as Array<{ name: string; tokens: number }>, + mcpTools: [] as Array<{ name: string; tokens: number }>, + memoryFiles: [] as Array<{ path: string; tokens: number }>, + skills: [] as Array<{ + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; + }>, + isEstimated: true, + showDetails, + }; + } + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + workspaceCwd: this.workspaceCwd(config), + usage: { + modelName: usage.modelName, + totalTokens: usage.totalTokens, + contextWindowSize: usage.contextWindowSize, + breakdown: usage.breakdown, + builtinTools: usage.builtinTools, + mcpTools: usage.mcpTools, + memoryFiles: usage.memoryFiles, + skills: usage.skills, + isEstimated: usage.isEstimated, + showDetails: usage.showDetails, + }, + formattedText: formatContextUsageText(usage), + }; + } + private async buildSessionSupportedCommandsStatus( sessionId: string, ): Promise { @@ -2011,6 +2077,11 @@ class QwenAgent implements Agent { }; } + private buildSessionTasksStatus(sessionId: string): ServeSessionTasksStatus { + const session = this.sessionOrThrow(sessionId); + return buildSessionTasksStatus(sessionId, session.getConfig()); + } + async extMethod( method: string, params: Record, @@ -2067,6 +2138,19 @@ class QwenAgent implements Agent { unknown >; } + case SERVE_STATUS_EXT_METHODS.sessionContextUsage: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return (await this.buildSessionContextUsageStatus( + sessionId, + params['detail'] === true, + )) as unknown as Record; + } case SERVE_STATUS_EXT_METHODS.sessionSupportedCommands: { const sessionId = params['sessionId']; if (typeof sessionId !== 'string' || sessionId.length === 0) { @@ -2079,6 +2163,19 @@ class QwenAgent implements Agent { sessionId, )) as unknown as Record; } + case SERVE_STATUS_EXT_METHODS.sessionTasks: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + return this.buildSessionTasksStatus(sessionId) as unknown as Record< + string, + unknown + >; + } case SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart: { // #4175 Wave 4 PR 17. Single-server MCP restart with budget // pre-check from PR 14 v1's accounting snapshot. Soft skips @@ -2441,6 +2538,36 @@ class QwenAgent implements Agent { ); return { sessionId, recap }; } + case SERVE_CONTROL_EXT_METHODS.sessionShellHistory: { + const sessionId = params['sessionId']; + if (typeof sessionId !== 'string' || sessionId.length === 0) { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing sessionId', + ); + } + const command = params['command']; + if (typeof command !== 'string') { + throw RequestError.invalidParams( + undefined, + 'Invalid or missing command', + ); + } + const session = this.sessionOrThrow(sessionId); + const config = session.getConfig(); + const geminiClient = config.getGeminiClient()!; + const outputText = + typeof params['output'] === 'string' ? params['output'] : ''; + geminiClient.addHistory({ + role: 'user', + parts: [ + { + text: `I ran the following shell command:\n\`\`\`sh\n${command}\n\`\`\`\n\nThis produced the following result:\n\`\`\`\n${outputText}\n\`\`\``, + }, + ], + }); + return { sessionId, injected: true }; + } case 'deleteSession': { const sessionId = params['sessionId'] as string; if (!sessionId || !SESSION_ID_RE.test(sessionId)) { diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 3a5f58a4e7c..2c7f3a76b2a 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -33,6 +33,22 @@ vi.mock('../../nonInteractiveCliCommands.js', () => ({ handleSlashCommand: vi.fn(), })); +// Partial-mock `@qwen-code/qwen-code-core` so the daemon follow-up +// suggestion tests below can spy on `generatePromptSuggestion` / +// `logPromptSuggestion` without spinning up a real LLM client. Other +// existing tests still get the real `CompressionStatus`, `ApprovalMode`, +// `AuthType`, etc. via the spread. +vi.mock('@qwen-code/qwen-code-core', async () => { + const actual = await vi.importActual< + typeof import('@qwen-code/qwen-code-core') + >('@qwen-code/qwen-code-core'); + return { + ...actual, + generatePromptSuggestion: vi.fn(), + logPromptSuggestion: vi.fn(), + }; +}); + function chatRecord(overrides: Record): ChatRecord { return { uuid: 'record', @@ -2994,4 +3010,195 @@ describe('Session', () => { }); }); }); + + describe('follow-up suggestion (daemon assist push)', () => { + let generateMock: ReturnType; + let logMock: ReturnType; + + beforeEach(() => { + generateMock = vi.mocked(core.generatePromptSuggestion); + logMock = vi.mocked(core.logPromptSuggestion); + generateMock.mockReset(); + logMock.mockReset(); + // Enable the feature by default in this describe block; individual + // tests override `mockSettings.merged.ui` to exercise the disabled + // path. + (mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = { + enableFollowupSuggestions: true, + }; + vi.mocked(mockChat.getHistory).mockReturnValue([ + { role: 'user', parts: [{ text: 'hello' }] }, + { role: 'model', parts: [{ text: 'hi back' }] }, + ]); + mockChat.sendMessageStream = vi + .fn() + .mockResolvedValue(createEmptyStream()); + }); + + it('fires prompt-suggestion extNotification after end_turn when enabled', async () => { + generateMock.mockResolvedValue({ suggestion: 'Run the tests next?' }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(mockClient.extNotification).toHaveBeenCalledWith( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: 'test-session-id', + suggestion: 'Run the tests next?', + promptId: 'test-session-id########1', + }, + ); + }); + + // The generator received an AbortSignal so the daemon can cancel + // mid-flight if the next prompt arrives first. + expect(generateMock).toHaveBeenCalledWith( + mockConfig, + expect.any(Array), + expect.any(AbortSignal), + expect.objectContaining({ enableCacheSharing: expect.any(Boolean) }), + ); + }); + + it('does not emit when the feature is disabled', async () => { + (mockSettings as unknown as { merged: { ui: unknown } }).merged.ui = { + enableFollowupSuggestions: false, + }; + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + // Give the (skipped) IIFE a chance to run. + await new Promise((r) => setTimeout(r, 10)); + expect(generateMock).not.toHaveBeenCalled(); + expect( + ( + mockClient.extNotification as ReturnType + ).mock.calls.find( + ([method]) => method === 'qwen/notify/session/prompt-suggestion', + ), + ).toBeUndefined(); + }); + + it('does not emit in PLAN approval mode', async () => { + mockConfig.getApprovalMode = vi.fn().mockReturnValue(ApprovalMode.PLAN); + generateMock.mockResolvedValue({ suggestion: 'something' }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await new Promise((r) => setTimeout(r, 10)); + expect(generateMock).not.toHaveBeenCalled(); + }); + + it('logs filterReason via PromptSuggestionEvent when generation is suppressed', async () => { + generateMock.mockResolvedValue({ + suggestion: null, + filterReason: 'meta', + }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'hello' }], + }); + + await vi.waitFor(() => { + expect(logMock).toHaveBeenCalledWith( + mockConfig, + expect.objectContaining({ outcome: 'suppressed', reason: 'meta' }), + ); + }); + // No extNotification when suggestion is filtered. + expect( + ( + mockClient.extNotification as ReturnType + ).mock.calls.find( + ([method]) => method === 'qwen/notify/session/prompt-suggestion', + ), + ).toBeUndefined(); + }); + + it('aborts the in-flight generator when a new prompt arrives', async () => { + let capturedSignal: AbortSignal | undefined; + generateMock + .mockImplementationOnce( + async ( + _config: unknown, + _history: unknown, + signal: AbortSignal, + ): Promise<{ suggestion: string | null }> => { + capturedSignal = signal; + return new Promise((resolve) => { + signal.addEventListener('abort', () => + resolve({ suggestion: null }), + ); + }); + }, + ) + .mockResolvedValue({ suggestion: null }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'first' }], + }); + // Wait for the IIFE to actually call generateMock and capture the + // signal — without this, the second prompt can race past the + // first IIFE's microtask. + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + expect(capturedSignal!.aborted).toBe(false); + + // Send a second prompt. The followupAbort on the first turn + // should fire synchronously at the top of `prompt()`. + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'second' }], + }); + + expect(capturedSignal!.aborted).toBe(true); + }); + + it('aborts the in-flight generator when cancelPendingPrompt is called', async () => { + let capturedSignal: AbortSignal | undefined; + generateMock + .mockImplementationOnce( + async ( + _config: unknown, + _history: unknown, + signal: AbortSignal, + ): Promise<{ suggestion: string | null }> => { + capturedSignal = signal; + return new Promise((resolve) => { + signal.addEventListener('abort', () => + resolve({ suggestion: null }), + ); + }); + }, + ) + .mockResolvedValue({ suggestion: null }); + + await session.prompt({ + sessionId: 'test-session-id', + prompt: [{ type: 'text', text: 'go' }], + }); + await vi.waitFor(() => expect(capturedSignal).toBeDefined()); + + // followupAbort cleanup now runs unconditionally before the + // prompt/cron guard — inject a fake pendingPrompt so the call + // doesn't throw, but the real assertion is the signal abort. + (session as unknown as { pendingPrompt: AbortController }).pendingPrompt = + new AbortController(); + + await session.cancelPendingPrompt(); + expect(capturedSignal!.aborted).toBe(true); + }); + }); }); diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index f13690e055b..4882646f6e5 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -34,8 +34,11 @@ import { DiscoveredMCPTool, StreamEventType, ToolConfirmationOutcome, + generatePromptSuggestion, + logPromptSuggestion, logToolCall, logUserPrompt, + PromptSuggestionEvent, getErrorStatus, UserPromptEvent, readManyFiles, @@ -69,6 +72,8 @@ import { recordFallbackApprove, shouldFallback, shouldRunAutoModeForCall, + extractDaemonTraceContext, + withInteractionSpan, } from '@qwen-code/qwen-code-core'; import { getCommandSubcommandNames } from '../../services/commandMetadata.js'; import { getEffectiveSupportedModes } from '../../services/commandUtils.js'; @@ -264,6 +269,13 @@ export class Session implements SessionContext { * process termination is slow. */ private pendingPromptCompletion: Promise | null = null; + /** + * Per-turn AbortController for the fire-and-forget follow-up suggestion + * generation. Aborted on the top of the next `prompt()` and on + * `cancelPendingPrompt()` so a stale suggestion never lands after the + * user has moved on. Null when no suggestion generation is in flight. + */ + private followupAbort: AbortController | null = null; private turn: number = 0; private readonly runtimeBaseDir: string; @@ -469,6 +481,11 @@ export class Session implements SessionContext { const hadPrompt = !!this.pendingPrompt; const hadCron = !!this.cronAbortController; + if (this.followupAbort) { + this.followupAbort.abort(); + this.followupAbort = null; + } + if (!hadPrompt && !hadCron) { throw new Error(NOT_CURRENTLY_GENERATING_CANCEL_MESSAGE); } @@ -506,6 +523,15 @@ export class Session implements SessionContext { const pendingSend = new AbortController(); this.pendingPrompt = pendingSend; + // Abort the previous turn's in-flight follow-up suggestion + // generation (if any). Mirrors `pendingPrompt?.abort()` above — + // a fresh prompt arriving means any pending suggestion would be + // stale before it could ever render. + if (this.followupAbort) { + this.followupAbort.abort(); + this.followupAbort = null; + } + // Abort any in-progress cron execution (user prompt takes priority) if (this.cronAbortController) { this.cronAbortController.abort(); @@ -548,12 +574,109 @@ export class Session implements SessionContext { this.#startCronSchedulerIfNeeded(); // Drain any cron prompts that queued while the prompt was active void this.#drainCronQueue(); + // Fire-and-forget follow-up suggestion generation. Best-effort UX + // hint — must not block the prompt response. See + // `#maybeEmitFollowupSuggestion` for guards and cancellation. + this.#maybeEmitFollowupSuggestion(result); return result; } finally { resolveCompletion(); } } + /** + * Generate a server-side follow-up suggestion for the just-completed + * turn and push it to attached clients via the daemon's + * `qwen/notify/session/prompt-suggestion` extNotification. Mirrors + * the CLI's `AppContainer.tsx` integration: same `generatePromptSuggestion` + * call, same `enableCacheSharing` flag forwarding, same curated + * history slice (`getHistory(true).slice(-40)`). + * + * Differences from the CLI: + * - Triggers only on `stopReason === 'end_turn'` (the daemon + * equivalent of "the assistant finished cleanly"). Cancelled / + * errored turns don't get a suggestion. + * - Aborted via `this.followupAbort`, which is reset on the next + * `prompt()` and on `cancelPendingPrompt()`. + * - Filter-reason logging only — accept / dismiss telemetry stays + * client-side (the CLI hook owns it). + * + * Fire-and-forget by design: an unawaited IIFE that swallows its own + * errors. A failed suggestion is invisible to the user; a thrown + * error here would propagate up through `prompt()` and break the + * primary response path. + */ + #maybeEmitFollowupSuggestion(result: PromptResponse): void { + if (result.stopReason !== 'end_turn') return; + if (this.settings.merged.ui?.enableFollowupSuggestions !== true) return; + if (this.config.getApprovalMode() === ApprovalMode.PLAN) return; + + const chat = this.config.getGeminiClient()?.getChat(); + if (!chat) return; + + const ac = new AbortController(); + this.followupAbort = ac; + const promptId = + this.config.getSessionId() + '########' + String(this.turn); + + void (async () => { + try { + const fullHistory = chat.getHistory(true); + const lastEntry = fullHistory[fullHistory.length - 1]; + if (!lastEntry || lastEntry.role !== 'model') { + debugLogger.debug( + 'Skipping followup suggestion: last history entry is not model', + ); + return; + } + const conversationHistory = + fullHistory.length > 40 ? fullHistory.slice(-40) : fullHistory; + + const r = await generatePromptSuggestion( + this.config, + conversationHistory, + ac.signal, + { + enableCacheSharing: + this.settings.merged.ui?.enableCacheSharing === true, + }, + ); + if (ac.signal.aborted) return; + if (r.suggestion) { + await this.client.extNotification( + 'qwen/notify/session/prompt-suggestion', + { + v: 1, + sessionId: this.sessionId, + suggestion: r.suggestion, + promptId, + }, + ); + } else if (r.filterReason) { + // Mirror the CLI's suppression analytics path so server-side + // generations are observable in the same telemetry stream. + logPromptSuggestion( + this.config, + new PromptSuggestionEvent({ + outcome: 'suppressed', + reason: r.filterReason, + }), + ); + } + } catch (error) { + if (ac.signal.aborted) { + debugLogger.debug('Follow-up suggestion generation aborted'); + } else { + debugLogger.warn('Follow-up suggestion generation failed', error); + } + } finally { + if (this.followupAbort === ac) { + this.followupAbort = null; + } + } + })(); + } + async #executePrompt( params: PromptRequest, pendingSend: AbortController, @@ -566,270 +689,290 @@ export class Session implements SessionContext { this.turn += 1; const promptId = this.config.getSessionId() + '########' + this.turn; + const parentContext = extractDaemonTraceContext(params); - // Extract text from all text blocks to construct the full prompt text for logging - const promptText = params.prompt - .filter((block) => block.type === 'text') - .map((block) => (block.type === 'text' ? block.text : '')) - .join(' '); - - // Log user prompt - logUserPrompt( + return await withInteractionSpan( this.config, - new UserPromptEvent( - promptText.length, + { promptId, - this.config.getContentGeneratorConfig()?.authType, - promptText, - ), - ); - - // record user message for session management - this.config.getChatRecordingService()?.recordUserMessage(promptText); - - // Check if the input contains a slash command - // Extract text from the first text block if present - const firstTextBlock = params.prompt.find( - (block) => block.type === 'text', - ); - const inputText = firstTextBlock?.text || ''; - - let parts: Part[] | null; - - if (isSlashCommand(inputText)) { - // Handle slash command in ACP mode using capability-based filtering - const slashCommandResult = await handleSlashCommand( - inputText, - pendingSend, - this.config, - this.settings, - ); + model: this.config.getModel(), + messageType: 'acp_prompt', + ...(parentContext ? { parentContext } : {}), + }, + async () => { + // Extract text from all text blocks to construct the full prompt text for logging + const promptText = params.prompt + .filter((block) => block.type === 'text') + .map((block) => (block.type === 'text' ? block.text : '')) + .join(' '); + + // Log user prompt + logUserPrompt( + this.config, + new UserPromptEvent( + promptText.length, + promptId, + this.config.getContentGeneratorConfig()?.authType, + promptText, + ), + ); - parts = await this.#processSlashCommandResult( - slashCommandResult, - params.prompt, - ); + // record user message for session management + this.config + .getChatRecordingService() + ?.recordUserMessage(promptText); - // If parts is null, the command was fully handled (e.g., /summary completed) - // Return early without sending to the model - if (parts === null) { - return { stopReason: 'end_turn' }; - } - } else { - // Normal processing for non-slash commands - parts = await this.#resolvePrompt(params.prompt, pendingSend.signal); - } + // Check if the input contains a slash command + // Extract text from the first text block if present + const firstTextBlock = params.prompt.find( + (block) => block.type === 'text', + ); + const inputText = firstTextBlock?.text || ''; - // Fire UserPromptSubmit hook through MessageBus (aligned with core path in client.ts) - const hooksEnabled = !this.config.getDisableAllHooks?.(); - const messageBus = this.config.getMessageBus?.(); - if ( - hooksEnabled && - messageBus && - this.config.hasHooksForEvent?.('UserPromptSubmit') - ) { - const response = await messageBus.request< - HookExecutionRequest, - HookExecutionResponse - >( - { - type: MessageBusType.HOOK_EXECUTION_REQUEST, - eventName: 'UserPromptSubmit', - input: { - prompt: promptText, - }, - signal: pendingSend.signal, - }, - MessageBusType.HOOK_EXECUTION_RESPONSE, - ); - const hookOutput = response.output - ? createHookOutput('UserPromptSubmit', response.output) - : undefined; + let parts: Part[] | null; - if ( - hookOutput?.isBlockingDecision() || - hookOutput?.shouldStopExecution() - ) { - // Hook blocked the prompt - send notification to UI and return - const blockReason = - hookOutput?.getEffectiveReason() || 'No reason provided'; - await this.messageEmitter.emitAgentMessage( - `🚫 **UserPromptSubmit blocked**: ${blockReason}`, - ); - return { stopReason: 'end_turn' }; - } + if (isSlashCommand(inputText)) { + // Handle slash command in ACP mode using capability-based filtering + const slashCommandResult = await handleSlashCommand( + inputText, + pendingSend, + this.config, + this.settings, + ); - // Add additional context from hooks to the request - const additionalContext = hookOutput?.getAdditionalContext(); - if (additionalContext) { - parts = [...parts, { text: additionalContext }]; - } - } + parts = await this.#processSlashCommandResult( + slashCommandResult, + params.prompt, + ); - // Prepend session-level system reminders (plan mode / subagent / - // arena) so the model sees them, matching the behaviour of - // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, - // plan mode in ACP has no effect because the model never learns it - // should avoid edits (#1151). - const systemReminders = await this.#buildInitialSystemReminders(); - if (systemReminders.length > 0) { - parts = [...systemReminders, ...parts]; - } + // If parts is null, the command was fully handled (e.g., /summary completed) + // Return early without sending to the model + if (parts === null) { + return { stopReason: 'end_turn' }; + } + } else { + // Normal processing for non-slash commands + parts = await this.#resolvePrompt( + params.prompt, + pendingSend.signal, + ); + } - // Phase C: one-shot worktree restore notice, set by acpAgent on - // --resume / loadSession when the session's worktree is still alive. - // Prepended exactly once, then cleared so it doesn't repeat on - // subsequent turns. - if (this.pendingWorktreeNotice) { - parts = [ - { - text: `\n${this.pendingWorktreeNotice}\n\n\n`, - }, - ...parts, - ]; - this.pendingWorktreeNotice = null; - } + // Fire UserPromptSubmit hook through MessageBus (aligned with core path in client.ts) + const hooksEnabled = !this.config.getDisableAllHooks?.(); + const messageBus = this.config.getMessageBus?.(); + if ( + hooksEnabled && + messageBus && + this.config.hasHooksForEvent?.('UserPromptSubmit') + ) { + const response = await messageBus.request< + HookExecutionRequest, + HookExecutionResponse + >( + { + type: MessageBusType.HOOK_EXECUTION_REQUEST, + eventName: 'UserPromptSubmit', + input: { + prompt: promptText, + }, + signal: pendingSend.signal, + }, + MessageBusType.HOOK_EXECUTION_RESPONSE, + ); + const hookOutput = response.output + ? createHookOutput('UserPromptSubmit', response.output) + : undefined; - let nextMessage: Content | null = { role: 'user', parts }; + if ( + hookOutput?.isBlockingDecision() || + hookOutput?.shouldStopExecution() + ) { + // Hook blocked the prompt - send notification to UI and return + const blockReason = + hookOutput?.getEffectiveReason() || 'No reason provided'; + await this.messageEmitter.emitAgentMessage( + `🚫 **UserPromptSubmit blocked**: ${blockReason}`, + ); + return { stopReason: 'end_turn' }; + } - while (nextMessage !== null) { - if (pendingSend.signal.aborted) { - this.#getCurrentChat().addHistory(nextMessage); - return { stopReason: 'cancelled' }; - } + // Add additional context from hooks to the request + const additionalContext = hookOutput?.getAdditionalContext(); + if (additionalContext) { + parts = [...parts, { text: additionalContext }]; + } + } - const functionCalls: FunctionCall[] = []; - let usageMetadata: GenerateContentResponseUsageMetadata | null = null; - const streamStartTime = Date.now(); + // Prepend session-level system reminders (plan mode / subagent / + // arena) so the model sees them, matching the behaviour of + // `GeminiClient.sendMessageStream` in the CLI/TUI path. Without this, + // plan mode in ACP has no effect because the model never learns it + // should avoid edits (#1151). + const systemReminders = await this.#buildInitialSystemReminders(); + if (systemReminders.length > 0) { + parts = [...systemReminders, ...parts]; + } - try { - const sendResult = await this.#sendMessageStreamWithAutoCompression( - promptId, - nextMessage?.parts ?? [], - pendingSend.signal, - ); - if (!sendResult.responseStream) { - this.#preserveUnsentMessageHistory( - nextMessage, - sendResult.stopReason === 'cancelled', - ); - return { stopReason: sendResult.stopReason }; + // Phase C: one-shot worktree restore notice, set by acpAgent on + // --resume / loadSession when the session's worktree is still alive. + // Prepended exactly once, then cleared so it doesn't repeat on + // subsequent turns. + if (this.pendingWorktreeNotice) { + parts = [ + { + text: `\n${this.pendingWorktreeNotice}\n\n\n`, + }, + ...parts, + ]; + this.pendingWorktreeNotice = null; } - const responseStream = sendResult.responseStream; - nextMessage = null; - for await (const resp of responseStream) { + let nextMessage: Content | null = { role: 'user', parts }; + + while (nextMessage !== null) { if (pendingSend.signal.aborted) { + this.#getCurrentChat().addHistory(nextMessage); return { stopReason: 'cancelled' }; } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.candidates && - resp.value.candidates.length > 0 - ) { - const candidate = resp.value.candidates[0]; - for (const part of candidate.content?.parts ?? []) { - if (!part.text) { - continue; + const functionCalls: FunctionCall[] = []; + let usageMetadata: GenerateContentResponseUsageMetadata | null = + null; + const streamStartTime = Date.now(); + + try { + const sendResult = + await this.#sendMessageStreamWithAutoCompression( + promptId, + nextMessage?.parts ?? [], + pendingSend.signal, + ); + if (!sendResult.responseStream) { + this.#preserveUnsentMessageHistory( + nextMessage, + sendResult.stopReason === 'cancelled', + ); + return { stopReason: sendResult.stopReason }; + } + const responseStream = sendResult.responseStream; + nextMessage = null; + + for await (const resp of responseStream) { + if (pendingSend.signal.aborted) { + return { stopReason: 'cancelled' }; } - this.messageEmitter.emitMessage( - part.text, - 'assistant', - part.thought, + if ( + resp.type === StreamEventType.CHUNK && + resp.value.candidates && + resp.value.candidates.length > 0 + ) { + const candidate = resp.value.candidates[0]; + for (const part of candidate.content?.parts ?? []) { + if (!part.text) { + continue; + } + + this.messageEmitter.emitMessage( + part.text, + 'assistant', + part.thought, + ); + } + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.usageMetadata + ) { + usageMetadata = resp.value.usageMetadata; + } + + if ( + resp.type === StreamEventType.CHUNK && + resp.value.functionCalls + ) { + functionCalls.push(...resp.value.functionCalls); + } + } + } catch (error) { + // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) + // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent + const errorStatus = getErrorStatus(error); + const errorMessage = + error instanceof Error ? error.message : String(error); + const errorType = classifyApiError({ + message: errorMessage, + status: errorStatus, + }); + + const hookSystem = this.config.getHookSystem?.(); + const hooksEnabledForStopFailure = + !this.config.getDisableAllHooks?.(); + if ( + hooksEnabledForStopFailure && + hookSystem && + this.config.hasHooksForEvent?.('StopFailure') + ) { + // Fire-and-forget: don't wait for hook to complete + hookSystem + .fireStopFailureEvent(errorType, errorMessage) + .catch((err) => { + debugLogger.warn(`StopFailure hook failed: ${err}`); + }); + } + + if (errorStatus === 429) { + throw new RequestError( + 429, + 'Rate limit exceeded. Try again later.', ); } - } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.usageMetadata - ) { - usageMetadata = resp.value.usageMetadata; + throw error; } - if ( - resp.type === StreamEventType.CHUNK && - resp.value.functionCalls - ) { - functionCalls.push(...resp.value.functionCalls); - } - } - } catch (error) { - // Fire StopFailure hook (fire-and-forget, replaces Stop event for API errors) - // Aligned with useGeminiStream.ts handleFinishedWithErrorEvent - const errorStatus = getErrorStatus(error); - const errorMessage = - error instanceof Error ? error.message : String(error); - const errorType = classifyApiError({ - message: errorMessage, - status: errorStatus, - }); + if (usageMetadata) { + this.#recordPromptTokenCount(usageMetadata); + // Kick off rewrite in background (non-blocking, runs parallel to tools) + if (this.messageRewriter) { + this.messageRewriter.flushTurn(pendingSend.signal); + } - const hookSystem = this.config.getHookSystem?.(); - const hooksEnabledForStopFailure = - !this.config.getDisableAllHooks?.(); - if ( - hooksEnabledForStopFailure && - hookSystem && - this.config.hasHooksForEvent?.('StopFailure') - ) { - // Fire-and-forget: don't wait for hook to complete - hookSystem - .fireStopFailureEvent(errorType, errorMessage) - .catch((err) => { - debugLogger.warn(`StopFailure hook failed: ${err}`); - }); - } + const durationMs = Date.now() - streamStartTime; + await this.messageEmitter.emitUsageMetadata( + usageMetadata, + '', + durationMs, + ); + } - if (errorStatus === 429) { - throw new RequestError( - 429, - 'Rate limit exceeded. Try again later.', - ); + if (functionCalls.length > 0) { + const toolResponseParts = await this.runToolCalls( + pendingSend.signal, + promptId, + functionCalls, + ); + nextMessage = { role: 'user', parts: toolResponseParts }; + } } - throw error; - } - - if (usageMetadata) { - this.#recordPromptTokenCount(usageMetadata); - // Kick off rewrite in background (non-blocking, runs parallel to tools) + // Wait for any pending rewrite before returning if (this.messageRewriter) { - this.messageRewriter.flushTurn(pendingSend.signal); + await this.messageRewriter.waitForPendingRewrites(); } - const durationMs = Date.now() - streamStartTime; - await this.messageEmitter.emitUsageMetadata( - usageMetadata, - '', - durationMs, - ); - } - - if (functionCalls.length > 0) { - const toolResponseParts = await this.runToolCalls( - pendingSend.signal, + // Fire Stop hook loop (aligned with core path in client.ts) + // This is triggered after model response completes with no pending tool calls + return this.#handleStopHookLoop( + pendingSend, promptId, - functionCalls, + hooksEnabled, + messageBus, ); - nextMessage = { role: 'user', parts: toolResponseParts }; - } - } - - // Wait for any pending rewrite before returning - if (this.messageRewriter) { - await this.messageRewriter.waitForPendingRewrites(); - } - - // Fire Stop hook loop (aligned with core path in client.ts) - // This is triggered after model response completes with no pending tool calls - return this.#handleStopHookLoop( - pendingSend, - promptId, - hooksEnabled, - messageBus, + }, + (result) => (result.stopReason === 'cancelled' ? 'cancelled' : 'ok'), ); }, ); diff --git a/packages/cli/src/acp-integration/session/tasksSnapshot.ts b/packages/cli/src/acp-integration/session/tasksSnapshot.ts new file mode 100644 index 00000000000..a70dcdd79ba --- /dev/null +++ b/packages/cli/src/acp-integration/session/tasksSnapshot.ts @@ -0,0 +1,129 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + buildBackgroundEntryLabel, + type AgentTask, + type Config, + type MonitorTask, + type ShellTask, +} from '@qwen-code/qwen-code-core'; +import { + STATUS_SCHEMA_VERSION, + type ServeSessionAgentTaskStatus, + type ServeSessionMonitorTaskStatus, + type ServeSessionShellTaskStatus, + type ServeSessionTaskStatus, + type ServeSessionTasksStatus, +} from '../../serve/status.js'; + +function runtimeMs( + entry: { startTime: number; endTime?: number }, + now: number, +) { + return Math.max(0, (entry.endTime ?? now) - entry.startTime); +} + +function serializeAgentTask( + entry: AgentTask, + now: number, +): ServeSessionAgentTaskStatus { + return { + kind: 'agent', + id: entry.id, + label: buildBackgroundEntryLabel(entry), + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + ...(entry.endTime !== undefined ? { endTime: entry.endTime } : {}), + ...(entry.subagentType !== undefined + ? { subagentType: entry.subagentType } + : {}), + isBackgrounded: entry.isBackgrounded, + ...(entry.error !== undefined ? { error: entry.error } : {}), + ...(entry.resumeBlockedReason !== undefined + ? { resumeBlockedReason: entry.resumeBlockedReason } + : {}), + }; +} + +function serializeShellTask( + entry: ShellTask, + now: number, +): ServeSessionShellTaskStatus { + return { + kind: 'shell', + id: entry.id, + label: entry.command, + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + outputFile: entry.outputFile, + command: entry.command, + cwd: entry.cwd, + ...(entry.endTime !== undefined ? { endTime: entry.endTime } : {}), + ...(entry.pid !== undefined ? { pid: entry.pid } : {}), + ...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}), + ...(entry.error !== undefined ? { error: entry.error } : {}), + }; +} + +function serializeMonitorTask( + entry: MonitorTask, + now: number, +): ServeSessionMonitorTaskStatus { + return { + kind: 'monitor', + id: entry.id, + label: entry.description, + description: entry.description, + status: entry.status, + startTime: entry.startTime, + runtimeMs: runtimeMs(entry, now), + command: entry.command, + eventCount: entry.eventCount, + lastEventTime: entry.lastEventTime, + droppedLines: entry.droppedLines, + ...(entry.endTime !== undefined ? { endTime: entry.endTime } : {}), + ...(entry.pid !== undefined ? { pid: entry.pid } : {}), + ...(entry.exitCode !== undefined ? { exitCode: entry.exitCode } : {}), + ...(entry.error !== undefined ? { error: entry.error } : {}), + ...(entry.ownerAgentId !== undefined + ? { ownerAgentId: entry.ownerAgentId } + : {}), + }; +} + +export function buildSessionTasksStatus( + sessionId: string, + config: Config, + now = Date.now(), +): ServeSessionTasksStatus { + const tasks: ServeSessionTaskStatus[] = [ + ...config + .getBackgroundTaskRegistry() + .getAll() + .map((entry) => serializeAgentTask(entry, now)), + ...config + .getBackgroundShellRegistry() + .getAll() + .map((entry) => serializeShellTask(entry, now)), + ...config + .getMonitorRegistry() + .getAll() + .map((entry) => serializeMonitorTask(entry, now)), + ].sort((a, b) => a.startTime - b.startTime); + + return { + v: STATUS_SCHEMA_VERSION, + sessionId, + now, + tasks, + }; +} diff --git a/packages/cli/src/serve/acpHttp/connectionRegistry.ts b/packages/cli/src/serve/acpHttp/connectionRegistry.ts new file mode 100644 index 00000000000..78146363f83 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/connectionRegistry.ts @@ -0,0 +1,414 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { randomUUID } from 'node:crypto'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { logSafe } from './jsonRpc.js'; +import type { SseStream } from './sseStream.js'; + +/** + * Per-stream cap on frames buffered before the client attaches its SSE + * stream. Mirrors the EventBus's `maxQueued` backpressure cap so a client + * that drives requests without ever opening a stream can't grow daemon + * memory without bound. Oldest frames are dropped past the cap. + */ +const MAX_BUFFERED_FRAMES = 256; + +/** Default cap on concurrent live connections (mirrors a bounded resource). */ +const DEFAULT_MAX_CONNECTIONS = 64; + +/** + * Invoked when a session/connection tears down while an agent→client + * request (e.g. a permission prompt) is still outstanding, so the bridge + * isn't left blocked awaiting a vote that will never arrive. + */ +export type AbandonPendingFn = ( + req: PendingClientRequest, + clientId: string | undefined, +) => boolean; + +/** + * Best-effort bridge detach for a session's bridge-stamped clientId on + * teardown. Without it, `session/new`/`load`/`resume`-registered client ids + * stay visible in `knownClientIds()`/`votersForSession()` after the ACP + * connection is gone — skewing permission mediation + origin validation. + * ACP clients can't clean this up themselves (the id isn't on the wire). + */ +export type DetachSessionFn = ( + sessionId: string, + clientId: string | undefined, +) => void; + +/** + * Tracks one logical ACP-over-HTTP connection (RFD #721). A connection is + * minted at `initialize`, keyed by `Acp-Connection-Id`, and may host many + * sessions — each with its own session-scoped SSE stream. + */ +export interface SessionBinding { + sessionId: string; + /** + * The clientId the bridge STAMPED for this session at create/attach. + * The bridge ignores caller-supplied ids it has never issued and mints + * a fresh one (returned on `spawnOrAttach`/`loadSession`), so every + * later per-session call (`sendPrompt`, permission votes, …) must echo + * THIS id, not the connection's own — otherwise the bridge rejects it + * with "client id is not registered for session". + */ + clientId?: string; + /** Session-scoped SSE stream (the client's `GET /acp` with both headers). */ + stream?: SseStream; + /** Frames emitted before the session stream attached, flushed on attach. */ + buffer: unknown[]; + /** + * Aborts the bridge event subscription tied to the CURRENT session + * stream. Replaced with a fresh controller on every re-attach — a + * controller, once aborted (on stream close), can never resume, so + * reusing it across reconnects would leave the new stream permanently + * event-starved. + */ + abort: AbortController; + /** + * Aborts the in-flight `session/prompt` for this session. Set by + * `handlePrompt` while a prompt runs; aborted on `session/cancel` and on + * session/connection teardown so a disconnecting client doesn't leave + * the agent burning model quota on a result nobody will read. + */ + promptAbort?: AbortController; +} + +/** An agent→client request awaiting the client's JSON-RPC response. */ +export interface PendingClientRequest { + sessionId: string; + /** Maps the JSON-RPC id we issued back to the bridge's permission id. */ + bridgeRequestId: string; + kind: 'permission'; +} + +export class AcpConnection { + readonly connectionId: string; + /** Connection-scoped SSE stream (the client's `GET /acp` with only the conn header). */ + connStream?: SseStream; + /** Frames emitted before the connection stream attached, flushed on attach. */ + private readonly connBuffer: unknown[] = []; + readonly sessions = new Map(); + /** + * Sessions this connection created (`session/new`) or explicitly + * attached to (`session/load`/`resume`). Per-session operations + * (subscribe, prompt, cancel, …) are gated on membership here so one + * connection can't drive or eavesdrop on a session it never claimed. + */ + readonly ownedSessions = new Set(); + /** + * Sessions with an in-flight `session/close` (between the synchronous + * ownership-revoke and the bridge close + local teardown). `session/load` + * / `resume` reject for an id in this set so a close racing a re-load + * can't have its `finally` teardown destroy the freshly-loaded session. + */ + readonly closingSessions = new Set(); + /** Agent→client requests awaiting a client response, keyed by JSON-RPC id. */ + readonly pending = new Map(); + /** Daemon-issued client id reused across this connection's bridge calls. */ + readonly clientId: string; + /** + * True when the `initialize` POST arrived from a kernel-stamped loopback + * peer. Threaded into per-session bridge contexts so the `local-only` + * permission policy can gate votes by transport — mirrors the REST + * surface's `detectFromLoopback(req)`. NOT derived from forgeable + * headers (`X-Forwarded-For` etc). + */ + readonly fromLoopback: boolean; + /** + * Set by `destroy()`. An in-flight `session/new`/`load`/`resume` whose + * bridge call resolves AFTER teardown checks this to kill/detach the + * late-registered session, so a `DELETE` (or idle sweep) racing a spawn + * doesn't orphan a child process / phantom clientId. + */ + destroyed = false; + /** + * Grace-period reap timer armed when the connection-scoped SSE stream + * closes; cleared on reconnect (`attachConnStream`) or teardown. Avoids a + * dead connection locking its `ownedSessions` (and counting against + * `maxConnections`) for the full 30-min idle TTL. + */ + connGraceTimer?: ReturnType; + lastActiveMs: number = Date.now(); + private idCounter = 0; + + constructor( + connectionId: string | undefined, + fromLoopback: boolean, + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + ) { + this.connectionId = connectionId ?? randomUUID(); + this.clientId = randomUUID(); + this.fromLoopback = fromLoopback; + } + + /** + * Allocate a fresh JSON-RPC id for an agent→client request. STRING-typed + * (`_qwen_perm_N`) so it can never collide with a client-originated id — + * JSON-RPC 2.0 permits clients to use any number (incl. negatives) or + * string, so a numeric namespace wasn't actually safe. + */ + nextId(): string { + this.idCounter += 1; + return `_qwen_perm_${this.idCounter}`; + } + + touch(): void { + this.lastActiveMs = Date.now(); + } + + ownSession(sessionId: string): void { + this.ownedSessions.add(sessionId); + } + + ownsSession(sessionId: string): boolean { + return this.ownedSessions.has(sessionId); + } + + getOrCreateSession(sessionId: string): SessionBinding { + let binding = this.sessions.get(sessionId); + if (!binding) { + binding = { sessionId, abort: new AbortController(), buffer: [] }; + this.sessions.set(sessionId, binding); + } + return binding; + } + + /** Send a frame on the connection-scoped stream (buffer until it attaches). */ + sendConn(frame: unknown): void { + if (this.connStream && !this.connStream.isClosed) { + void this.connStream.send(frame); + } else { + pushCapped(this.connBuffer, frame, `conn ${this.connectionId}`); + } + } + + /** True if any session currently has a live (open) SSE stream. */ + hasLiveSessionStream(): boolean { + for (const b of this.sessions.values()) { + if (b.stream && !b.stream.isClosed) return true; + } + return false; + } + + /** Cancel a pending grace-period reap (e.g. on conn-stream reconnect). */ + clearGraceTimer(): void { + if (this.connGraceTimer) { + clearTimeout(this.connGraceTimer); + this.connGraceTimer = undefined; + } + } + + /** Attach the connection-scoped stream and flush any buffered frames. */ + attachConnStream(stream: SseStream): void { + // A reconnect cancels any pending grace-period reap. + this.clearGraceTimer(); + // Close any prior connection stream so its heartbeat interval + socket + // don't leak when a client reconnects the connection-scoped GET. + if (this.connStream && this.connStream !== stream) this.connStream.close(); + this.connStream = stream; + for (const frame of this.connBuffer.splice(0)) void stream.send(frame); + } + + /** + * Send a frame on a session-scoped stream (buffer until it attaches). + * LOOKUP-ONLY: drops the frame when the session has no binding — a binding + * always exists for a live session (created at `session/new`/`load`/ + * `resume`), so a missing one means the session was torn down. Auto- + * creating here would resurrect a ghost binding (no stream, no owner) that + * buffers up to 256 late pump/reply frames forever. + */ + sendSession(sessionId: string, frame: unknown): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + if (binding.stream && !binding.stream.isClosed) { + void binding.stream.send(frame); + } else { + pushCapped(binding.buffer, frame, `session ${sessionId}`); + } + } + + /** + * Attach a session-scoped stream: close any prior stream, abort the prior + * subscription, install the caller's FRESH AbortController (the old one is + * aborted and can never resume — reusing it would leave the new stream + * event-starved), flush buffered frames, and return the binding. + */ + attachSessionStream( + sessionId: string, + stream: SseStream, + abort: AbortController, + ): SessionBinding { + const binding = this.getOrCreateSession(sessionId); + const prevStream = binding.stream; + binding.abort.abort(); + binding.abort = abort; + // Install the NEW stream BEFORE closing the old one. The old stream's + // `onClose` is identity-guarded on `binding.stream` (see the session-GET + // handler in `index.ts` — `if (conn.sessions.get(sessionId)?.stream === + // stream) ...promptAbort?.abort()`), so installing first means a + // reconnect's close can't abort the in-flight prompt (the client is + // reconnecting, not leaving — the prompt must survive). CONTRACT: that + // identity guard and this ordering must stay in lockstep. + binding.stream = stream; + if (prevStream && prevStream !== stream) prevStream.close(); + for (const frame of binding.buffer.splice(0)) void stream.send(frame); + return binding; + } + + closeSessionStream(sessionId: string): void { + const binding = this.sessions.get(sessionId); + if (!binding) return; + binding.abort.abort(); + binding.promptAbort?.abort(); + binding.stream?.close(); + this.abandonPendingForSession(sessionId, binding.clientId); + this.onDetachSession?.(sessionId, binding.clientId); + this.sessions.delete(sessionId); + this.ownedSessions.delete(sessionId); + } + + destroy(): void { + this.destroyed = true; + this.clearGraceTimer(); + for (const binding of this.sessions.values()) { + binding.abort.abort(); + binding.promptAbort?.abort(); + binding.stream?.close(); + this.abandonPendingForSession(binding.sessionId, binding.clientId); + // Release the bridge-stamped clientId so it doesn't linger in the + // bridge's voter/known-client sets after this connection is gone. + this.onDetachSession?.(binding.sessionId, binding.clientId); + } + this.sessions.clear(); + this.ownedSessions.clear(); + this.pending.clear(); + this.connStream?.close(); + } + + /** + * Cancel + drop any pending agent→client requests for a closing session. + * This is the LAST-RESORT recovery path: `resolveClientResponse` retains a + * pending entry on double-failure (vote AND cancel both threw) precisely so + * this teardown sweep can retry the cancel. We always drop the entry here + * (the connection is going away — there is no further retry after teardown), + * but if the cancel itself still fails (triple-failure) the bridge mediator + * may be stuck awaiting a vote that will never arrive, so log it for the + * operator rather than failing silently. + */ + private abandonPendingForSession( + sessionId: string, + clientId: string | undefined, + ): void { + for (const [id, req] of this.pending) { + if (req.sessionId !== sessionId) continue; + this.pending.delete(id); + const cancelled = this.onAbandonPending?.(req, clientId) ?? true; + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp MEDIATOR STUCK: abandonPendingForSession(${logSafe(sessionId)}) cancel failed for ${logSafe(req.bridgeRequestId)}`, + ); + } + } + } +} + +function pushCapped(buf: unknown[], frame: unknown, label = 'stream'): void { + if (buf.length >= MAX_BUFFERED_FRAMES) { + buf.shift(); + writeStderrLine( + `qwen serve: /acp pre-attach buffer full (${label}), dropped oldest frame`, + ); + } + buf.push(frame); +} + +/** + * Registry of live ACP connections with an idle-TTL sweep. The sweep is + * defensive: a well-behaved client `DELETE /acp`s, but a crashed client + * that never closes its streams would otherwise leak connection state. + */ +export class ConnectionRegistry { + private readonly byId = new Map(); + private readonly sweepTimer: ReturnType; + + constructor( + private readonly onAbandonPending?: AbandonPendingFn, + private readonly onDetachSession?: DetachSessionFn, + private readonly maxConnections = DEFAULT_MAX_CONNECTIONS, + private readonly idleTtlMs = 30 * 60_000, + ) { + this.sweepTimer = setInterval(() => this.sweep(), 60_000); + this.sweepTimer.unref(); + } + + /** + * Mint a connection, or return `undefined` when the live-connection cap + * is reached (the caller answers `503`). Bounds an `initialize` flood from + * growing the registry without limit through the full TTL window. + */ + create(fromLoopback: boolean): AcpConnection | undefined { + if (this.maxConnections > 0 && this.byId.size >= this.maxConnections) { + return undefined; + } + const conn = new AcpConnection( + undefined, + fromLoopback, + this.onAbandonPending, + this.onDetachSession, + ); + this.byId.set(conn.connectionId, conn); + return conn; + } + + get(connectionId: string | undefined): AcpConnection | undefined { + if (!connectionId) return undefined; + const conn = this.byId.get(connectionId); + conn?.touch(); + return conn; + } + + delete(connectionId: string): boolean { + const conn = this.byId.get(connectionId); + if (!conn) return false; + conn.destroy(); + return this.byId.delete(connectionId); + } + + get size(): number { + return this.byId.size; + } + + /** The configured concurrent-connection cap (for operator-facing logs). */ + get connectionCap(): number { + return this.maxConnections; + } + + dispose(): void { + clearInterval(this.sweepTimer); + for (const id of [...this.byId.keys()]) this.delete(id); + } + + private sweep(): void { + const cutoff = Date.now() - this.idleTtlMs; + for (const [id, conn] of this.byId) { + if (conn.lastActiveMs >= cutoff) continue; + // Observability: a reaped connection silently dropping its SSE + // streams is otherwise invisible to operators chasing "my client + // froze". Note that `touch()` fires on inbound HTTP AND on event + // delivery (pumpSessionEvents), so a long quiet prompt isn't reaped. + writeStderrLine( + `qwen serve: /acp reaping idle connection ${id} ` + + `(idle > ${Math.round(this.idleTtlMs / 60_000)}m, ` + + `${conn.sessions.size} session(s))`, + ); + this.delete(id); + } + } +} diff --git a/packages/cli/src/serve/acpHttp/dispatch.ts b/packages/cli/src/serve/acpHttp/dispatch.ts new file mode 100644 index 00000000000..7e0f5b7683e --- /dev/null +++ b/packages/cli/src/serve/acpHttp/dispatch.ts @@ -0,0 +1,1212 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import path from 'node:path'; +import { APPROVAL_MODES, type ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import { MAX_WORKSPACE_PATH_LENGTH } from '../fs/paths.js'; +import type { DaemonWorkspaceService , WorkspaceRequestContext } from '../workspace-service/types.js'; +import type { AcpConnection } from './connectionRegistry.js'; +import { + QWEN_META_KEY, + QWEN_METHOD_NS, + RPC, + error, + isNotification, + isObject, + isRequest, + isResponse, + logSafe, + notification, + request, + success, + type JsonRpcId, + type JsonRpcInbound, + type JsonRpcRequest, + type JsonRpcResponse, +} from './jsonRpc.js'; + +function errMsg(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + +/** + * Method names whose responses ride the CONNECTION-scoped stream (the + * session stream may not exist yet / ownership not granted on failure). + * Error frames must route the same way as their success path. + */ +const CONN_ROUTED_METHODS = new Set([ + 'authenticate', + 'session/new', + 'session/load', + 'session/resume', + 'session/list', + 'session/close', + `${QWEN_METHOD_NS}session/heartbeat`, + `${QWEN_METHOD_NS}session/context`, + `${QWEN_METHOD_NS}session/supported_commands`, + `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}workspace/mcp`, + `${QWEN_METHOD_NS}workspace/skills`, + `${QWEN_METHOD_NS}workspace/providers`, + `${QWEN_METHOD_NS}workspace/env`, + `${QWEN_METHOD_NS}workspace/preflight`, + `${QWEN_METHOD_NS}workspace/init`, + `${QWEN_METHOD_NS}workspace/set_tool_enabled`, + `${QWEN_METHOD_NS}workspace/restart_mcp_server`, +]); + +// SYNC: server.ts MAX_TOOL_NAME_LENGTH / MAX_SERVER_NAME_LENGTH (both 256). +// Keep in lockstep with the REST surface — a divergence means ACP clients get +// INVALID_PARAMS for names REST accepts (or vice versa). (Not extracted to a +// shared module to avoid churning the 2987-line server.ts near merge; a +// follow-up may lift all three to a `serve/limits.ts`.) +const MAX_NAME_LENGTH = 256; + +class AcpParamError extends Error {} + +/** + * Validate an optional `cwd` param the same way the REST `POST /session` + * route does: when present it must be a string, ≤ PATH_MAX, and absolute. + * Closes the body-amplification DoS the REST code documents. Returns the + * bound workspace when omitted. + */ +function parseOptionalWorkspaceCwd( + params: Record, + boundWorkspace: string, +): string { + if (!('cwd' in params) || params['cwd'] === undefined) return boundWorkspace; + const cwd = params['cwd']; + if (typeof cwd !== 'string') { + throw new AcpParamError( + '`cwd` must be a string absolute path when provided', + ); + } + if (cwd.length > MAX_WORKSPACE_PATH_LENGTH) { + throw new AcpParamError( + `\`cwd\` exceeds the ${MAX_WORKSPACE_PATH_LENGTH}-character limit`, + ); + } + // `path.isAbsolute` (platform-aware) — same as the REST route. A bare + // `startsWith('/')` would reject valid Windows `C:\…`/UNC paths a client + // gets back from `/capabilities.workspaceCwd`. + if (!path.isAbsolute(cwd)) { + throw new AcpParamError('`cwd` must be an absolute path when provided'); + } + return cwd; +} + +/** Validate a `session/prompt` body before it reaches the bridge/agent. */ +function validatePrompt(params: Record): void { + const prompt = params['prompt']; + if (!Array.isArray(prompt) || prompt.length === 0) { + throw new AcpParamError( + '`prompt` is required and must be a non-empty array of content blocks', + ); + } + if ( + !prompt.every( + (b) => typeof b === 'object' && b !== null && !Array.isArray(b), + ) + ) { + throw new AcpParamError('each `prompt` element must be an object'); + } +} + +/** + * Map a thrown error to a JSON-RPC error code + a client-safe message. + * Param-validation errors are echoed (they describe the client's own bad + * input); bridge/internal errors are coded by class name with their + * message preserved (the daemon's trust boundary is the bearer token, so + * the operator-facing message is not a cross-tenant leak), and anything + * unrecognized collapses to a generic INTERNAL_ERROR string. + */ +function toRpcError(err: unknown): { code: number; message: string } { + if (err instanceof AcpParamError) { + return { code: RPC.INVALID_PARAMS, message: err.message }; + } + const name = err instanceof Error ? err.name : ''; + switch (name) { + case 'SessionNotFoundError': + case 'InvalidSessionScopeError': + case 'WorkspaceMismatchError': + case 'InvalidClientIdError': + return { code: RPC.INVALID_PARAMS, message: errMsg(err) }; + case 'SessionLimitExceededError': + return { code: RPC.INTERNAL_ERROR, message: errMsg(err) }; + default: + return { code: RPC.INTERNAL_ERROR, message: 'Internal error' }; + } +} + +/** + * The ACP protocol version this transport speaks (ACP stable = 1). + */ +export const ACP_PROTOCOL_VERSION = 1; + +/** + * Routes JSON-RPC messages between the HTTP transport and the + * `HttpAcpBridge`. Inbound client messages map to bridge calls; the + * bridge's `BridgeEvent`s map back to JSON-RPC frames on the matching + * session stream (see the design doc §4 translation table). + */ +export class AcpDispatcher { + constructor( + private readonly bridge: HttpAcpBridge, + private readonly boundWorkspace: string, + private readonly workspace: DaemonWorkspaceService, + ) {} + + /** + * Build the `WorkspaceRequestContext` for workspace-scoped operations + * routed through the workspace service. The ACP dispatch has no session + * context, so `sessionId` is omitted. + */ + private wsCtx(conn: AcpConnection, method: string): WorkspaceRequestContext { + return { + originatorClientId: conn.clientId, + route: `ACP ${method}`, + workspaceCwd: this.boundWorkspace, + }; + } + + /** + * Build the bridge context for a per-session call. Echoes the clientId the + * bridge STAMPED at create/attach (the connection's own id is unregistered + * and would be rejected) and threads `fromLoopback` so the `local-only` + * permission policy can gate votes by transport — symmetric with the REST + * surface's `detectFromLoopback(req)`. + * + * Throws when no stamped clientId is present: the only callers reach here + * AFTER `requireOwned`, so the binding must exist and carry the bridge's + * id. A missing id means an invariant broke (a `session/new`/`load` that + * didn't record it) — fail loud rather than silently send an unregistered + * id whose rejection surfaces asynchronously, far from the cause. + */ + private sessionCtx( + conn: AcpConnection, + sessionId: string, + fromLoopback: boolean, + ): { clientId: string; fromLoopback: boolean } { + const clientId = conn.sessions.get(sessionId)?.clientId; + if (!clientId) { + throw new Error( + `no bridge-stamped clientId for session ${sessionId} (ownership invariant violated)`, + ); + } + return { clientId, fromLoopback }; + } + + /** + * The session's ACP-shaped config options (model/mode/…), read from the + * child's own session state. Returned in `session/new` and as the result + * of `session/set_config_option`. Best-effort — `undefined` on error. + */ + private async configOptionsFor( + sessionId: string, + ): Promise { + try { + const ctx = (await this.bridge.getSessionContextStatus(sessionId)) as { + state?: { configOptions?: unknown }; + }; + const co = ctx?.state?.configOptions; + return Array.isArray(co) ? co : undefined; + } catch (err) { + writeStderrLine( + `qwen serve: /acp configOptionsFor(${logSafe(sessionId)}) failed: ${logSafe(errMsg(err))}`, + ); + return undefined; + } + } + + /** + * Cancel a permission request the client abandoned (closed its stream / + * connection before voting), so the bridge isn't left blocked. Invoked + * by the connection-registry teardown path. + */ + cancelAbandonedPermission( + req: { sessionId: string; bridgeRequestId: string }, + clientId: string | undefined, + ): boolean { + try { + this.bridge.respondToSessionPermission( + req.sessionId, + req.bridgeRequestId, + { outcome: { outcome: 'cancelled' } } as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + clientId !== undefined ? { clientId } : undefined, + ); + return true; + } catch (err) { + // "Session already gone" is the common, expected path (treat as done). + // Any OTHER failure means the mediator may still be stuck — log it AND + // report failure so a caller can keep the pending entry for a later + // teardown retry rather than dropping it. + const msg = errMsg(err); + if (/not found|unknown session/i.test(msg)) return true; + writeStderrLine( + `qwen serve: /acp cancelAbandonedPermission(${logSafe(req.sessionId)}) failed: ${logSafe(msg)}`, + ); + return false; + } + } + + /** + * Build the `initialize` result advertising standard + `_qwen` caps. + * Negotiates the protocol version: we only implement stable V1, so we + * clamp to `[1, ACP_PROTOCOL_VERSION]` — a client asking for 0/negative + * (ACP marks V0 a pre-release fallback) or a future version gets `1` + * rather than an echoed version we don't actually implement. + */ + buildInitializeResult( + connectionId: string, + requestedVersion?: unknown, + ): Record { + const requested = + typeof requestedVersion === 'number' && Number.isFinite(requestedVersion) + ? requestedVersion + : ACP_PROTOCOL_VERSION; + const negotiated = Math.max(1, Math.min(requested, ACP_PROTOCOL_VERSION)); + return { + protocolVersion: negotiated, + agentCapabilities: { + loadSession: true, + promptCapabilities: { + image: true, + audio: false, + embeddedContext: true, + }, + // Model + mode are exposed via the STANDARD `session/set_config_option` + // (categories `model`/`mode`); advertise that here. + configOptions: true, + // Vendor extensions are advertised under `_meta` keyed by domain + // (ACP convention, e.g. `_meta: { "zed.dev": … }`). Clients + // feature-detect before calling `_qwen/…` methods. + _meta: { + [QWEN_META_KEY]: { + connectionId, + workspaceCwd: this.boundWorkspace, + methods: [ + `${QWEN_METHOD_NS}session/heartbeat`, + `${QWEN_METHOD_NS}session/context`, + `${QWEN_METHOD_NS}session/supported_commands`, + `${QWEN_METHOD_NS}session/update_metadata`, + `${QWEN_METHOD_NS}workspace/mcp`, + `${QWEN_METHOD_NS}workspace/skills`, + `${QWEN_METHOD_NS}workspace/providers`, + `${QWEN_METHOD_NS}workspace/env`, + `${QWEN_METHOD_NS}workspace/preflight`, + `${QWEN_METHOD_NS}workspace/init`, + `${QWEN_METHOD_NS}workspace/set_tool_enabled`, + `${QWEN_METHOD_NS}workspace/restart_mcp_server`, + ], + }, + }, + }, + }; + } + + /** + * Gate a per-session operation on connection ownership. Sends a JSON-RPC + * error and returns false when this connection never created/attached + * the session (prevents driving or eavesdropping on another + * connection's session). `session/new|load|resume` are the + * ownership-GRANTING ops and skip this. + */ + private requireOwned( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + ): boolean { + if (conn.ownsSession(sessionId)) return true; + if (id === undefined) { + // Notification (no id) for an unowned session: no wire response to + // send, so log it — otherwise "my cancel did nothing" is undebuggable. + writeStderrLine( + `qwen serve: /acp notification for unowned session ${logSafe(sessionId)} (dropped)`, + ); + return false; + } + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `Session ${sessionId} is not owned by this connection`, + ), + ); + return false; + } + + /** + * Handle one inbound POST message. Returns nothing — every reply is + * delivered asynchronously on a long-lived SSE stream per the RFD + * (`POST` itself answers `202`). `initialize` is handled by the caller + * (it mints the connection) and never reaches here. + */ + async handle( + conn: AcpConnection, + msg: JsonRpcInbound, + sessionHeader?: string, + reqLoopback?: boolean, + ): Promise { + // Loopback is evaluated PER REQUEST (the permission-vote POST may arrive + // from a different peer than `initialize`), falling back to the + // connection's initialize-time value when the caller didn't supply it. + const loopback = reqLoopback ?? conn.fromLoopback; + + // A client's JSON-RPC RESPONSE (to an agent→client request) — wrapped + // so a throwing bridge call can't reject this promise after index.ts + // already sent `202` (which would surface as an unhandled rejection). + if (isResponse(msg)) { + try { + this.resolveClientResponse(conn, msg, loopback); + } catch (err) { + writeStderrLine( + `qwen serve: /acp response handling error: ${logSafe(errMsg(err))}`, + ); + } + return; + } + if (!isRequest(msg) && !isNotification(msg)) return; + + const method = msg.method; + const params = (isObject(msg.params) ? msg.params : {}) as Record< + string, + unknown + >; + const id = isRequest(msg) ? msg.id : undefined; + + // RFD §2.3: when both are present the `Acp-Session-Id` header and the + // `sessionId` param MUST agree — reject divergence rather than let a + // POST act on a session other than the one the header names. + if ( + sessionHeader && + typeof params['sessionId'] === 'string' && + params['sessionId'] !== sessionHeader + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + 'Acp-Session-Id header does not match params.sessionId', + ), + ); + } + return; + } + + try { + switch (method) { + case 'authenticate': + // HTTP transport authenticates via the daemon's bearer token + // middleware; the ACP-level method is a success no-op. + this.replyConn(conn, id, {}); + return; + + case 'session/new': { + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + // Forward sessionScope like REST (bridge supports single|thread). + const rawScope = params['sessionScope']; + if ( + rawScope !== undefined && + rawScope !== 'single' && + rawScope !== 'thread' + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`sessionScope` must be "single" or "thread"', + ), + ); + } + return; + } + const session = await this.bridge.spawnOrAttach({ + workspaceCwd: cwd, + clientId: conn.clientId, + ...(rawScope !== undefined + ? { sessionScope: rawScope as 'single' | 'thread' } + : {}), + }); + // Teardown raced the spawn: the connection was destroyed while the + // bridge call was in flight, so nothing will tear this session down. + // Kill the orphan (no other client could have attached yet). + if (conn.destroyed) { + void this.bridge + .killSession(session.sessionId, { requireZeroAttaches: true }) + .catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(session.sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); + return; + } + // Record the clientId the bridge actually stamped — later + // per-session calls MUST echo it (see SessionBinding.clientId). + conn.getOrCreateSession(session.sessionId).clientId = + session.clientId; + conn.ownSession(session.sessionId); + // Advertise the session's config options (model/mode/…) so a + // standard client can drive `session/set_config_option`. Sourced + // from the child's own session state (already ACP-shaped). + const configOptions = await this.configOptionsFor(session.sessionId); + if (conn.destroyed) { + void this.bridge + .killSession(session.sessionId, { requireZeroAttaches: true }) + .catch((err) => + writeStderrLine( + `qwen serve: /acp orphan killSession(${logSafe(session.sessionId)}) failed: ${logSafe(errMsg(err))}`, + ), + ); + return; + } + this.replyConn(conn, id, { + sessionId: session.sessionId, + ...(configOptions ? { configOptions } : {}), + }); + return; + } + + case 'session/load': + case 'session/resume': { + const sessionId = String(params['sessionId'] ?? ''); + if (!sessionId) { + if (id !== undefined) { + conn.sendConn( + error(id, RPC.INVALID_PARAMS, '`sessionId` is required'), + ); + } + return; + } + // Reject if a session/close for this id is in flight — otherwise the + // close's `finally` teardown would destroy the session we're about + // to load (TOCTOU). Client should retry after the close settles. + if (conn.closingSessions.has(sessionId)) { + if (id !== undefined) { + // The client's params are valid — the rejection is a server-side + // timing race against an in-flight close, so use INTERNAL_ERROR + // (-32603), not INVALID_PARAMS, to signal a transient/retryable + // condition rather than a permanent parameter fault. + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} is being closed; retry`, + ), + ); + } + return; + } + const cwd = parseOptionalWorkspaceCwd(params, this.boundWorkspace); + const restored = + method === 'session/load' + ? await this.bridge.loadSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }) + : await this.bridge.resumeSession({ + sessionId, + workspaceCwd: cwd, + clientId: conn.clientId, + }); + // Teardown raced the restore — EITHER the whole connection was + // destroyed (`conn.destroyed`) OR a `session/close` for this id + // started DURING the await (`closingSessions`); in the latter the + // close's `finally` teardown would destroy the binding we're about + // to create. Both need the same cleanup; only the client reply + // differs. Cleanup depends on what restore did: + // - attached:true → detachClient rolls back just our attach. + // - attached:false → restore SPAWNED a fresh session from disk; + // detachClient only decrements attachCount and does NOT reap + // (reaping is the spawn-owner's job) — so kill it. + const closeRaced = conn.closingSessions.has(sessionId); + if (conn.destroyed || closeRaced) { + const cleanup = restored.attached + ? this.bridge.detachClient(sessionId, restored.clientId) + : this.bridge.killSession(sessionId, { + requireZeroAttaches: true, + }); + void cleanup.catch((err) => + writeStderrLine( + `qwen serve: /acp orphan ${restored.attached ? 'detach' : 'kill'}(${logSafe(sessionId)}) teardown-race: ${logSafe(errMsg(err))}`, + ), + ); + // Connection-still-alive close race → tell the client to retry. + // Same rationale as the pre-await guard: a transient server-side + // race, so INTERNAL_ERROR (-32603), not INVALID_PARAMS. + if (closeRaced && !conn.destroyed && id !== undefined) { + conn.sendConn( + error( + id, + RPC.INTERNAL_ERROR, + `session ${sessionId} was closed during load; retry`, + ), + ); + } + return; + } + conn.getOrCreateSession(sessionId).clientId = restored.clientId; + conn.ownSession(sessionId); + this.replyConn(conn, id, restored.state ?? {}); + return; + } + + case 'session/list': { + const sessions = this.bridge.listWorkspaceSessions( + this.boundWorkspace, + ); + this.replyConn(conn, id, { sessions }); + return; + } + + case 'session/close': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Close the ownership gate SYNCHRONOUSLY (before the await) so two + // concurrent `session/close`s don't both pass `requireOwned` — + // the second would otherwise send a misleading error and trigger a + // redundant bridge close. + conn.ownedSessions.delete(sessionId); + // Mark closing so a concurrent session/load|resume of the SAME id + // can't grant fresh ownership + create a new binding that this + // close's `finally` teardown would then destroy (TOCTOU). + conn.closingSessions.add(sessionId); + try { + await this.bridge.closeSession( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + } finally { + // Local teardown must run even if the bridge close throws — + // otherwise the SSE stream, abort controller, buffered frames and + // pending permissions leak until idle TTL. + try { + conn.closeSessionStream(sessionId); + } catch (teardownErr) { + writeStderrLine( + `qwen serve: /acp session/close local teardown failed (${logSafe(sessionId)}): ${logSafe(teardownErr instanceof Error ? teardownErr.message : String(teardownErr))}`, + ); + } + conn.closingSessions.delete(sessionId); + } + this.replyConn(conn, id, {}); + return; + } + + case 'session/cancel': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + // Abort our local in-flight prompt controller too — cancelSession + // tells the agent to wind down, but the HTTP-side `sendPrompt` + // await must also be released so the session FIFO unblocks. + conn.sessions.get(sessionId)?.promptAbort?.abort(); + await this.bridge.cancelSession( + sessionId, + // Forward client-supplied cancel fields (reason/context) while + // force-stamping sessionId — mirrors the REST surface. + { ...params, sessionId } as Parameters< + HttpAcpBridge['cancelSession'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + // `session/cancel` is normally a notification (no id), but answer + // the request-form so a client that sent an id isn't left hanging. + if (id !== undefined) this.replySession(conn, sessionId, id, {}); + return; + } + + case 'session/prompt': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + validatePrompt(params); + await this.handlePrompt(conn, sessionId, id, params, loopback); + return; + } + + // STANDARD method (SDK 0.14.1, non-`unstable_`): model + mode live + // here under categories `model`/`mode`, routed to the existing bridge + // setters. Replaces the old vendor `_qwen/session/set_model`. + case 'session/set_config_option': { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const configId = String(params['configId'] ?? ''); + const rawValue = params['value']; + const ctx = this.sessionCtx(conn, sessionId, loopback); + // Validate value at the boundary like REST (empty/null is rejected + // rather than forwarded as "" to the bridge). + if (typeof rawValue !== 'string' || rawValue.length === 0) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + '`value` must be a non-empty string', + ), + ); + } + return; + } + const value = rawValue; + if (configId === 'model') { + await this.bridge.setSessionModel( + sessionId, + { modelId: value } as unknown as Parameters< + HttpAcpBridge['setSessionModel'] + >[1], + ctx, + ); + } else if (configId === 'mode') { + // Validate against the closed approval-mode set, like REST. + if (!APPROVAL_MODES.includes(value as ApprovalMode)) { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error( + id, + RPC.INVALID_PARAMS, + `invalid mode "${value}" (expected one of: ${APPROVAL_MODES.join(', ')})`, + ), + ); + } + return; + } + await this.bridge.setSessionApprovalMode( + sessionId, + value as ApprovalMode, + // Forward the optional persist flag like REST. + { persist: params['persist'] === true }, + ctx, + ); + } else { + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, RPC.INVALID_PARAMS, `Unknown configId: ${configId}`), + ); + } + return; + } + // Response returns the updated config option set (per ACP). + const configOptions = await this.configOptionsFor(sessionId); + this.replySession(conn, sessionId, id, { configOptions }); + return; + } + + case `${QWEN_METHOD_NS}session/heartbeat`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const result = this.bridge.recordHeartbeat( + sessionId, + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}session/context`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionContextStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/supported_commands`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + this.replyConn( + conn, + id, + await this.bridge.getSessionSupportedCommandsStatus(sessionId), + ); + return; + } + + case `${QWEN_METHOD_NS}session/update_metadata`: { + const sessionId = String(params['sessionId'] ?? ''); + if (!this.requireOwned(conn, sessionId, id)) return; + const metadata = isObject(params['metadata']) + ? (params['metadata'] as Record) + : {}; + const result = this.bridge.updateSessionMetadata( + sessionId, + metadata as unknown as Parameters< + HttpAcpBridge['updateSessionMetadata'] + >[1], + this.sessionCtx(conn, sessionId, loopback), + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/mcp`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceMcpStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/skills`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceSkillsStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/providers`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceProvidersStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/env`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspaceEnvStatus( + this.wsCtx(conn, method), + ), + ); + return; + case `${QWEN_METHOD_NS}workspace/preflight`: + this.replyConn( + conn, + id, + await this.workspace.getWorkspacePreflightStatus( + this.wsCtx(conn, method), + ), + ); + return; + + case `${QWEN_METHOD_NS}workspace/init`: { + const rawForce = params['force']; + if (rawForce !== undefined && typeof rawForce !== 'boolean') { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`force` must be a boolean when provided', + ), + ); + } + return; + } + const force = rawForce === true; + const result = await this.workspace.initWorkspace( + this.wsCtx(conn, method), + { force }, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/set_tool_enabled`: { + const toolName = String(params['toolName'] ?? ''); + if (!toolName || toolName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`toolName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const result = await this.workspace.setWorkspaceToolEnabled( + this.wsCtx(conn, method), + toolName, + params['enabled'] === true, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + case `${QWEN_METHOD_NS}workspace/restart_mcp_server`: { + const serverName = String(params['serverName'] ?? ''); + if (!serverName || serverName.length > MAX_NAME_LENGTH) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + `\`serverName\` is required and must be ≤ ${MAX_NAME_LENGTH} chars`, + ), + ); + } + return; + } + const rawIdx = params['entryIndex']; + if ( + rawIdx !== undefined && + (typeof rawIdx !== 'number' || + !Number.isInteger(rawIdx) || + rawIdx < 0) + ) { + if (id !== undefined) { + conn.sendConn( + error( + id, + RPC.INVALID_PARAMS, + '`entryIndex` must be a non-negative integer', + ), + ); + } + return; + } + const result = await this.workspace.restartMcpServer( + this.wsCtx(conn, method), + serverName, + rawIdx !== undefined ? { entryIndex: rawIdx } : undefined, + ); + this.replyConn(conn, id, result as unknown); + return; + } + + default: + if (id !== undefined) { + conn.sendConn( + error(id, RPC.METHOD_NOT_FOUND, `Unknown method: ${method}`), + ); + } + return; + } + } catch (err) { + // Full detail to stderr for the operator; a coded, client-safe shape + // on the wire (raw bridge messages may carry internal paths/details). + writeStderrLine( + `qwen serve: /acp dispatch error (${logSafe(method)}): ${logSafe(errMsg(err))}`, + ); + if (id !== undefined) { + const { code, message } = toRpcError(err); + const frame = error(id, code, message); + // Route the error the SAME way as the method's success path. Inferring + // from `params.sessionId` would misroute conn-scoped method failures + // (session/load|resume|close|…) to a session stream that doesn't exist + // yet — the client waiting on the connection stream never sees them. + const sessionId = + typeof params['sessionId'] === 'string' + ? (params['sessionId'] as string) + : undefined; + if (sessionId && !CONN_ROUTED_METHODS.has(method)) { + this.replySession(conn, sessionId, id, undefined, frame); + } else { + conn.sendConn(frame); + } + } + } + } + + /** + * Bind a session-scoped SSE stream to the bridge's event stream, + * translating each `BridgeEvent` into a JSON-RPC frame (design §4.2). + */ + async pumpSessionEvents( + conn: AcpConnection, + sessionId: string, + signal: AbortSignal, + ): Promise { + try { + const iterable = this.bridge.subscribeEvents(sessionId, { signal }); + for await (const event of iterable) { + if (signal.aborted) break; + // Count event delivery as connection activity so a long, quiet prompt + // (no inbound HTTP) isn't reaped by the idle-TTL sweep. + conn.touch(); + this.translateEvent(conn, sessionId, event); + } + } catch (err) { + // Symmetric for the SYNC `subscribeEvents` throw and a MID-STREAM + // iterator error: surface a `stream_error` to the client, then re-throw + // so the caller's `.catch()` closes the stream. Returning would leave a + // zombie SSE stream (heartbeats, no events, no reconnect signal). + if (!signal.aborted) { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: 'stream_error', + error: errMsg(err), + }), + ); + } + throw err; + } + // Normal completion (iterator returned `done` — e.g. the subprocess ended + // cleanly). The caller's `.then` closes the stream so it isn't left as a + // zombie heartbeating with nothing more to deliver. + } + + private translateEvent( + conn: AcpConnection, + sessionId: string, + event: BridgeEvent, + ): void { + switch (event.type) { + case 'session_update': { + // `event.data` is the ACP `SessionNotification` (params shape). + conn.sendSession(sessionId, notification('session/update', event.data)); + return; + } + case 'permission_request': { + const data = event.data as { + requestId: string; + sessionId: string; + toolCall: unknown; + options: unknown; + }; + // A permission request MUST reach a LIVE session stream. Going + // through `sendSession` would (a) silently drop the frame if the + // session was torn down (lookup-only), or (b) buffer it pre-attach + // where `pushCapped` could evict it under event throughput — either + // way the `pending` entry is orphaned and the agent's prompt blocks + // on a vote forever. So deliver DIRECTLY to a live stream, and if + // there is none, cancel (deny-safe) rather than register+stall. + const binding = conn.sessions.get(sessionId); + if (!binding?.stream || binding.stream.isClosed) { + const cancelled = this.cancelAbandonedPermission( + { sessionId, bridgeRequestId: data.requestId }, + // Pass the bridge-stamped clientId when the binding still exists + // (stream closed but session live) — only `undefined` when the + // session is fully gone. + binding?.clientId, + ); + // Unlike resolveClientResponse (where the pending entry exists and + // teardown can retry), this path returns BEFORE `conn.pending.set` — + // so `abandonPendingForSession` will NOT find it. A failed cancel + // here means the mediator is stuck permanently, not just until + // teardown. Log clearly so the operator knows there is no automatic + // recovery; manual intervention (restart the agent session) is needed. + if (!cancelled) { + writeStderrLine( + `qwen serve: /acp permission cancel FAILED for ${logSafe(sessionId)} (mediator stuck; no automatic recovery)`, + ); + } + return; + } + const id = conn.nextId(); + conn.pending.set(id, { + sessionId, + bridgeRequestId: data.requestId, + kind: 'permission', + }); + void binding.stream.send( + request(id, 'session/request_permission', { + sessionId: data.sessionId, + toolCall: data.toolCall, + options: data.options, + _meta: { [QWEN_META_KEY]: { requestId: data.requestId } }, + }), + ); + return; + } + case 'stream_error': { + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + // Spread first so a stray `kind` in event.data can't shadow the + // discriminator the client's error handler keys on. + ...(event.data as object), + kind: 'stream_error', + }), + ); + return; + } + default: { + // client_evicted / slow_client_warning / state_resync_required / + // model_switched / approval_mode_changed / … → opaque qwen notify. + conn.sendSession( + sessionId, + notification(`${QWEN_METHOD_NS}notify`, { + kind: event.type, + data: event.data, + }), + ); + } + } + } + + /** + * Resolve a client's JSON-RPC response to an agent→client request. + * `fromLoopback` is the CURRENT request's loopback bit (the vote POST may + * arrive from a different peer than `initialize`). + */ + private resolveClientResponse( + conn: AcpConnection, + msg: JsonRpcResponse, + fromLoopback: boolean, + ): void { + // Our outbound request ids are strings (`_qwen_perm_N`); a client echoes + // the same id verbatim. Anything else can't match a pending entry. + const id = msg.id; + if (typeof id !== 'string') return; + const pending = conn.pending.get(id); + if (!pending) return; + // NOTE: do NOT delete the pending entry yet. Keep it until either the + // bridge vote OR the cancel fallback runs — if both somehow fail, the + // entry survives so a later session/connection teardown + // (`abandonPendingForSession`) can still release the mediator. + + // A client error response is a cancellation; otherwise pass the result + // through. The cast defers shape validation to the bridge, so a + // MALFORMED result (e.g. `{}` with no `outcome`) makes the mediator + // throw — caught below, where we fall back to an explicit cancel so the + // mediator is always released. The pending entry is dropped only after a + // successful vote/cancel (see the NOTE above), so a double-failure leaves + // it for teardown to retry. + const vote = + 'error' in msg + ? { outcome: { outcome: 'cancelled' } } + : (msg as { result: unknown }).result; + try { + this.bridge.respondToSessionPermission( + pending.sessionId, + pending.bridgeRequestId, + vote as unknown as Parameters< + HttpAcpBridge['respondToSessionPermission'] + >[2], + this.sessionCtx(conn, pending.sessionId, fromLoopback), + ); + conn.pending.delete(id); // vote landed — safe to drop + } catch (err) { + writeStderrLine( + `qwen serve: /acp permission vote failed (${logSafe(pending.sessionId)}): ${logSafe(errMsg(err))}`, + ); + // Cancel BEFORE deleting, and ONLY drop the entry if the cancel + // landed. If it also failed, keep the entry so teardown's + // `abandonPendingForSession` can retry — otherwise the mediator is + // permanently stuck with no recovery path. + const cancelled = this.cancelAbandonedPermission( + pending, + conn.sessions.get(pending.sessionId)?.clientId, + ); + if (cancelled) conn.pending.delete(id); + } + } + + private async handlePrompt( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + params: Record, + fromLoopback: boolean, + ): Promise { + // Park the controller on the binding so `session/cancel` and + // session/connection teardown can abort an in-flight prompt — otherwise + // a disconnecting client leaves the agent running, burning model quota + // and holding the session's prompt FIFO. + const binding = conn.getOrCreateSession(sessionId); + // Abort any prior in-flight prompt for this session before replacing the + // controller — two concurrent `session/prompt`s would otherwise orphan + // the first (it runs to completion in the bridge FIFO, burning quota, + // and `session/cancel` could only reach the latest controller). + binding.promptAbort?.abort(); + const abort = new AbortController(); + binding.promptAbort = abort; + try { + const result = await this.bridge.sendPrompt( + sessionId, + // SECURITY NOTE: `params.sessionId` already equals the routing + // `sessionId` (both from the same params), so there's no routing + // divergence today. If the bridge ever trusts an additional + // `sendPrompt` field by name (e.g. a priority/temperature override), + // force-stamp it here like the REST surface does (`{ ...body, + // sessionId, prompt }`) so it can't become client-controlled. + params as unknown as Parameters[1], + abort.signal, + this.sessionCtx(conn, sessionId, fromLoopback), + ); + if (id !== undefined) this.replySession(conn, sessionId, id, result); + } catch (err) { + const { code, message } = toRpcError(err); + if (id !== undefined) { + this.replySession( + conn, + sessionId, + id, + undefined, + error(id, code, message), + ); + } else { + // Notification-form prompt (no id): no response frame to send, so a + // failure would vanish silently — log it for the operator. + writeStderrLine( + `qwen serve: /acp prompt error (${logSafe(sessionId)}, notification): ${logSafe(errMsg(err))}`, + ); + } + } finally { + if (binding.promptAbort === abort) binding.promptAbort = undefined; + } + } + + private replyConn( + conn: AcpConnection, + id: JsonRpcId | undefined, + result: unknown, + ): void { + if (id === undefined) return; + conn.sendConn(success(id, result)); + } + + private replySession( + conn: AcpConnection, + sessionId: string, + id: JsonRpcId | undefined, + result: unknown, + errorFrame?: ReturnType, + ): void { + if (id === undefined) return; + const frame = errorFrame ?? success(id, result); + // If the session was torn down mid-flight (e.g. a concurrent + // `session/close`), the binding + session stream are gone and + // `sendSession` is lookup-only — it would SILENTLY DROP this frame, + // violating the JSON-RPC one-response-per-request contract. Fall back to + // the connection-scoped stream so an id'd request always gets its reply. + if (conn.sessions.has(sessionId)) { + conn.sendSession(sessionId, frame); + } else { + // Fallback fired — log it so an operator can correlate "reply arrived on + // the connection stream, not the session stream" with a mid-flight + // session teardown. + writeStderrLine( + `qwen serve: /acp replySession(${logSafe(sessionId)}) binding gone mid-flight, ` + + `reply routed to connection stream ${conn.connectionId.slice(0, 8)}`, + ); + conn.sendConn(frame); + } + } +} + +// Re-export so tests can reference the request type without the jsonRpc path. +export type { JsonRpcRequest }; diff --git a/packages/cli/src/serve/acpHttp/index.ts b/packages/cli/src/serve/acpHttp/index.ts new file mode 100644 index 00000000000..39ebcdccbb7 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/index.ts @@ -0,0 +1,329 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Application, Request, Response } from 'express'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; +import { AcpDispatcher } from './dispatch.js'; +import { ConnectionRegistry } from './connectionRegistry.js'; +import { SseStream } from './sseStream.js'; +import { RPC, error as rpcError, isRequest, parseInbound } from './jsonRpc.js'; + +export const ACP_CONNECTION_HEADER = 'acp-connection-id'; +export const ACP_SESSION_HEADER = 'acp-session-id'; + +/** + * Grace window after the connection-scoped SSE stream closes before the + * connection is reaped (if not reconnected and no session stream is live). + * Long enough to ride out a transient blip / reconnect, short enough to free + * `ownedSessions` + a `maxConnections` slot well before the 30-min idle TTL. + */ +const CONN_GRACE_MS = 10_000; + +export interface MountAcpHttpOptions { + boundWorkspace: string; + /** Workspace service facade for workspace-scoped operations. */ + workspace: DaemonWorkspaceService; + /** Defaults to `process.env.QWEN_SERVE_ACP_HTTP !== '0'`. */ + enabled?: boolean; + /** Mount path; defaults to `/acp`. */ + path?: string; + /** Concurrent-connection cap; `0` disables. Defaults to the registry default. */ + maxConnections?: number; +} + +export interface AcpHttpHandle { + dispose(): void; + registry: ConnectionRegistry; +} + +/** + * Mount the official ACP Streamable HTTP transport (RFD #721) on an + * existing Express app, backed by the shared `HttpAcpBridge`. Additive: + * the REST surface (`/session/*`) is untouched (design doc §6). + * + * Wire shape (single `/acp` endpoint): + * - POST {initialize} → 200 + capabilities JSON + `Acp-Connection-Id` + * - POST {other} → 202; reply delivered on a long-lived SSE stream + * - GET (conn header) → connection-scoped SSE stream + * - GET (conn+session)→ session-scoped SSE stream + * - DELETE → 202; tears the connection down + */ +export function mountAcpHttp( + app: Application, + bridge: HttpAcpBridge, + opts: MountAcpHttpOptions, +): AcpHttpHandle | undefined { + const enabled = opts.enabled ?? process.env['QWEN_SERVE_ACP_HTTP'] !== '0'; + if (!enabled) return undefined; + + const path = opts.path ?? '/acp'; + const dispatcher = new AcpDispatcher( + bridge, + opts.boundWorkspace, + opts.workspace, + ); + // When a session/connection tears down with a permission still pending, + // cancel it on the bridge so the agent's prompt isn't left blocked. + const registry = new ConnectionRegistry( + (req, clientId) => dispatcher.cancelAbandonedPermission(req, clientId), + // Best-effort bridge detach so a torn-down connection's bridge-stamped + // client ids don't linger in the bridge's voter/known-client sets. + (sessionId, clientId) => { + void bridge.detachClient(sessionId, clientId).catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp detachClient(${sessionId}) failed: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }, + opts.maxConnections, + ); + + // ── POST /acp ────────────────────────────────────────────────────── + app.post(path, async (req: Request, res: Response) => { + const parsed = parseInbound(req.body); + if (!parsed.ok) { + writeStderrLine( + `qwen serve: /acp malformed request from ${req.socket?.remoteAddress}: ${parsed.error.error.message}`, + ); + res.status(400).json(parsed.error); + return; + } + const message = parsed.message; + + // `initialize` mints a connection and replies inline (200 + JSON). + if (isRequest(message) && message.method === 'initialize') { + const conn = registry.create(isLoopbackReq(req)); + if (!conn) { + // Connection cap reached — shed load rather than grow unbounded. + writeStderrLine( + `qwen serve: /acp connection cap reached (max=${registry.connectionCap}), rejecting initialize`, + ); + res.setHeader('Retry-After', '5'); + res + .status(503) + .json( + rpcError( + message.id, + RPC.INTERNAL_ERROR, + 'Too many ACP connections; retry later', + ), + ); + return; + } + const requestedVersion = + message.params && + typeof message.params === 'object' && + !Array.isArray(message.params) + ? (message.params as Record)['protocolVersion'] + : undefined; + res.setHeader('Acp-Connection-Id', conn.connectionId); + res.status(200).json({ + // success envelope: clients correlate by the request id. + jsonrpc: '2.0', + id: message.id, + result: dispatcher.buildInitializeResult( + conn.connectionId, + requestedVersion, + ), + }); + writeStderrLine( + `qwen serve: /acp connection established ${conn.connectionId.slice(0, 8)} ` + + `(loopback=${conn.fromLoopback}, active=${registry.size})`, + ); + return; + } + + const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); + if (!conn) { + res + .status(400) + .json( + rpcError( + isRequest(message) ? message.id : null, + RPC.INVALID_REQUEST, + 'Missing or unknown Acp-Connection-Id', + ), + ); + return; + } + + // Per RFD: non-initialize POST acks 202; the reply rides an SSE stream. + res.status(202).end(); + // Response already sent — `handle` delivers everything else over SSE, so + // swallow+log any late rejection rather than let it escape as an + // unhandled rejection (which could take the daemon down). + await dispatcher + .handle( + conn, + message, + headerOf(req, ACP_SESSION_HEADER), + isLoopbackReq(req), + ) + .catch((err: unknown) => { + writeStderrLine( + `qwen serve: /acp handle error: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + }); + }); + + // ── GET /acp (SSE) ───────────────────────────────────────────────── + app.get(path, (req: Request, res: Response) => { + const conn = registry.get(headerOf(req, ACP_CONNECTION_HEADER)); + if (!conn) { + res.status(400).json({ error: 'Missing or unknown Acp-Connection-Id' }); + return; + } + const sessionId = headerOf(req, ACP_SESSION_HEADER); + + if (!sessionId) { + // Connection-scoped stream. onClose logs the disconnect so a + // half-dead connection (conn stream gone, replies silently buffering) + // leaves an operator breadcrumb. + const connId = conn.connectionId; + const stream = new SseStream( + res, + () => { + writeStderrLine( + `qwen serve: /acp connection stream closed (${connId.slice(0, 8)})`, + ); + // Grace-period reap: a dead connection otherwise locks its + // ownedSessions + counts against maxConnections for the full 30-min + // idle TTL. After the grace window, reap UNLESS a reconnect + // re-attached the conn stream (clears the timer) OR a session + // stream is still live (client is active — only the conn stream + // blipped, don't kill its sessions/prompts). + conn.clearGraceTimer(); + conn.connGraceTimer = setTimeout(() => { + if ( + registry.get(connId) === conn && + conn.connStream === stream && + !conn.hasLiveSessionStream() + ) { + writeStderrLine( + `qwen serve: /acp reaping connection ${connId.slice(0, 8)} (conn stream gone, no live session stream)`, + ); + registry.delete(connId); + } + }, CONN_GRACE_MS); + conn.connGraceTimer.unref?.(); + }, + () => conn.touch(), + ); + stream.open(); + conn.attachConnStream(stream); + return; + } + + // Session-scoped stream — only for a session THIS connection owns + // (created via session/new or attached via session/load|resume). Stops + // one connection eavesdropping on another's session event stream. + if (!conn.ownsSession(sessionId)) { + res.status(403).json({ error: 'Session not owned by this connection' }); + return; + } + + // Fresh controller per stream so a reconnect gets a live (non-aborted) + // signal; `attachSessionStream` installs it and tears down any prior + // stream/subscription. onClose aborts THIS stream's controller — a + // stale stream closing can't cancel a newer subscription. + const ac = new AbortController(); + const stream = new SseStream( + res, + () => { + // Stream closed (tab close / network drop / crash): stop the event + // pump AND abort any in-flight prompt for this session — otherwise + // the agent keeps running (quota, FIFO) until idle TTL. + ac.abort(); + // BUT only abort the prompt when THIS is still the session's live + // stream. A reconnect already installed a newer stream — the prompt + // must survive the old stream's close. CONTRACT: this identity guard + // pairs with `attachSessionStream`'s install-before-close ordering + // (connectionRegistry.ts) — keep both in lockstep. + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.sessions.get(sessionId)?.promptAbort?.abort(); + } + }, + () => conn.touch(), + ); + // Open (write SSE headers + `retry:`) BEFORE attaching, so the protocol + // handshake precedes any buffered frames the attach flushes. + stream.open(); + conn.attachSessionStream(sessionId, stream, ac); + // Identity-guarded close: only tear down if THIS stream is still the + // session's current one (a reconnect between settle and this microtask + // would otherwise kill the fresh stream). + const closeIfCurrent = () => { + if (conn.sessions.get(sessionId)?.stream === stream) { + conn.closeSessionStream(sessionId); + } + }; + void dispatcher.pumpSessionEvents(conn, sessionId, ac.signal).then( + // NORMAL completion (iterator returned `done` — subprocess ended): close + // so the stream isn't a zombie heartbeating with nothing left to deliver. + closeIfCurrent, + (err: unknown) => { + writeStderrLine( + `qwen serve: /acp event pump error (${sessionId}): ${ + err instanceof Error ? err.message : String(err) + }`, + ); + closeIfCurrent(); + }, + ); + }); + + // ── DELETE /acp ──────────────────────────────────────────────────── + app.delete(path, (req: Request, res: Response) => { + const connectionId = headerOf(req, ACP_CONNECTION_HEADER); + if (!connectionId) { + res.status(400).json({ error: 'Missing Acp-Connection-Id' }); + return; + } + // NOTE: like every other route, DELETE is gated only by the bearer + // token — the daemon's trust boundary is "holds the token for this + // single-workspace daemon", so any token-holder may tear down any + // connection (same posture as the REST `DELETE /session/:id`). A + // per-connection secret would add intra-token isolation; deferred with + // the rest of the multi-tenant hardening (design §7). + const existed = registry.delete(connectionId); + if (existed) { + writeStderrLine( + `qwen serve: /acp connection deleted ${connectionId.slice(0, 8)} (remaining=${registry.size})`, + ); + } + res.status(202).end(); + }); + + return { dispose: () => registry.dispose(), registry }; +} + +function headerOf(req: Request, name: string): string | undefined { + const v = req.headers[name]; + return Array.isArray(v) ? v[0] : v; +} + +/** + * True when the request's KERNEL-stamped peer address is loopback. Mirrors + * the REST surface's `detectFromLoopback` (NOT derived from forgeable + * headers like `X-Forwarded-For`). Replicated here rather than imported + * from `server.ts` to avoid a server↔acpHttp import cycle. + */ +function isLoopbackReq(req: Request): boolean { + const addr = req.socket?.remoteAddress; + if (typeof addr !== 'string') return false; + // Match the REST surface's `detectFromLoopback`: the full 127.0.0.0/8 + // range + the IPv4-mapped block, not just three exact literals (a + // container peer on 127.0.0.2 is legal loopback). + return ( + addr === '::1' || addr.startsWith('127.') || addr.startsWith('::ffff:127.') + ); +} diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.test.ts b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts new file mode 100644 index 00000000000..43281887ad0 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.test.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + isNotification, + isRequest, + isResponse, + parseInbound, + QWEN_METHOD_NS, + RPC, +} from './jsonRpc.js'; + +describe('jsonRpc helpers', () => { + it('classifies a request', () => { + const m = { jsonrpc: '2.0', id: 1, method: 'initialize' }; + expect(isRequest(m)).toBe(true); + expect(isNotification(m)).toBe(false); + expect(isResponse(m)).toBe(false); + }); + + it('classifies a notification (no id)', () => { + const m = { jsonrpc: '2.0', method: 'session/cancel' }; + expect(isNotification(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies a response (result, no method)', () => { + const m = { jsonrpc: '2.0', id: -1, result: { ok: true } }; + expect(isResponse(m)).toBe(true); + expect(isRequest(m)).toBe(false); + }); + + it('classifies an error response', () => { + const m = { jsonrpc: '2.0', id: 2, error: { code: -1, message: 'x' } }; + expect(isResponse(m)).toBe(true); + }); + + it('rejects a response with BOTH result and error (XOR); parseInbound → 400-shape', () => { + const m = { + jsonrpc: '2.0', + id: 3, + result: {}, + error: { code: -1, message: 'x' }, + }; + expect(isResponse(m)).toBe(false); + const r = parseInbound(m); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects JSON-RPC batch arrays', () => { + const r = parseInbound([{ jsonrpc: '2.0', id: 1, method: 'x' }]); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.error.error.code).toBe(RPC.INVALID_REQUEST); + }); + + it('rejects malformed envelopes', () => { + expect(parseInbound({ foo: 'bar' }).ok).toBe(false); + expect(parseInbound(null).ok).toBe(false); + }); + + it('accepts a well-formed request', () => { + const r = parseInbound({ jsonrpc: '2.0', id: 1, method: 'session/new' }); + expect(r.ok).toBe(true); + }); + + it('exposes the qwen extension namespace', () => { + expect(QWEN_METHOD_NS).toBe('_qwen/'); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/jsonRpc.ts b/packages/cli/src/serve/acpHttp/jsonRpc.ts new file mode 100644 index 00000000000..71339e69c6f --- /dev/null +++ b/packages/cli/src/serve/acpHttp/jsonRpc.ts @@ -0,0 +1,187 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Minimal JSON-RPC 2.0 helpers for the ACP-over-HTTP transport + * (`packages/cli/src/serve/acpHttp/`). The official ACP Streamable HTTP + * transport (RFD #721) frames every message as a JSON-RPC 2.0 object; + * this module owns the wire types + parse/validate/serialize so the + * dispatcher stays focused on bridge routing. + * + * We hand-roll framing (rather than reuse `@agentclientprotocol/sdk`'s + * `ndJsonStream`) because the RFD splits a single logical connection + * across multiple long-lived SSE streams (connection-scoped + one per + * session), so outbound frames must be demultiplexed to the right + * stream — something a single duplex `Connection` can't express. + */ + +/** + * Vendor extension namespace. ACP reserves any `_`-prefixed method for + * extensions (the ONLY hard rule); the spec's `_zed.dev/…` example shows a + * domain-style segment by convention, but `qwen` is distinctive enough that + * we use the shorter bare form `_qwen/…`. Vendor data on standard messages + * goes under `_meta` keyed by the same name (`_meta: { "qwen": … }`). + */ +export const QWEN_METHOD_NS = '_qwen/'; +/** Key for vendor `_meta` blocks (capabilities + per-message data). */ +export const QWEN_META_KEY = 'qwen'; + +export type JsonRpcId = number | string; + +export interface JsonRpcRequest { + jsonrpc: '2.0'; + id: JsonRpcId; + method: string; + params?: unknown; +} + +export interface JsonRpcNotification { + jsonrpc: '2.0'; + method: string; + params?: unknown; +} + +export interface JsonRpcSuccess { + jsonrpc: '2.0'; + id: JsonRpcId; + result: unknown; +} + +export interface JsonRpcErrorObject { + code: number; + message: string; + data?: unknown; +} + +export interface JsonRpcError { + jsonrpc: '2.0'; + id: JsonRpcId | null; + error: JsonRpcErrorObject; +} + +export type JsonRpcOutbound = JsonRpcRequest | JsonRpcNotification; +export type JsonRpcResponse = JsonRpcSuccess | JsonRpcError; +export type JsonRpcInbound = + | JsonRpcRequest + | JsonRpcNotification + | JsonRpcResponse; + +/** Standard JSON-RPC 2.0 error codes. */ +export const RPC = { + PARSE_ERROR: -32700, + INVALID_REQUEST: -32600, + METHOD_NOT_FOUND: -32601, + INVALID_PARAMS: -32602, + INTERNAL_ERROR: -32603, +} as const; + +export function isObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +export function isRequest(m: unknown): m is JsonRpcRequest { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + 'id' in m && + m['id'] !== null && + (typeof m['id'] === 'number' || typeof m['id'] === 'string') + ); +} + +export function isNotification(m: unknown): m is JsonRpcNotification { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + typeof m['method'] === 'string' && + !('id' in m) + ); +} + +export function isResponse(m: unknown): m is JsonRpcResponse { + return ( + isObject(m) && + m['jsonrpc'] === '2.0' && + !('method' in m) && + 'id' in m && + // JSON-RPC 2.0 §5: EXACTLY one of result/error (XOR). Accepting both + // would let a buggy client's approval (result + error) be misread as a + // cancellation by the `'error' in msg` check downstream. A dual-field + // message therefore fails isRequest/isNotification/isResponse → + // `parseInbound` rejects it → the POST handler returns 400 (logged by the + // malformed-request path in index.ts), so the client is told its vote was + // not accepted (not a silent drop); it can retry with a valid response, + // and teardown still releases the pending entry if it doesn't. + 'result' in m !== 'error' in m + ); +} + +/** + * Strip C0 control chars + DEL from values interpolated into operator-facing + * stderr logs, so a client-controlled `sessionId`/`method`/error string can't + * forge or split log lines (log injection). Shared by the transport modules. + */ +export function logSafe(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\u0000-\u001f\u007f\u0080-\u009f]/g, ' '); +} + +export function success(id: JsonRpcId, result: unknown): JsonRpcSuccess { + return { jsonrpc: '2.0', id, result }; +} + +export function error( + id: JsonRpcId | null, + code: number, + message: string, + data?: unknown, +): JsonRpcError { + return { + jsonrpc: '2.0', + id, + error: { code, message, ...(data !== undefined ? { data } : {}) }, + }; +} + +export function notification( + method: string, + params: unknown, +): JsonRpcNotification { + return { jsonrpc: '2.0', method, params }; +} + +export function request( + id: JsonRpcId, + method: string, + params: unknown, +): JsonRpcRequest { + return { jsonrpc: '2.0', id, method, params }; +} + +/** + * Parse a request body into a JSON-RPC message. Returns `{ ok: false }` + * with a ready-to-send error on malformed JSON or a non-conforming + * envelope (batch arrays are rejected per RFD §"batch → 501", surfaced + * here as INVALID_REQUEST since we never reach the 501 path). + */ +export function parseInbound( + raw: unknown, +): { ok: true; message: JsonRpcInbound } | { ok: false; error: JsonRpcError } { + if (Array.isArray(raw)) { + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'JSON-RPC batch not supported'), + }; + } + if (isRequest(raw) || isNotification(raw) || isResponse(raw)) { + return { ok: true, message: raw }; + } + return { + ok: false, + error: error(null, RPC.INVALID_REQUEST, 'Malformed JSON-RPC message'), + }; +} diff --git a/packages/cli/src/serve/acpHttp/sseStream.test.ts b/packages/cli/src/serve/acpHttp/sseStream.test.ts new file mode 100644 index 00000000000..500fe270728 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.test.ts @@ -0,0 +1,150 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'node:events'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { Response } from 'express'; +import { SseStream } from './sseStream.js'; + +/** + * Minimal Express `Response` mock: an EventEmitter with the `write`/`end`/ + * header surface `SseStream` touches. `writeBehavior` lets a test force + * `res.write` to return false (backpressure) or throw (socket error). + */ +function mockRes(writeBehavior?: () => boolean) { + const ee = new EventEmitter() as unknown as Response & { + chunks: string[]; + ended: boolean; + }; + const m = ee as unknown as { + chunks: string[]; + ended: boolean; + writableEnded: boolean; + status: () => unknown; + setHeader: () => void; + flushHeaders: () => void; + write: (c: string) => boolean; + end: () => void; + req: EventEmitter; + }; + m.chunks = []; + m.ended = false; + m.writableEnded = false; + m.status = () => ee; + m.setHeader = () => {}; + m.flushHeaders = () => {}; + m.req = new EventEmitter(); + m.write = (chunk: string) => { + m.chunks.push(chunk); + return writeBehavior ? writeBehavior() : true; + }; + m.end = () => { + m.ended = true; + m.writableEnded = true; + }; + return ee as unknown as Response & { chunks: string[]; ended: boolean }; +} + +describe('SseStream', () => { + afterEach(() => vi.useRealTimers()); + + it('open() writes the retry hint; send() writes a data: frame', async () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + await s.send({ jsonrpc: '2.0', id: 1, result: { ok: true } }); + const joined = (res as unknown as { chunks: string[] }).chunks.join(''); + expect(joined).toContain('retry: 3000'); + expect(joined).toContain( + 'data: {"jsonrpc":"2.0","id":1,"result":{"ok":true}}\n\n', + ); + }); + + it('close() ends the response once and is idempotent', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + s.close(); + expect((res as unknown as { ended: boolean }).ended).toBe(true); + expect(s.isClosed).toBe(true); + s.close(); // no throw on double close + }); + + it('close() swallows a throwing onClose callback', () => { + const res = mockRes(); + const s = new SseStream(res, () => { + throw new Error('onClose boom'); + }); + s.open(); + expect(() => s.close()).not.toThrow(); + expect(s.isClosed).toBe(true); + }); + + it('a write failure closes the stream and fires onClose', async () => { + let closed = false; + const res = mockRes(() => { + throw new Error('EPIPE'); + }); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); // retry write throws → chain catch closes + await new Promise((r) => setTimeout(r, 10)); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('heartbeat fires onHeartbeat on the interval', () => { + vi.useFakeTimers(); + let beats = 0; + const res = mockRes(); + const s = new SseStream(res, undefined, () => { + beats++; + }); + s.open(); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(1); + vi.advanceTimersByTime(15_000); + expect(beats).toBe(2); + s.close(); + }); + + it('a req "close" event auto-closes the stream and fires onClose', () => { + let closed = false; + const res = mockRes(); + const s = new SseStream(res, () => { + closed = true; + }); + s.open(); + (res as unknown as { req: EventEmitter }).req.emit('close'); + expect(s.isClosed).toBe(true); + expect(closed).toBe(true); + }); + + it('a res "error" event auto-closes the stream', () => { + const res = mockRes(); + const s = new SseStream(res); + s.open(); + (res as unknown as EventEmitter).emit('error', new Error('ECONNRESET')); + expect(s.isClosed).toBe(true); + }); + + it('doWrite resolves after drain when write() returns false (backpressure)', async () => { + let backpressured = true; + const res = mockRes(() => !backpressured); // false first → drain needed + const s = new SseStream(res); + s.open(); + const p = s.send({ id: 2 }); + let settled = false; + void p.then(() => (settled = true)); + await new Promise((r) => setTimeout(r, 10)); + expect(settled).toBe(false); // still awaiting drain + backpressured = false; + (res as unknown as EventEmitter).emit('drain'); + await p; + expect(settled).toBe(true); + }); +}); diff --git a/packages/cli/src/serve/acpHttp/sseStream.ts b/packages/cli/src/serve/acpHttp/sseStream.ts new file mode 100644 index 00000000000..dfac5745c68 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/sseStream.ts @@ -0,0 +1,160 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Response } from 'express'; +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +/** + * A long-lived Server-Sent-Events writer for the ACP-over-HTTP transport. + * + * Unlike the REST `/session/:id/events` stream (qwen event envelopes), the + * ACP transport carries raw JSON-RPC 2.0 objects as the SSE `data:` payload + * — one object per frame. The RFD keeps these streams open for the life of + * the connection/session, so the writer must: + * - serialize writes through a single chain (heartbeat can't interleave), + * - respect backpressure (`res.write` → false ⇒ await `drain`), + * - emit periodic comment heartbeats to keep NAT/proxies alive. + * + * This mirrors the battle-tested pattern in `server.ts`'s SSE handler but + * trimmed to what the ACP transport needs (no ring-buffer `id:` sequencing — + * resumability is RFD Phase 4, deferred per the design doc §7). + */ +export class SseStream { + private writeChain: Promise = Promise.resolve(); + private heartbeat: ReturnType | undefined; + private closed = false; + private cleanupFn: (() => void) | undefined; + + constructor( + private readonly res: Response, + private readonly onClose?: () => void, + /** + * Fired on each heartbeat tick while the stream is open. Used to mark the + * connection active so a long-running prompt that emits no intermediate + * frames for >30 min isn't reaped by the idle-TTL sweep. + */ + private readonly onHeartbeat?: () => void, + ) {} + + /** Write SSE headers + retry hint and start the heartbeat. */ + open(): void { + this.res.status(200); + this.res.setHeader('Content-Type', 'text/event-stream'); + this.res.setHeader('Cache-Control', 'no-cache, no-transform'); + this.res.setHeader('Connection', 'keep-alive'); + this.res.setHeader('X-Accel-Buffering', 'no'); + this.res.flushHeaders(); + void this.writeRaw('retry: 3000\n\n'); + + this.heartbeat = setInterval(() => { + if (this.closed) return; + this.onHeartbeat?.(); + void this.writeRaw(': hb\n\n'); + }, 15_000); + this.heartbeat.unref(); + + this.cleanupFn = () => this.close(); + this.res.req.on('close', this.cleanupFn); + this.res.on('error', this.cleanupFn); + } + + /** Serialize a JSON-RPC message as one SSE frame. */ + send(message: unknown): Promise { + return this.writeRaw(`data: ${JSON.stringify(message)}\n\n`); + } + + get isClosed(): boolean { + return this.closed; + } + + close(): void { + if (this.closed) return; + this.closed = true; + if (this.heartbeat) clearInterval(this.heartbeat); + if (this.cleanupFn) { + this.res.req.off('close', this.cleanupFn); + this.res.off('error', this.cleanupFn); + this.cleanupFn = undefined; + } + try { + if (!this.res.writableEnded) this.res.end(); + } catch { + // socket already gone — nothing to flush + } + // Guard `onClose`: `close()` can run inside a socket `'error'`/`'close'` + // event handler, and a throwing callback there would escape into Node's + // emitter stack (potential crash). Swallow + log instead. + try { + this.onClose?.(); + } catch (err) { + writeStderrLine( + `qwen serve: /acp SSE onClose threw: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + } + } + + private writeRaw(chunk: string): Promise { + const next = this.writeChain.then(() => this.doWrite(chunk)); + // The stream OWNS write-failure handling: callers fire-and-forget + // (`void stream.send(...)`), so a broken socket would otherwise leave a + // zombie stream (heartbeats firing, no events delivered, no log). On the + // first failure, log once and close so the subscription tears down. + this.writeChain = next.catch((err: unknown) => { + if (!this.closed) { + writeStderrLine( + `qwen serve: /acp SSE write failed, closing stream: ${ + err instanceof Error ? err.message : String(err) + }`, + ); + this.close(); + } + return undefined; + }); + return next; + } + + private doWrite(chunk: string): Promise { + return new Promise((resolve, reject) => { + if (this.closed || this.res.writableEnded) { + resolve(); + return; + } + let ok: boolean; + try { + ok = this.res.write(chunk); + } catch (err) { + reject(err as Error); + return; + } + if (ok) { + resolve(); + return; + } + // Await drain, but also bail on close/error so a socket failure during + // the drain window rejects promptly instead of hanging until 'close'. + const onDrain = () => { + this.res.off('close', onCloseEv); + this.res.off('error', onErrorEv); + resolve(); + }; + const onCloseEv = () => { + this.res.off('drain', onDrain); + this.res.off('error', onErrorEv); + resolve(); + }; + const onErrorEv = (err: Error) => { + this.res.off('drain', onDrain); + this.res.off('close', onCloseEv); + reject(err); + }; + this.res.once('drain', onDrain); + this.res.once('close', onCloseEv); + this.res.once('error', onErrorEv); + }); + } +} diff --git a/packages/cli/src/serve/acpHttp/transport.test.ts b/packages/cli/src/serve/acpHttp/transport.test.ts new file mode 100644 index 00000000000..3a87f8c22d9 --- /dev/null +++ b/packages/cli/src/serve/acpHttp/transport.test.ts @@ -0,0 +1,1452 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import express from 'express'; +import type { Server } from 'node:http'; +import type { AddressInfo } from 'node:net'; +import type { HttpAcpBridge } from '@qwen-code/acp-bridge/bridgeTypes'; +import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; +import type { DaemonWorkspaceService } from '../workspace-service/types.js'; +import { mountAcpHttp } from './index.js'; + +/** + * End-to-end transport test: boots a real Express server with the ACP + * Streamable-HTTP transport mounted over a *fake* bridge, then drives it + * with a real HTTP client (global fetch + manual SSE parsing). This is + * the automated form of the design doc's local verification plan — it + * exercises the actual wire protocol (200/202 conventions, both SSE + * streams, JSON-RPC framing) without needing a model. + */ + +interface PushIterable { + iterable: AsyncIterable; + push: (e: Omit) => void; + end: () => void; +} + +function pushQueue(signal?: AbortSignal): PushIterable { + const buf: BridgeEvent[] = []; + let resolveNext: (() => void) | undefined; + let done = false; + let nextId = 1; + const wake = () => { + resolveNext?.(); + resolveNext = undefined; + }; + signal?.addEventListener('abort', () => { + done = true; + wake(); + }); + const iterable: AsyncIterable = { + async *[Symbol.asyncIterator]() { + while (true) { + while (buf.length) yield buf.shift()!; + if (done) return; + await new Promise((r) => (resolveNext = r)); + } + }, + }; + return { + iterable, + push: (e) => { + buf.push({ v: 1, id: nextId++, ...e } as BridgeEvent); + wake(); + }, + end: () => { + done = true; + wake(); + }, + }; +} + +// A controllable fake bridge: tests register what `sendPrompt` should do. +class FakeBridge { + queues = new Map(); + promptBehavior: + | (( + sessionId: string, + q: PushIterable, + signal?: AbortSignal, + ) => Promise) + | undefined; + lastSetModel: unknown; + lastSpawnScope: string | undefined; + closeShouldThrow = false; + killed: string[] = []; + cancelled: string[] = []; + /** When set, spawnOrAttach/loadSession await it (to simulate a slow bridge). */ + gate: Promise | undefined; + /** `attached` value loadSession returns (false = spawned-from-disk). */ + loadAttached = true; + + closedSessions: string[] = []; + + async spawnOrAttach(req: { sessionScope?: string }) { + this.lastSpawnScope = req?.sessionScope; + if (this.gate) await this.gate; + return { + sessionId: 'sess-1', + workspaceCwd: '/ws', + attached: false, + clientId: 'client-1', + }; + } + async killSession(sessionId: string) { + this.killed.push(sessionId); + } + + loadShouldThrow = false; + + async loadSession(req: { sessionId: string }) { + if (this.loadShouldThrow) throw new Error('load failed'); + if (this.gate) await this.gate; + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: this.loadAttached, + clientId: 'client-load', + state: { replayed: true }, + }; + } + + async resumeSession(req: { sessionId: string }) { + return { + sessionId: req.sessionId, + workspaceCwd: '/ws', + attached: true, + clientId: 'client-resume', + state: { resumed: true }, + }; + } + + subscribeThrows = false; + + subscribeEvents(sessionId: string, opts?: { signal?: AbortSignal }) { + if (this.subscribeThrows) throw new Error('subscribe failed'); + const q = pushQueue(opts?.signal); + this.queues.set(sessionId, q); + return q.iterable; + } + + async sendPrompt(sessionId: string, _req: unknown, signal?: AbortSignal) { + const q = this.queues.get(sessionId); + if (this.promptBehavior && q) + return this.promptBehavior(sessionId, q, signal); + return { stopReason: 'end_turn' }; + } + + respondToSessionPermission() { + return true; + } + + async setSessionModel(_s: string, req: unknown) { + this.lastSetModel = req; + return { modelServiceId: 'qwen-max' }; + } + + lastApprovalMode: string | undefined; + async setSessionApprovalMode(_s: string, mode: string) { + this.lastApprovalMode = mode; + return { sessionId: 'sess-1', mode, previous: 'default', persisted: false }; + } + + // Session config options live in the child's session context state. + async getSessionContextStatus(sessionId: string) { + return { + v: 1, + sessionId, + workspaceCwd: '/ws', + state: { + configOptions: [ + { + id: 'model', + name: 'Model', + category: 'model', + type: 'select', + currentValue: 'qwen-max', + options: [], + }, + ], + }, + }; + } + async getSessionSupportedCommandsStatus(sessionId: string) { + return { v: 1, sessionId, availableCommands: [], availableSkills: [] }; + } + updateSessionMetadata(_s: string, metadata: unknown) { + return metadata; + } + + recordHeartbeat() { + return { sessionId: 'sess-1', lastSeenAt: Date.now() }; + } + + listWorkspaceSessions() { + return []; + } + + detached: Array<{ sessionId: string; clientId?: string }> = []; + + async cancelSession(sessionId: string) { + this.cancelled.push(sessionId); + } + closeGate: Promise | undefined; + async closeSession(sessionId: string) { + this.closedSessions.push(sessionId); + if (this.closeGate) await this.closeGate; + if (this.closeShouldThrow) throw new Error('bridge close failed'); + } + async detachClient(sessionId: string, clientId?: string) { + this.detached.push({ sessionId, clientId }); + } +} + +// A minimal fake workspace service for dispatch tests. +const fakeWorkspace = { + async getWorkspaceMcpStatus() { + return { ok: true, v: 1, workspaceCwd: '/ws' }; + }, + async getWorkspaceSkillsStatus() { + return { ok: true }; + }, + async getWorkspaceProvidersStatus() { + return { ok: true }; + }, + async getWorkspaceEnvStatus() { + return { ok: true }; + }, + async getWorkspacePreflightStatus() { + return { ok: true }; + }, + async setWorkspaceToolEnabled( + _ctx: unknown, + toolName: string, + enabled: boolean, + ) { + return { toolName, enabled }; + }, + async initWorkspace() { + return { path: '/ws/QWEN.md', action: 'created' as const }; + }, + async restartMcpServer() { + return { ok: true }; + }, +} as unknown as DaemonWorkspaceService; + +// ── SSE client helper ──────────────────────────────────────────────── +async function* readSse( + res: Response, + signal: AbortSignal, +): AsyncGenerator { + const reader = res.body!.getReader(); + const decoder = new TextDecoder(); + let buf = ''; + signal.addEventListener('abort', () => void reader.cancel().catch(() => {})); + while (true) { + const { value, done } = await reader.read(); + if (done) return; + buf += decoder.decode(value, { stream: true }); + let idx: number; + while ((idx = buf.indexOf('\n\n')) !== -1) { + const frame = buf.slice(0, idx); + buf = buf.slice(idx + 2); + const dataLine = frame.split('\n').find((l) => l.startsWith('data: ')); + if (dataLine) yield JSON.parse(dataLine.slice('data: '.length)); + } + } +} + +/** Read the next N data frames from an SSE response, then abort. */ +async function takeFrames( + res: Response, + n: number, + timeoutMs = 2000, +): Promise { + const out: unknown[] = []; + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + for await (const f of readSse(res, ac.signal)) { + out.push(f); + if (out.length >= n) break; + } + } finally { + clearTimeout(timer); + ac.abort(); + } + return out; +} + +describe('ACP Streamable HTTP transport (over the wire)', () => { + let server: Server; + let base: string; + let bridge: FakeBridge; + + beforeEach(async () => { + bridge = new FakeBridge(); + const app = express(); + app.use(express.json()); + mountAcpHttp(app, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + workspace: fakeWorkspace, + enabled: true, + }); + await new Promise((resolve) => { + server = app.listen(0, '127.0.0.1', () => resolve()); + }); + const addr = server.address() as AddressInfo; + base = `http://127.0.0.1:${addr.port}`; + }); + + afterEach(async () => { + // Force-close any long-lived SSE sockets a test left open so + // `server.close()` doesn't hang on them. + server.closeAllConnections?.(); + await new Promise((r) => server.close(() => r())); + }); + + async function initialize(): Promise { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize' }), + }); + expect(res.status).toBe(200); + const connId = res.headers.get('acp-connection-id'); + expect(connId).toBeTruthy(); + const body = (await res.json()) as { result: { protocolVersion: number } }; + expect(body.result.protocolVersion).toBe(1); + return connId!; + } + + function post(connId: string, msg: unknown) { + return fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + }, + body: JSON.stringify(msg), + }); + } + + function openStream(connId: string, sessionId?: string) { + const headers: Record = { + accept: 'text/event-stream', + 'acp-connection-id': connId, + }; + if (sessionId) headers['acp-session-id'] = sessionId; + return fetch(`${base}/acp`, { headers }); + } + + // Establish ownership of the fake bridge's session ('sess-1') so the + // ownership-gated session stream + per-session POSTs are allowed. + async function newSession(connId: string, id = 99): Promise { + await post(connId, { + jsonrpc: '2.0', + id, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // let handle() register ownership + } + + it('initialize → 200 + Acp-Connection-Id; unknown conn → 400', async () => { + await initialize(); + const bad = await post('nope', { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + }); + expect(bad.status).toBe(400); + }); + + it('session/new reply rides the connection-scoped stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + // Give the SSE handshake a tick before POSTing. + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 2, + method: 'session/new', + params: { cwd: '/ws' }, + }); + expect(ack.status).toBe(202); + const [frame] = (await got) as Array<{ + id: number; + result: { sessionId: string }; + }>; + expect(frame.id).toBe(2); + expect(frame.result.sessionId).toBe('sess-1'); + }); + + it('prompt streams session/update then the final result', async () => { + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'session_update', + data: { + sessionId: 'sess-1', + update: { sessionUpdate: 'agent_message_chunk' }, + }, + }); + await new Promise((r) => setTimeout(r, 20)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 2); + await new Promise((r) => setTimeout(r, 50)); + const ack = await post(connId, { + jsonrpc: '2.0', + id: 5, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + expect(ack.status).toBe(202); + const frames = (await got) as Array>; + expect(frames[0]['method']).toBe('session/update'); + expect( + (frames[1] as { id: number; result: { stopReason: string } }).id, + ).toBe(5); + expect( + (frames[1] as { result: { stopReason: string } }).result.stopReason, + ).toBe('end_turn'); + }); + + it('permission request round-trips agent→client→agent', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-1', + sessionId: 'sess-1', + toolCall: { name: 'shell' }, + options: [{ optionId: 'allow', name: 'Allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 30)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 7, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'rm' }] }, + }); + const [reqFrame] = (await got) as Array<{ + id: number; + method: string; + params: { _meta: Record }; + }>; + expect(reqFrame.method).toBe('session/request_permission'); + expect(reqFrame.params._meta['qwen'].requestId).toBe('perm-1'); + // Client answers with a JSON-RPC response echoing the issued id. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(resolvedWith).toEqual({ + outcome: { outcome: 'selected', optionId: 'allow' }, + }); + }); + + it('standard session/set_config_option (model) routes to the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 9, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: 'qwen-max' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { configOptions: unknown }; + }>; + expect(frame.id).toBe(9); + expect(bridge.lastSetModel).toMatchObject({ modelId: 'qwen-max' }); + }); + + it('session/set_config_option (mode) routes to setSessionApprovalMode', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 10, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'yolo' }, + }); + await got; + expect(bridge.lastApprovalMode).toBe('yolo'); + }); + + it('_qwen/workspace/mcp introspection reaches the bridge', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 12, + method: '_qwen/workspace/mcp', + }); + const [frame] = (await got) as Array<{ + id: number; + result: { ok: boolean }; + }>; + expect(frame.id).toBe(12); + expect(frame.result.ok).toBe(true); + }); + + it('unknown method → JSON-RPC method-not-found on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { jsonrpc: '2.0', id: 11, method: 'bogus/method' }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32601); + }); + + it('session stream for an unowned session → 403', async () => { + const connId = await initialize(); + // No session/new → connection does not own 'sess-1'. + const res = await openStream(connId, 'sess-1'); + expect(res.status).toBe(403); + }); + + it('prompt for an unowned session → INVALID_PARAMS on conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 13, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('Acp-Session-Id header that disagrees with params.sessionId → INVALID_PARAMS', async () => { + // Cross-check fires before ownership, so no session/new needed (and + // skipping it keeps a buffered session/new reply off the conn stream). + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await fetch(`${base}/acp`, { + method: 'POST', + headers: { + 'content-type': 'application/json', + 'acp-connection-id': connId, + 'acp-session-id': 'sess-1', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 14, + method: 'session/prompt', + params: { sessionId: 'OTHER', prompt: [{ type: 'text', text: 'x' }] }, + }), + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/load owns the session + replies state on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 20, + method: 'session/load', + params: { sessionId: 'loaded-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { replayed: boolean }; + }>; + expect(frame.id).toBe(20); + expect(frame.result.replayed).toBe(true); + // Ownership was granted, so the session stream is now allowed. + const sess = await openStream(connId, 'loaded-1'); + expect(sess.status).toBe(200); + await sess.body?.cancel(); // release the long-lived SSE socket + }); + + it('session/resume owns the session + replies state', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 21, + method: 'session/resume', + params: { sessionId: 'resumed-1' }, + }); + const [frame] = (await got) as Array<{ + id: number; + result: { resumed: boolean }; + }>; + expect(frame.id).toBe(21); + expect(frame.result.resumed).toBe(true); + }); + + it('session/close reaches the bridge + replies on the conn stream', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + // 2 frames: the session/new reply (establishes ownership), then close. + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 99, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 22, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ id: number }>; + expect(frames.map((f) => f.id)).toContain(22); + expect(bridge.closedSessions).toContain('sess-1'); + }); + + it('initialize clamps protocolVersion to [1, 1]', async () => { + for (const [requested, expected] of [ + [0, 1], + [-3, 1], + [99, 1], + ['bad', 1], + ] as Array<[unknown, number]>) { + const res = await fetch(`${base}/acp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method: 'initialize', + params: { protocolVersion: requested }, + }), + }); + const body = (await res.json()) as { + result: { protocolVersion: number }; + }; + expect(body.result.protocolVersion).toBe(expected); + } + }); + + it('session/load failure routes the error to the connection stream', async () => { + bridge.loadShouldThrow = true; + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 30, + method: 'session/load', + params: { sessionId: 'x' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.id).toBe(30); + expect(frame.error.code).toBe(-32603); + }); + + it('connection teardown detaches the session client from the bridge', async () => { + const connId = await initialize(); + await newSession(connId); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await new Promise((r) => setTimeout(r, 20)); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + }); + + it('malformed permission response still releases the bridge (cancel fallback)', async () => { + const votes: Array<{ outcome?: { outcome?: string } }> = []; + // Emulate the real bridge: throw on a vote with no `outcome`. + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + const r = resp as { outcome?: { outcome?: string } }; + if (!r?.outcome?.outcome) throw new Error('invalid permission response'); + votes.push(r); + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-x', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a malformed result (no outcome) → bridge throws → + // fallback must still cancel so the mediator is released. + await post(connId, { jsonrpc: '2.0', id: reqFrame.id, result: {} }); + await new Promise((r) => setTimeout(r, 50)); + expect(votes).toContainEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('a second concurrent prompt aborts the first', async () => { + let firstSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + if (!firstSignal) { + firstSignal = signal; + await new Promise((r) => + signal?.addEventListener('abort', () => r(), { once: true }), + ); + return { stopReason: 'cancelled' }; + } + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const drain = takeFrames(sessStream, 2); // both prompt results + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'a' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'b' }] }, + }); + await drain; + expect(firstSignal?.aborted).toBe(true); + }); + + it('subscribeEvents throwing closes the session stream promptly (no zombie)', async () => { + bridge.subscribeThrows = true; + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + // The guarantee is that the server CLOSES the stream (not a zombie that + // heartbeats forever). A safety abort at 3s distinguishes "server closed" + // (loop ends fast) from "zombie" (only our timeout ends it). + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + // Server-initiated close arrives well under the 3s safety timeout. + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('concurrent session/close calls the bridge exactly once (no TOCTOU double-close)', async () => { + const connId = await initialize(); + await newSession(connId); + await Promise.all([ + post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + post(connId, { + jsonrpc: '2.0', + id: 71, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }), + ]); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions.filter((s) => s === 'sess-1')).toHaveLength(1); + }); + + it('clean iterator end closes the session stream (no zombie)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 50)); + // Subprocess ends cleanly → bridge event iterator returns done. + bridge.queues.get('sess-1')?.end(); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), 3000); + const start = Date.now(); + try { + for await (const _f of readSse(sessStream, ac.signal)) { + // drain + } + } finally { + clearTimeout(timer); + ac.abort(); + } + expect(Date.now() - start).toBeLessThan(1500); + }); + + it('session-stream reconnect does NOT abort the in-flight prompt', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, q, signal) => { + promptSignal = signal; + q.push({ + type: 'session_update', + data: { sessionId: 'sess-1', update: {} }, + }); + await new Promise((r) => setTimeout(r, 200)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const s1 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + // Reconnect: install the NEW stream and let it attach FIRST, then drop the + // old one. This deterministically exercises the invariant under test — + // the old (now-stale) stream's close must NOT abort the prompt because a + // newer stream is already the session's current one (install-before-close + // + identity-guarded onClose). (Attaching s2 before dropping s1 avoids a + // test-only race between s1.close and s2.attach under full-suite load.) + const s2 = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await s1.body?.cancel(); + await new Promise((r) => setTimeout(r, 40)); + // The prompt must survive the reconnect. + expect(promptSignal?.aborted).toBe(false); + await s2.body?.cancel(); + }); + + it('prompt response is delivered even if the session closes mid-flight', async () => { + // Prompt resolves only after we close the session — exercises the + // binding-gone fallback (reply must ride the connection stream). + let release: () => void = () => {}; + bridge.promptBehavior = async (_s, _q) => { + await new Promise((r) => (release = r)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const sessStream = await openStream(connId, 'sess-1'); + // conn stream carries: buffered session/new reply (id 99), the close + // ack (id 91), AND the fallback prompt reply (id 90). + const connFrames = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 90, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 30)); + // Close the session while the prompt is still in flight, then let it resolve. + await post(connId, { + jsonrpc: '2.0', + id: 91, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + release(); + const frames = (await connFrames) as Array<{ id: number }>; + // The prompt's id-90 response must appear (on the conn stream, since the + // session binding is gone) — not silently dropped. + expect(frames.map((f) => f.id)).toContain(90); + await sessStream.body?.cancel(); + }); + + it('session/set_config_option rejects empty value (INVALID_PARAMS)', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 41, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'model', value: '' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/set_config_option rejects an invalid mode value', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 42, + method: 'session/set_config_option', + params: { sessionId: 'sess-1', configId: 'mode', value: 'bogus-mode' }, + }); + const [frame] = (await got) as Array<{ + id: number; + error: { code: number }; + }>; + expect(frame.error.code).toBe(-32602); + expect(bridge.lastApprovalMode).toBeUndefined(); + }); + + it('session/new forwards sessionScope; rejects invalid scope', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 1); + await new Promise((r) => setTimeout(r, 50)); + // invalid scope → error on conn stream + await post(connId, { + jsonrpc: '2.0', + id: 43, + method: 'session/new', + params: { sessionScope: 'bogus' }, + }); + const [bad] = (await got) as Array<{ error: { code: number } }>; + expect(bad.error.code).toBe(-32602); + // valid scope → forwarded to bridge + const c2 = await initialize(); + await post(c2, { + jsonrpc: '2.0', + id: 44, + method: 'session/new', + params: { sessionScope: 'thread' }, + }); + await new Promise((r) => setTimeout(r, 30)); + expect(bridge.lastSpawnScope).toBe('thread'); + }); + + it('session/prompt with empty prompt → INVALID_PARAMS', async () => { + const connId = await initialize(); + await newSession(connId); + const sessStream = await openStream(connId, 'sess-1'); + const got = takeFrames(sessStream, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 45, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [] }, + }); + const [frame] = (await got) as Array<{ error: { code: number } }>; + expect(frame.error.code).toBe(-32602); + }); + + it('session/close runs local cleanup even if the bridge close throws', async () => { + bridge.closeShouldThrow = true; + const connId = await initialize(); + await newSession(connId); // creates + owns sess-1 + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 46, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(bridge.closedSessions).toContain('sess-1'); // bridge was called (then threw) + // Local teardown ran in `finally` despite the throw → session unowned now. + const after = await openStream(connId, 'sess-1'); + expect(after.status).toBe(403); + }); + + it('connection cap → 503 on initialize', async () => { + const app2 = express(); + app2.use(express.json()); + mountAcpHttp(app2, bridge as unknown as HttpAcpBridge, { + boundWorkspace: '/ws', + workspace: fakeWorkspace, + enabled: true, + maxConnections: 1, + }); + const srv = app2.listen(0, '127.0.0.1'); + await new Promise((r) => srv.once('listening', r)); + const port = (srv.address() as AddressInfo).port; + const url = `http://127.0.0.1:${port}/acp`; + const init = (n: number) => + fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: n, method: 'initialize' }), + }); + const r1 = await init(1); + expect(r1.status).toBe(200); + const r2 = await init(2); + expect(r2.status).toBe(503); + expect(r2.headers.get('retry-after')).toBe('5'); + srv.closeAllConnections?.(); + await new Promise((r) => srv.close(() => r())); + }); + + it('session/cancel aborts the in-flight prompt and calls the bridge', async () => { + let promptSignal: AbortSignal | undefined; + bridge.promptBehavior = async (_s, _q, signal) => { + promptSignal = signal; + await new Promise((r) => setTimeout(r, 300)); + return { stopReason: 'cancelled' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 50, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'hi' }] }, + }); + await new Promise((r) => setTimeout(r, 40)); + await post(connId, { + jsonrpc: '2.0', + id: 51, + method: 'session/cancel', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 40)); + expect(promptSignal?.aborted).toBe(true); + expect(bridge.cancelled).toContain('sess-1'); + await sess.body?.cancel(); + }); + + it('session/new rejects bad cwd (non-string + relative) → INVALID_PARAMS', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 60, + method: 'session/new', + params: { cwd: 123 }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 61, + method: 'session/new', + params: { cwd: 'rel/path' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + }>; + for (const f of frames) expect(f.error?.code).toBe(-32602); + }); + + it('session/new orphan: DELETE before spawn resolves → bridge.killSession', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 70, + method: 'session/new', + params: {}, + }); + await new Promise((r) => setTimeout(r, 30)); // spawnOrAttach now awaiting the gate + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); // spawn resolves AFTER destroy + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + }); + + it('session/load orphan (attached:false) → killSession, not detach', async () => { + let release: () => void = () => {}; + bridge.gate = new Promise((r) => (release = r)); + bridge.loadAttached = false; // restore SPAWNED from disk → must be killed + const connId = await initialize(); + await post(connId, { + jsonrpc: '2.0', + id: 80, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + release(); + await new Promise((r) => setTimeout(r, 40)); + expect(bridge.killed).toContain('sess-1'); + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(false); + }); + + it('_qwen/* introspection methods reach the bridge (conn-routed)', async () => { + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + // 4 frames: buffered session/new reply (id 99) + the 3 below. + const got = takeFrames(connStream, 4); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 200, + method: '_qwen/session/context', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 201, + method: '_qwen/session/heartbeat', + params: { sessionId: 'sess-1' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 202, + method: '_qwen/workspace/skills', + }); + const ids = ((await got) as Array<{ id?: number }>).map((f) => f.id); + expect(ids).toEqual(expect.arrayContaining([200, 201, 202])); + }); + + it('_qwen/workspace/set_tool_enabled + restart_mcp_server validate name', async () => { + const connId = await initialize(); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 3); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 210, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: '', enabled: true }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 211, + method: '_qwen/workspace/restart_mcp_server', + params: { serverName: '' }, + }); + await post(connId, { + jsonrpc: '2.0', + id: 212, + method: '_qwen/workspace/set_tool_enabled', + params: { toolName: 'shell', enabled: false }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number }; + result?: unknown; + }>; + const byId = Object.fromEntries(frames.map((f) => [f.id, f])); + expect(byId[210].error?.code).toBe(-32602); + expect(byId[211].error?.code).toBe(-32602); + expect(byId[212].result).toBeDefined(); + }); + + it('translateEvent: stream_error + client_evicted → _qwen/notify with kind', async () => { + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 2); + await new Promise((r) => setTimeout(r, 50)); + const q = bridge.queues.get('sess-1'); + q?.push({ type: 'stream_error', data: { error: 'boom' } }); + q?.push({ type: 'client_evicted', data: { reason: 'slow' } }); + const frames = (await got) as Array<{ + method: string; + params: { kind: string }; + }>; + expect(frames.every((f) => f.method === '_qwen/notify')).toBe(true); + const kinds = frames.map((f) => f.params.kind); + expect(kinds).toEqual( + expect.arrayContaining(['stream_error', 'client_evicted']), + ); + // (takeFrames already locked + aborted `sess`; afterEach force-closes.) + }); + + it('session/load while a session/close is in-flight → rejected (TOCTOU guard)', async () => { + let releaseClose: () => void = () => {}; + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connId = await initialize(); + await newSession(connId); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // close is now in flight (awaiting closeGate) → sess-1 is "closing". + void post(connId, { + jsonrpc: '2.0', + id: 300, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 30)); + await post(connId, { + jsonrpc: '2.0', + id: 301, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 301); + // Transient server-side race → INTERNAL_ERROR (-32603), not INVALID_PARAMS. + expect(loadReply?.error?.code).toBe(-32603); // "being closed; retry" + expect(loadReply?.error?.message).toContain('being closed'); + releaseClose(); + }); + + it('session/load while close races DURING loadSession → post-await reject + rollback', async () => { + // Distinct from the pre-await guard above: here the pre-await + // `closingSessions` check passes, then a `session/close` for the same id + // starts WHILE `loadSession` is awaiting. The post-await re-check + // (dispatch.ts) must detect `closeRaced`, roll back the just-restored + // attach (detachClient, since loadAttached=true), and reply INTERNAL_ERROR. + let releaseLoad: () => void = () => {}; + let releaseClose: () => void = () => {}; + const connId = await initialize(); + await newSession(connId); // own sess-1 so session/close passes requireOwned + // Arm the gates only AFTER ownership is established — otherwise newSession's + // own spawnOrAttach would block on bridge.gate and never grant ownership. + bridge.gate = new Promise((r) => (releaseLoad = r)); + bridge.closeGate = new Promise((r) => (releaseClose = r)); + const connStream = await openStream(connId); + const got = takeFrames(connStream, 2); // buffered session/new reply + load reject + await new Promise((r) => setTimeout(r, 50)); + // Load goes in-flight (awaits bridge.gate); pre-await closingSessions empty. + void post(connId, { + jsonrpc: '2.0', + id: 340, + method: 'session/load', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + // Close starts DURING the load → marks sess-1 closing (awaits closeGate). + void post(connId, { + jsonrpc: '2.0', + id: 341, + method: 'session/close', + params: { sessionId: 'sess-1' }, + }); + await new Promise((r) => setTimeout(r, 20)); + releaseLoad(); // loadSession resolves → post-await sees closeRaced + const frames = (await got) as Array<{ + id: number; + error?: { code: number; message: string }; + }>; + const loadReply = frames.find((f) => f.id === 340); + expect(loadReply?.error?.code).toBe(-32603); + expect(loadReply?.error?.message).toContain('closed during load'); + // attached:true → rollback is a detach, NOT a kill. + expect(bridge.detached.some((d) => d.sessionId === 'sess-1')).toBe(true); + expect(bridge.killed).not.toContain('sess-1'); + releaseClose(); + }); + + it('double-failure permission vote → pending retained + retried on teardown', async () => { + // Core R14 invariant: when BOTH the vote and the immediate cancel throw a + // non-"not found" error, resolveClientResponse must RETAIN the pending + // entry so connection teardown's abandonPendingForSession can retry the + // cancel (otherwise the bridge mediator is stuck forever). Retention is + // observable as a SECOND cancel attempt during teardown. + const calls: unknown[] = []; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + calls.push(resp); + throw new Error('mediator unavailable'); // vote AND every cancel fail + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-d', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 100)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 350, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Vote → respondToSessionPermission throws → immediate cancel ALSO throws. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + result: { outcome: { outcome: 'selected', optionId: 'allow' } }, + }); + await new Promise((r) => setTimeout(r, 40)); + // Teardown retries the cancel — whether triggered by the session stream + // closing or the explicit DELETE below. Either way it only happens if the + // entry was RETAINED after the immediate cancel failed. + await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + await new Promise((r) => setTimeout(r, 40)); + const cancels = calls.filter((c) => + JSON.stringify(c).includes('cancelled'), + ); + // 1 vote + ≥2 cancels (immediate fail + teardown retry). If the entry were + // dropped unconditionally after the failed immediate cancel, there would be + // exactly ONE cancel — so ≥2 is the retention invariant. + expect(cancels.length).toBeGreaterThanOrEqual(2); + expect(calls.length).toBeGreaterThanOrEqual(3); + }); + + it('client error response to a permission request → cancellation', async () => { + let resolvedWith: unknown; + bridge.respondToSessionPermission = (( + _s: string, + _r: string, + resp: unknown, + ) => { + resolvedWith = resp; + return true; + }) as never; + bridge.promptBehavior = async (_s, q) => { + q.push({ + type: 'permission_request', + data: { + requestId: 'perm-e', + sessionId: 'sess-1', + toolCall: {}, + options: [{ optionId: 'allow' }], + }, + }); + await new Promise((r) => setTimeout(r, 40)); + return { stopReason: 'end_turn' }; + }; + const connId = await initialize(); + await newSession(connId); + const sess = await openStream(connId, 'sess-1'); + const got = takeFrames(sess, 1); + await new Promise((r) => setTimeout(r, 50)); + await post(connId, { + jsonrpc: '2.0', + id: 310, + method: 'session/prompt', + params: { sessionId: 'sess-1', prompt: [{ type: 'text', text: 'x' }] }, + }); + const [reqFrame] = (await got) as Array<{ id: string }>; + // Client answers with a JSON-RPC ERROR (not result) → treated as cancel. + await post(connId, { + jsonrpc: '2.0', + id: reqFrame.id, + error: { code: -32000, message: 'user declined' }, + }); + await new Promise((r) => setTimeout(r, 50)); + expect(resolvedWith).toEqual({ outcome: { outcome: 'cancelled' } }); + }); + + it('DELETE without a connection id → 400', async () => { + const res = await fetch(`${base}/acp`, { method: 'DELETE' }); + expect(res.status).toBe(400); + }); + + it('DELETE tears the connection down (subsequent POST 400)', async () => { + const connId = await initialize(); + const del = await fetch(`${base}/acp`, { + method: 'DELETE', + headers: { 'acp-connection-id': connId }, + }); + expect(del.status).toBe(202); + const after = await post(connId, { + jsonrpc: '2.0', + id: 12, + method: 'session/new', + }); + expect(after.status).toBe(400); + }); +}); diff --git a/packages/cli/src/serve/httpAcpBridge.ts b/packages/cli/src/serve/acpSessionBridge.ts similarity index 90% rename from packages/cli/src/serve/httpAcpBridge.ts rename to packages/cli/src/serve/acpSessionBridge.ts index 36f0564ce65..72483a15ece 100644 --- a/packages/cli/src/serve/httpAcpBridge.ts +++ b/packages/cli/src/serve/acpSessionBridge.ts @@ -8,22 +8,22 @@ * Stage 1 HTTP→ACP bridge — backward-compat re-export shim. * * #4175 PR F1 lifted the bridge core (`BridgeClient`, - * `defaultSpawnChannelFactory`, `createHttpAcpBridge` factory closure, + * `defaultSpawnChannelFactory`, `createAcpSessionBridge` factory closure, * plus the supporting types/errors/options/status) to * `@qwen-code/acp-bridge`. This shim preserves every existing relative - * import path (`./httpAcpBridge.js`) so `server.ts`, `runQwenServe.ts`, + * import path (`./acpSessionBridge.js`) so `server.ts`, `runQwenServe.ts`, * `workspaceAgents.ts`, `workspaceMemory.ts`, `index.ts`, plus the * bridge test suite, keep resolving without any call-site changes. * * The implementation now lives at: - * - `@qwen-code/acp-bridge/bridge` — `createHttpAcpBridge` factory + * - `@qwen-code/acp-bridge/bridge` — `createAcpSessionBridge` factory * - `@qwen-code/acp-bridge/bridgeClient` — `BridgeClient` class + * permission record types * - `@qwen-code/acp-bridge/spawnChannel` — `defaultSpawnChannelFactory` * - `@qwen-code/acp-bridge/bridgeOptions` — `BridgeOptions` + * `DaemonStatusProvider` interfaces * - `@qwen-code/acp-bridge/bridgeTypes` — bridge session + heartbeat - * types + `HttpAcpBridge` interface + * types + `AcpSessionBridge` interface * - `@qwen-code/acp-bridge/bridgeErrors` — typed bridge error classes * - `@qwen-code/acp-bridge/workspacePaths` — `canonicalizeWorkspace` * + `MAX_WORKSPACE_PATH_LENGTH` @@ -37,7 +37,7 @@ * in the lifted package for the full Stage 1/Stage 2 contract. */ -export { createHttpAcpBridge } from '@qwen-code/acp-bridge/bridge'; +export { createAcpSessionBridge, createHttpAcpBridge } from '@qwen-code/acp-bridge/bridge'; export { defaultSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel'; // Wenshao review #4335 / 3272581548 — `MAX_RESOLVED_PERMISSION_RECORDS`, // `PendingPermission`, `PermissionResolutionRecord` re-exports @@ -71,6 +71,7 @@ export type { BridgeClientRequestContext, BridgeHeartbeatResult, BridgeHeartbeatState, + AcpSessionBridge, HttpAcpBridge, } from '@qwen-code/acp-bridge/bridgeTypes'; diff --git a/packages/cli/src/serve/capabilities.ts b/packages/cli/src/serve/capabilities.ts index 52e7e5282c2..bf5420ee4e4 100644 --- a/packages/cli/src/serve/capabilities.ts +++ b/packages/cli/src/serve/capabilities.ts @@ -80,7 +80,9 @@ export const SERVE_CAPABILITY_REGISTRY = { workspace_env: { since: 'v1' }, workspace_preflight: { since: 'v1' }, session_context: { since: 'v1' }, + session_context_usage: { since: 'v1' }, session_supported_commands: { since: 'v1' }, + session_tasks: { since: 'v1' }, session_close: { since: 'v1' }, session_metadata: { since: 'v1' }, // Issue #4175 PR 14. Daemon supports the MCP client guardrail @@ -227,6 +229,7 @@ export const SERVE_CAPABILITY_REGISTRY = { }, prompt_absolute_deadline: { since: 'v1' }, writer_idle_timeout: { since: 'v1' }, + non_blocking_prompt: { since: 'v1' }, } as const satisfies Record; export type ServeFeature = keyof typeof SERVE_CAPABILITY_REGISTRY; diff --git a/packages/cli/src/serve/daemonStatusProvider.test.ts b/packages/cli/src/serve/daemonStatusProvider.test.ts index bec3e2fe4a6..d1e68a63143 100644 --- a/packages/cli/src/serve/daemonStatusProvider.test.ts +++ b/packages/cli/src/serve/daemonStatusProvider.test.ts @@ -6,66 +6,77 @@ /** * Daemon-host integration tests for the `DaemonStatusProvider` seam - * introduced in #4175 PR 22b/2. Carved out of the lifted - * `bridge.test.ts` suite during the #4175 F1 test split (deferred - * from #4334): the bulk of that 6861-line suite is pure bridge - * behavior that lives in `@qwen-code/acp-bridge` now, but these 4 - * tests must stay in cli because they wire `createDaemonStatusProvider()` - * — the daemon-host-specific cells that scan `$PATH` for git/npm/rg - * and read `process.env`. acp-bridge has no view into that and its - * tests exercise the no-provider / throwing-provider fallback paths - * instead. - * - * Importing `createHttpAcpBridge` via the `./httpAcpBridge.js` - * re-export shim (rather than directly from `@qwen-code/acp-bridge`) - * also acts as a smoke check that the shim's surface stays in sync - * with the lifted factory. + * introduced in #4175 PR 22b/2. Rewritten to exercise the + * `DaemonWorkspaceService` facade (which now owns env + preflight + * status) rather than the removed bridge methods. The tests verify + * that `createDaemonStatusProvider()` cells flow correctly through + * the workspace service layer — the daemon-host-specific cells that + * scan `$PATH` for git/npm/rg and read `process.env`. */ import { describe, it, expect } from 'vitest'; -import { - createHttpAcpBridge, - type BridgeOptions, - type HttpAcpBridge, -} from './httpAcpBridge.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; -import { - type ChannelHandle, - makeChannel, - WS_A, -} from '@qwen-code/acp-bridge/internal/testUtils'; +import { createDaemonWorkspaceService } from './workspace-service/index.js'; +import type { + DaemonWorkspaceServiceDeps, + WorkspaceRequestContext, +} from './workspace-service/types.js'; +import { WS_A } from '@qwen-code/acp-bridge/internal/testUtils'; /** - * Cli-side bridge factory wired to the real - * `createDaemonStatusProvider()`. Distinct name + JSDoc from - * `testUtils.makeBridge` (which omits the provider for the - * no-provider fallback assertions in `bridge.test.ts`) so a - * contributor adding a test can't pick the wrong helper by accident - * — wenshao review #4445 thread. + * Minimal request context for status queries. */ -function makeBridgeWithDaemonStatusProvider( - opts: Partial = {}, -): HttpAcpBridge { - return createHttpAcpBridge({ +const CTX: WorkspaceRequestContext = { + route: 'GET /workspace/status', + workspaceCwd: WS_A, +}; + +/** + * Build a workspace service wired to the real `createDaemonStatusProvider()` + * with a configurable `queryWorkspaceStatus` and `isChannelLive` for + * controlling the ACP simulation layer. + */ +function makeWorkspaceServiceWithProvider( + opts: { + isChannelLive?: () => boolean; + queryWorkspaceStatus?: DaemonWorkspaceServiceDeps['queryWorkspaceStatus']; + } = {}, +) { + const statusProvider = createDaemonStatusProvider(); + const noopQueryWorkspaceStatus: DaemonWorkspaceServiceDeps['queryWorkspaceStatus'] = + async (_method, idle) => idle(); + + return createDaemonWorkspaceService({ boundWorkspace: WS_A, - statusProvider: createDaemonStatusProvider(), - ...opts, + contextFilename: 'QWEN.md', + fsFactory: undefined as never, // Not exercised in status tests. + deviceFlowRegistry: undefined, + subagentManager: undefined, + statusProvider, + isChannelLive: opts.isChannelLive ?? (() => false), + persistDisabledTools: async () => {}, + queryWorkspaceStatus: opts.queryWorkspaceStatus ?? noopQueryWorkspaceStatus, + invokeWorkspaceCommand: async () => { + throw new Error('not wired'); + }, + publishWorkspaceEvent: () => {}, + knownClientIds: () => new Set(), }); } -describe('createHttpAcpBridge — daemon-host status provider integration', () => { +describe('DaemonWorkspaceService — daemon-host status provider integration', () => { it('answers /workspace/env from process state without consulting ACP, idle or live', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridgeWithDaemonStatusProvider({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; + let queryCount = 0; + const service = makeWorkspaceServiceWithProvider({ + isChannelLive: () => false, + queryWorkspaceStatus: async (_method, idle) => { + queryCount++; + return idle(); }, }); - // Idle path — daemon answers env from `process.*`; no ACP child spawn. - const idle = await bridge.getWorkspaceEnvStatus(); + // Idle path — daemon answers env from `process.*`; no ACP query. + const idle = await service.getWorkspaceEnvStatus(CTX); expect(idle).toMatchObject({ v: 1, workspaceCwd: WS_A, @@ -73,34 +84,31 @@ describe('createHttpAcpBridge — daemon-host status provider integration', () = acpChannelLive: false, }); expect(idle.cells.length).toBeGreaterThan(0); - expect(handles).toHaveLength(0); - - // Live path — bridge still answers locally; the ACP child sees no - // ext-method invocation for env. - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const live = await bridge.getWorkspaceEnvStatus(); + // Env status is purely daemon-local — queryWorkspaceStatus must NOT be called. + expect(queryCount).toBe(0); + + // Live path — workspace service still answers locally; no ACP round-trip. + const liveService = makeWorkspaceServiceWithProvider({ + isChannelLive: () => true, + queryWorkspaceStatus: async (_method, idle) => { + queryCount++; + return idle(); + }, + }); + queryCount = 0; + const live = await liveService.getWorkspaceEnvStatus(CTX); expect(live.acpChannelLive).toBe(true); - expect(handles).toHaveLength(1); - expect( - handles[0]?.agent.extMethodCalls.some((c) => - c.method.includes('/workspace/env'), - ), - ).toBe(false); - - await bridge.shutdown(); + expect(live.cells.length).toBeGreaterThan(0); + // Still no ACP query — env is always daemon-local. + expect(queryCount).toBe(0); }); it('returns daemon preflight cells with not_started ACP cells when idle', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridgeWithDaemonStatusProvider({ - channelFactory: async () => { - const h = makeChannel(); - handles.push(h); - return h.channel; - }, + const service = makeWorkspaceServiceWithProvider({ + isChannelLive: () => false, }); - const status = await bridge.getWorkspacePreflightStatus(); + const status = await service.getWorkspacePreflightStatus(CTX); expect(status).toMatchObject({ v: 1, workspaceCwd: WS_A, @@ -136,13 +144,9 @@ describe('createHttpAcpBridge — daemon-host status provider integration', () = for (const cell of acpCells) { expect(cell.status).toBe('not_started'); } - - expect(handles).toHaveLength(0); - await bridge.shutdown(); }); it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => { - const handles: ChannelHandle[] = []; const acpCells = [ { kind: 'auth', status: 'ok', locality: 'acp' }, { kind: 'mcp_discovery', status: 'ok', locality: 'acp' }, @@ -151,23 +155,17 @@ describe('createHttpAcpBridge — daemon-host status provider integration', () = { kind: 'tool_registry', status: 'ok', locality: 'acp' }, { kind: 'egress', status: 'not_started', locality: 'acp' }, ]; - const bridge = makeBridgeWithDaemonStatusProvider({ - channelFactory: async () => { - const h = makeChannel({ - extMethodImpl: (method) => { - if (method === 'qwen/status/workspace/preflight') { - return { cells: acpCells }; - } - return { cells: [] }; - }, - }); - handles.push(h); - return h.channel; - }, + const service = makeWorkspaceServiceWithProvider({ + isChannelLive: () => true, + queryWorkspaceStatus: (async (method: string, idle: () => unknown) => { + if (method === 'qwen/status/workspace/preflight') { + return { cells: acpCells }; + } + return idle(); + }) as DaemonWorkspaceServiceDeps['queryWorkspaceStatus'], }); - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const status = await bridge.getWorkspacePreflightStatus(); + const status = await service.getWorkspacePreflightStatus(CTX); expect(status.acpChannelLive).toBe(true); // Daemon cells precede ACP cells in the merged response. const daemonKinds = status.cells @@ -193,52 +191,34 @@ describe('createHttpAcpBridge — daemon-host status provider integration', () = ['egress', 'not_started'], ]); expect(status.errors).toBeUndefined(); - - await bridge.shutdown(); }); - it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => { - const handles: ChannelHandle[] = []; - const bridge = makeBridgeWithDaemonStatusProvider({ - channelFactory: async () => { - const h = makeChannel({ - extMethodImpl: () => { - throw new Error('agent channel closed mid-request'); - }, - }); - handles.push(h); - return h.channel; + it('falls back to idle ACP cells + envelope error when queryWorkspaceStatus throws mid-preflight', async () => { + const service = makeWorkspaceServiceWithProvider({ + isChannelLive: () => true, + queryWorkspaceStatus: async () => { + throw new Error('agent channel closed mid-request'); }, }); - await bridge.spawnOrAttach({ workspaceCwd: WS_A }); - const status = await bridge.getWorkspacePreflightStatus(); + const status = await service.getWorkspacePreflightStatus(CTX); // Daemon cells must still render — that's the route's resilience contract. const daemonKinds = status.cells .filter((c) => c.locality === 'daemon') .map((c) => c.kind); expect(daemonKinds.length).toBeGreaterThan(0); - // ACP cells fall back to `not_started` placeholders since the extMethod - // call rejected. + // ACP cells fall back to `not_started` placeholders since the query rejected. const acpCells = status.cells.filter((c) => c.locality === 'acp'); expect(acpCells.length).toBe(6); for (const cell of acpCells) { expect(cell.status).toBe('not_started'); } - // The envelope's `errors` array carries the bridge-side failure - // describing which surface failed without sinking the whole route. - // `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the - // ACP SDK wraps the inner throw as a generic JSON-RPC "Internal - // error" which doesn't match any of the helper's recognition rules - // (the typed `BridgeChannelClosedError` follow-up will close that - // gap), so we only assert the structural shape, not the tag. + // The envelope's `errors` array carries the failure description. expect(status.errors).toBeDefined(); expect(status.errors![0]).toMatchObject({ kind: 'preflight', status: 'error', }); - expect(status.errors![0].error).toBeTruthy(); - - await bridge.shutdown(); + expect(status.errors![0]!.error).toBeTruthy(); }); }); diff --git a/packages/cli/src/serve/index.ts b/packages/cli/src/serve/index.ts index 0f5b7a98142..28f5f65532b 100644 --- a/packages/cli/src/serve/index.ts +++ b/packages/cli/src/serve/index.ts @@ -56,7 +56,14 @@ export { type ServePreflightCell, type ServePreflightKind, type ServeSessionContextStatus, + type ServeSessionAgentTaskStatus, + type ServeSessionMonitorTaskStatus, + type ServeSessionProcessTaskLifecycleStatus, + type ServeSessionShellTaskStatus, type ServeSessionSupportedCommandsStatus, + type ServeSessionTaskLifecycleStatus, + type ServeSessionTaskStatus, + type ServeSessionTasksStatus, type ServeSkillLevel, type ServeStatus, type ServeStatusCell, @@ -86,6 +93,7 @@ export { type MutationGateOptions, } from './auth.js'; export { + createAcpSessionBridge, createHttpAcpBridge, defaultSpawnChannelFactory, // #4297 fold-in 1 (16:32:44-round S2): export every typed error @@ -93,7 +101,7 @@ export { // embeds that want to recognize these errors (parallel to how // they already match `WorkspaceInitConflictError` / // `SessionNotFoundError`) need them on the public barrel; without - // this they have to deep-import `./httpAcpBridge.js`. + // this they have to deep-import `./acpSessionBridge.js`. McpServerNotFoundError, McpServerRestartFailedError, SessionNotFoundError, @@ -102,12 +110,13 @@ export { WorkspaceInitSymlinkError, WorkspaceInitRaceError, type AcpChannel, + type AcpSessionBridge, type BridgeOptions, type BridgeSession, type BridgeSpawnRequest, type ChannelFactory, type HttpAcpBridge, -} from './httpAcpBridge.js'; +} from './acpSessionBridge.js'; export { EventBus, EVENT_SCHEMA_VERSION, diff --git a/packages/cli/src/serve/permissionAudit.ts b/packages/cli/src/serve/permissionAudit.ts index e0b9e6fbf78..97368620ecd 100644 --- a/packages/cli/src/serve/permissionAudit.ts +++ b/packages/cli/src/serve/permissionAudit.ts @@ -15,12 +15,12 @@ * intentionally separate channels per the F3 plan. * * v1 does not expose a `GET /workspace/permission/audit` route — the - * ring is held inside `createHttpAcpBridge`'s closure for future query + * ring is held inside `createAcpSessionBridge`'s closure for future query * infrastructure. This file provides the writer; the bridge factory * constructs the ring (only when `BridgeOptions.permissionAudit` is * omitted; a host-supplied publisher takes the ring's place) and * wires it to the publisher. The ring is NOT exposed on the - * `HttpAcpBridge` interface today — a follow-up PR adding + * `AcpSessionBridge` interface today — a follow-up PR adding * `GET /workspace/permission/audit` will need to surface it via a new * accessor or pass it through `BridgeOptions`. * diff --git a/packages/cli/src/serve/routes/workspaceFileWrite.ts b/packages/cli/src/serve/routes/workspaceFileWrite.ts index a746aae0894..05b49ab5d20 100644 --- a/packages/cli/src/serve/routes/workspaceFileWrite.ts +++ b/packages/cli/src/serve/routes/workspaceFileWrite.ts @@ -5,7 +5,7 @@ */ import type { Application, Request, RequestHandler, Response } from 'express'; -import type { HttpAcpBridge } from '../httpAcpBridge.js'; +import type { AcpSessionBridge } from '../acpSessionBridge.js'; import { isContentHash, type ContentHash, @@ -19,7 +19,7 @@ import { } from './workspaceFileRead.js'; interface RegisterDeps { - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; mutate: (opts?: { strict?: boolean }) => RequestHandler; parseClientId: (req: Request, res: Response) => string | undefined | null; safeBody: (req: Request) => Record; diff --git a/packages/cli/src/serve/runQwenServe.test.ts b/packages/cli/src/serve/runQwenServe.test.ts index aa4f1925bb3..14f1e8570fc 100644 --- a/packages/cli/src/serve/runQwenServe.test.ts +++ b/packages/cli/src/serve/runQwenServe.test.ts @@ -14,7 +14,7 @@ import { runQwenServe, validatePolicyConfig, } from './runQwenServe.js'; -import type { HttpAcpBridge } from './httpAcpBridge.js'; +import type { HttpAcpBridge } from './acpSessionBridge.js'; /** * #4297 fold-in 7 (deepseek S1, addresses #3262690842). Lock the diff --git a/packages/cli/src/serve/runQwenServe.ts b/packages/cli/src/serve/runQwenServe.ts index cc4f5dd911b..6ad40f63b55 100644 --- a/packages/cli/src/serve/runQwenServe.ts +++ b/packages/cli/src/serve/runQwenServe.ts @@ -13,9 +13,20 @@ import { getDeviceFlowRegistry } from './auth/deviceFlow.js'; import { loadSettings, SettingScope } from '../config/settings.js'; import { canonicalizeWorkspace, - createHttpAcpBridge, - type HttpAcpBridge, -} from './httpAcpBridge.js'; + createAcpSessionBridge, + type AcpSessionBridge, +} from './acpSessionBridge.js'; +import { + DEFAULT_OTLP_ENDPOINT, + DEFAULT_TELEMETRY_TARGET, + createDaemonBridgeTelemetry, + hashDaemonWorkspace, + initializeTelemetry, + resolveTelemetrySettings, + shutdownTelemetry, + type TelemetryRuntimeConfig, + type TelemetrySettings, +} from '@qwen-code/qwen-code-core'; import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isLoopbackBind } from './loopbackBinds.js'; @@ -27,10 +38,12 @@ import { import { createServeApp, resolveBridgeFsFactory } from './server.js'; import { initDaemonLogger, type DaemonLogger } from './daemonLogger.js'; import { createSpawnChannelFactory } from '@qwen-code/acp-bridge/spawnChannel'; +import { createDaemonWorkspaceService } from './workspace-service/index.js'; import { SERVE_CAPABILITY_REGISTRY } from './capabilities.js'; import type { ServeOptions } from './types.js'; import type { WorkspaceFileSystemFactory } from './fs/index.js'; import type { PermissionPolicy } from '@qwen-code/acp-bridge'; +import { getCliVersion } from '../utils/version.js'; const QWEN_SERVER_TOKEN_ENV = 'QWEN_SERVER_TOKEN'; const QWEN_SERVE_PROMPT_DEADLINE_MS_ENV = 'QWEN_SERVE_PROMPT_DEADLINE_MS'; @@ -83,6 +96,33 @@ function parseDeadlineEnv( return parsed; } +function createDaemonTelemetryRuntimeConfig( + telemetry: TelemetrySettings, + cliVersion: string, + daemonSessionId: string, +): TelemetryRuntimeConfig { + return { + getTelemetryEnabled: () => telemetry.enabled ?? false, + getTelemetryOtlpEndpoint: () => + telemetry.otlpEndpoint ?? DEFAULT_OTLP_ENDPOINT, + getTelemetryOtlpProtocol: () => telemetry.otlpProtocol ?? 'grpc', + getTelemetryOtlpTracesEndpoint: () => telemetry.otlpTracesEndpoint, + getTelemetryOtlpLogsEndpoint: () => telemetry.otlpLogsEndpoint, + getTelemetryOtlpMetricsEndpoint: () => telemetry.otlpMetricsEndpoint, + getTelemetryTarget: () => telemetry.target ?? DEFAULT_TELEMETRY_TARGET, + getTelemetryOutfile: () => telemetry.outfile, + getTelemetryIncludeSensitiveSpanAttributes: () => + telemetry.includeSensitiveSpanAttributes ?? false, + getTelemetryResourceAttributes: () => telemetry.resourceAttributes ?? {}, + getTelemetryMetricsIncludeSessionId: () => + telemetry.metrics?.includeSessionId ?? false, + getTelemetryResourceAttributeWarnings: () => + telemetry.resourceAttributeWarnings ?? [], + getCliVersion: () => cliVersion, + getSessionId: () => daemonSessionId, + }; +} + /** * Wenshao review #4335 / 3271978374 — boot-time policy validation * errors. Replaces the previous substring-matching of "invalid @@ -294,14 +334,14 @@ function withSettingsLock( export interface RunHandle { server: Server; url: string; - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; /** Resolves when the listener has fully closed and the bridge is drained. */ close(): Promise; } export interface RunQwenServeDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ - bridge?: HttpAcpBridge; + bridge?: AcpSessionBridge; /** * Workspace filesystem factory (#4175 PR 19). When omitted, * `runQwenServe` constructs one using `boundWorkspace`, @@ -624,8 +664,9 @@ export async function runQwenServe( let contextFilenameForInit: string | undefined; let permissionPolicy: PermissionPolicy | undefined; let permissionConsensusQuorum: number | undefined; + let bootSettings: ReturnType | undefined; try { - const bootSettings = loadSettings(boundWorkspace); + bootSettings = loadSettings(boundWorkspace); contextFilenameForInit = extractContextFilename( bootSettings.merged.context?.fileName, ); @@ -660,6 +701,20 @@ export async function runQwenServe( ); } + const daemonWorkspaceHash = hashDaemonWorkspace(boundWorkspace); + const daemonTelemetrySettings = await resolveTelemetrySettings({ + env: process.env, + settings: bootSettings?.merged.telemetry, + }); + initializeTelemetry( + createDaemonTelemetryRuntimeConfig( + daemonTelemetrySettings, + await getCliVersion(), + `daemon:${daemonWorkspaceHash}:${process.pid}`, + ), + ); + const daemonTelemetry = createDaemonBridgeTelemetry(); + // F3 Commit 2 — allocate the audit ring + publisher in the daemon // host (here) rather than inside the bridge factory, because the // ring is the seam future PRs will lift up to expose `GET @@ -699,9 +754,36 @@ export async function runQwenServe( onDiagnosticLine: diagnosticSink, }); + const persistDisabledToolsFn = ( + workspace: string, + toolName: string, + enabled: boolean, + ): Promise => + withSettingsLock(workspace, async () => { + const fresh = loadSettings(workspace); + const wsScope = fresh.forScope(SettingScope.Workspace).settings; + const wsDisabled = wsScope.tools?.disabled; + const current = Array.isArray(wsDisabled) + ? wsDisabled.filter((v): v is string => typeof v === 'string') + : []; + const next = new Set(current); + if (enabled) next.delete(toolName); + else next.add(toolName); + fresh.setValue( + SettingScope.Workspace, + 'tools.disabled', + [...next].sort(), + ); + }); + + // Create the status provider once — shared between bridge and workspace + // service so both answer env/preflight cells from the same daemon-local + // implementation. + const statusProvider = createDaemonStatusProvider(); + const bridge = deps.bridge ?? - createHttpAcpBridge({ + createAcpSessionBridge({ maxSessions: opts.maxSessions, ...(opts.eventRingSize !== undefined ? { eventRingSize: opts.eventRingSize } @@ -710,6 +792,7 @@ export async function runQwenServe( childEnvOverrides, channelFactory, onDiagnosticLine: diagnosticSink, + telemetry: daemonTelemetry, // F3 Commit 5 — wire the validated policy/quorum from // settings into the bridge. Bridge factory does its own // defensive `Number.isInteger` recheck on the quorum so a @@ -724,16 +807,13 @@ export async function runQwenServe( // instead of importing daemon-host helpers directly. Production // implementation wraps `buildEnvStatusFromProcess` and the // (lifted) `buildDaemonPreflightCells` body. - statusProvider: createDaemonStatusProvider(), + statusProvider, // F1 follow-up (#4319): inject the WorkspaceFileSystem adapter so // agent ACP `writeTextFile` / `readTextFile` calls go through // PR 18's defensive fs layer (trust gate + atomic write + symlink // resolution + audit emit) instead of `BridgeClient`'s inline // raw-fs proxy. Closes the `ws.ts:613` follow-up thread. fileSystem: createBridgeFileSystemAdapter(fsFactory), - ...(contextFilenameForInit !== undefined - ? { contextFilename: contextFilenameForInit } - : {}), // #4175 Wave 4 PR 17: `POST /session/:id/approval-mode` accepts // an opt-in `persist: true` flag. We re-load settings on each // persist call rather than caching a `LoadedSettings` handle — @@ -770,24 +850,40 @@ export async function runQwenServe( // toggle. Subsequent removals at the originating scope (e.g. // User) would no longer take effect because the names have been // baked into the workspace file with no obvious source. - persistDisabledTools: (workspace, toolName, enabled) => - withSettingsLock(workspace, async () => { - const fresh = loadSettings(workspace); - const wsScope = fresh.forScope(SettingScope.Workspace).settings; - const wsDisabled = wsScope.tools?.disabled; - const current = Array.isArray(wsDisabled) - ? wsDisabled.filter((v): v is string => typeof v === 'string') - : []; - const next = new Set(current); - if (enabled) next.delete(toolName); - else next.add(toolName); - fresh.setValue( - SettingScope.Workspace, - 'tools.disabled', - [...next].sort(), - ); - }), + persistDisabledTools: persistDisabledToolsFn, }); + + // Construct the DaemonWorkspaceService AFTER the bridge so it can + // close over the bridge's generic delegation methods. This service + // owns workspace-scoped status queries, tool toggle, init, and MCP + // restart — routes in server.ts delegate here instead of reaching + // into the bridge for workspace concerns. + const workspaceService = createDaemonWorkspaceService({ + boundWorkspace, + contextFilename: contextFilenameForInit ?? 'QWEN.md', + fsFactory, + // Device-flow registry is constructed inside createServeApp (it + // needs provider map + event sink wiring that lives there). The + // workspace service's auth sub-service uses it for the auth routes + // — those routes are wired in a follow-up PR, so the registry is + // not available at this point. Passing undefined is safe because the + // type is now optional; auth routes will throw at call-time if + // they're invoked before the registry is wired. + deviceFlowRegistry: undefined, + subagentManager: undefined, + // Daemon-host status provider for env + preflight cells. + statusProvider, + // Channel liveness check — proxied through bridge.sessionCount. + isChannelLive: () => bridge.sessionCount > 0, + persistDisabledTools: persistDisabledToolsFn, + queryWorkspaceStatus: (method, idle) => + bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, invokeOpts) => + bridge.invokeWorkspaceCommand(method, params, invokeOpts), + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + }); + let actualPort = opts.port; // Pass the already-canonical `boundWorkspace` into `createServeApp` // via `deps.boundWorkspace`. That field is the pre-canonicalized @@ -808,6 +904,9 @@ export async function runQwenServe( boundWorkspace, fsFactory, daemonLog, + workspace: workspaceService, + persistDisabledTools: persistDisabledToolsFn, + contextFilename: contextFilenameForInit ?? 'QWEN.md', }); // Issue #4175 PR 21 — `createServeApp` parks the device-flow registry // on `app.locals` when it constructs (or accepts) one. Pull it back @@ -1032,15 +1131,26 @@ export async function runQwenServe( const finish = (err?: Error | null) => { if (settled) return; settled = true; - // Drain finished (or timed out) — safe to detach now. process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal); - // Server.close error takes precedence (operator-visible - // listener problem); fall back to the bridge error - // captured during shutdown if any. - const finalErr = err ?? bridgeShutdownError; - if (finalErr) rej(finalErr); - else res(); + void shutdownTelemetry() + .catch((telemetryErr) => { + writeStderrLine( + `qwen serve: telemetry shutdown error: ${ + telemetryErr instanceof Error + ? telemetryErr.message + : String(telemetryErr) + }`, + ); + }) + .finally(() => { + // Server.close error takes precedence (operator-visible + // listener problem); fall back to the bridge error + // captured during shutdown if any. + const finalErr = err ?? bridgeShutdownError; + if (finalErr) rej(finalErr); + else res(); + }); }; // PR 21: dispose the device-flow registry FIRST so any diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index 4b0d011c2a5..62b17aa457f 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -53,9 +53,6 @@ import { RestoreInProgressError, SessionLimitExceededError, SessionNotFoundError, - WorkspaceInitConflictError, - WorkspaceInitPathEscapeError, - WorkspaceInitSymlinkError, WorkspaceMismatchError, type BridgeHeartbeatResult, type BridgeHeartbeatState, @@ -65,13 +62,15 @@ import { type BridgeSession, type BridgeSessionSummary, type BridgeSpawnRequest, - type HttpAcpBridge, + type AcpSessionBridge, type SessionMetadataUpdate, -} from './httpAcpBridge.js'; +} from './acpSessionBridge.js'; import type { BridgeEvent, SubscribeOptions } from './eventBus.js'; import type { ServeSessionContextStatus, + ServeSessionContextUsageStatus, ServeSessionSupportedCommandsStatus, + ServeSessionTasksStatus, ServeWorkspaceEnvStatus, ServeWorkspaceMcpStatus, ServeWorkspaceMcpToolsStatus, @@ -124,7 +123,9 @@ const EXPECTED_STAGE1_FEATURES = [ 'workspace_env', 'workspace_preflight', 'session_context', + 'session_context_usage', 'session_supported_commands', + 'session_tasks', 'session_close', 'session_metadata', // Issue #4175 PR 14. Always-on. Daemon supports the MCP client @@ -163,6 +164,7 @@ const EXPECTED_STAGE1_FEATURES = [ // `permission_partial_vote` / `permission_forbidden` SSE events. Always- // on; runtime-active policy is at `/capabilities` body `policy.permission`. 'permission_mediation', + 'non_blocking_prompt', ] as const; // Issue #4175 PR 15. `require_auth` is registered but conditionally @@ -183,7 +185,10 @@ const EXPECTED_REGISTERED_FEATURES = [ // they appear here in their registry-declaration order, not the // stage1 order. ...EXPECTED_STAGE1_FEATURES.filter( - (f) => f !== 'auth_device_flow' && f !== 'permission_mediation', + (f) => + f !== 'auth_device_flow' && + f !== 'permission_mediation' && + f !== 'non_blocking_prompt', ), 'mcp_workspace_pool', 'mcp_pool_restart', @@ -196,6 +201,7 @@ const EXPECTED_REGISTERED_FEATURES = [ 'permission_mediation', 'prompt_absolute_deadline', 'writer_idle_timeout', + 'non_blocking_prompt', ] as const; interface FakeBridgeOpts { @@ -224,6 +230,7 @@ interface FakeBridgeOpts { req?: CancelNotification, context?: BridgeClientRequestContext, ) => Promise; + getSessionLastEventIdImpl?: (sessionId: string) => number; subscribeImpl?: ( sessionId: string, opts?: SubscribeOptions, @@ -252,9 +259,14 @@ interface FakeBridgeOpts { sessionContextImpl?: ( sessionId: string, ) => Promise; + sessionContextUsageImpl?: ( + sessionId: string, + opts?: { detail?: boolean }, + ) => Promise; sessionSupportedCommandsImpl?: ( sessionId: string, ) => Promise; + sessionTasksImpl?: (sessionId: string) => Promise; setModelImpl?: ( sessionId: string, req: SetSessionModelRequest, @@ -313,7 +325,7 @@ interface FakeBridgeOpts { heartbeatStateImpl?: (sessionId: string) => BridgeHeartbeatState | undefined; } -interface FakeBridge extends HttpAcpBridge { +interface FakeBridge extends AcpSessionBridge { calls: BridgeSpawnRequest[]; loadCalls: BridgeRestoreSessionRequest[]; resumeCalls: BridgeRestoreSessionRequest[]; @@ -353,7 +365,9 @@ interface FakeBridge extends HttpAcpBridge { workspaceEnvCalls: number; workspacePreflightCalls: number; sessionContextCalls: string[]; + sessionContextUsageCalls: string[]; sessionSupportedCommandsCalls: string[]; + sessionTasksCalls: string[]; setModelCalls: Array<{ sessionId: string; req: SetSessionModelRequest; @@ -423,6 +437,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { let workspacePreflightCalls = 0; const sessionContextCalls: string[] = []; const sessionSupportedCommandsCalls: string[] = []; + const sessionTasksCalls: string[] = []; const setModelCalls: FakeBridge['setModelCalls'] = []; const closeCalls: FakeBridge['closeCalls'] = []; const updateMetadataCalls: FakeBridge['updateMetadataCalls'] = []; @@ -531,6 +546,34 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { workspaceCwd: WS_BOUND, state: {}, })); + const sessionContextUsageImpl = + opts.sessionContextUsageImpl ?? + (async (sessionId) => ({ + v: 1 as const, + sessionId, + workspaceCwd: WS_BOUND, + usage: { + modelName: 'test-model', + totalTokens: 1000, + contextWindowSize: 200000, + breakdown: { + systemPrompt: 500, + builtinTools: 100, + mcpTools: 50, + memoryFiles: 50, + skills: 100, + messages: 150, + freeSpace: 199000, + autocompactBuffer: 50, + }, + builtinTools: [], + mcpTools: [], + memoryFiles: [], + skills: [], + }, + formattedText: 'Context usage: 1000/200000 tokens', + })); + const sessionContextUsageCalls: string[] = []; const sessionSupportedCommandsImpl = opts.sessionSupportedCommandsImpl ?? (async (sessionId) => ({ @@ -539,6 +582,14 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { availableCommands: [], availableSkills: [], })); + const sessionTasksImpl = + opts.sessionTasksImpl ?? + (async (sessionId) => ({ + v: 1 as const, + sessionId, + now: 1_700_000_000_000, + tasks: [], + })); const setModelImpl = opts.setModelImpl ?? (async () => ({})); const setApprovalModeCalls: FakeBridge['setApprovalModeCalls'] = []; const setApprovalModeImpl = @@ -604,7 +655,7 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { clientLastSeenAt: new Map(), })); return { - // F3 Commit 6 — `HttpAcpBridge.permissionPolicy` is required so + // F3 Commit 6 — `AcpSessionBridge.permissionPolicy` is required so // `/capabilities` can expose `policy.permission`. Tests don't // exercise mediation; pin to the pre-F3 default ('first-responder') // so existing assertions stay shape-compatible. @@ -621,7 +672,9 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { listCalls, workspaceMcpToolsCalls, sessionContextCalls, + sessionContextUsageCalls, sessionSupportedCommandsCalls, + sessionTasksCalls, setModelCalls, setApprovalModeCalls, generateSessionRecapCalls, @@ -694,6 +747,12 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { // empty })(); }, + getSessionLastEventId(sessionId) { + if (opts.getSessionLastEventIdImpl) { + return opts.getSessionLastEventIdImpl(sessionId); + } + return 0; + }, respondToPermission(requestId, response, context) { const accepted = respondImpl(requestId, response, context); permissionVotes.push({ @@ -754,10 +813,18 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { sessionContextCalls.push(sessionId); return sessionContextImpl(sessionId); }, + async getSessionContextUsageStatus(sessionId, opts) { + sessionContextUsageCalls.push(sessionId); + return sessionContextUsageImpl(sessionId, opts); + }, async getSessionSupportedCommandsStatus(sessionId) { sessionSupportedCommandsCalls.push(sessionId); return sessionSupportedCommandsImpl(sessionId); }, + async getSessionTasksStatus(sessionId) { + sessionTasksCalls.push(sessionId); + return sessionTasksImpl(sessionId); + }, async setSessionModel(sessionId, req, context) { setModelCalls.push({ sessionId, req, ...(context ? { context } : {}) }); return setModelImpl(sessionId, req, context); @@ -778,7 +845,11 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return generateSessionRecapImpl(sessionId, context); }, - async setWorkspaceToolEnabled(toolName, enabled, originatorClientId) { + async setWorkspaceToolEnabled( + toolName: string, + enabled: boolean, + originatorClientId?: string, + ) { setToolEnabledCalls.push({ toolName, enabled, @@ -786,14 +857,21 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { }); return setToolEnabledImpl(toolName, enabled, originatorClientId); }, - async initWorkspace(initOpts, originatorClientId) { + async initWorkspace( + initOpts: { force?: boolean }, + originatorClientId?: string, + ) { initWorkspaceCalls.push({ initOpts, ...(originatorClientId !== undefined ? { originatorClientId } : {}), }); return initWorkspaceImpl(initOpts, originatorClientId); }, - async restartMcpServer(serverName, originatorClientId, restartOpts) { + async restartMcpServer( + serverName: string, + originatorClientId?: string, + restartOpts?: { entryIndex?: number }, + ) { restartMcpServerCalls.push({ serverName, ...(originatorClientId !== undefined ? { originatorClientId } : {}), @@ -845,6 +923,45 @@ function fakeBridge(opts: FakeBridgeOpts = {}): FakeBridge { ...(clientId !== undefined ? { clientId } : {}), }); }, + async queryWorkspaceStatus(method: string, idle: () => unknown) { + // Dispatch based on method to mirror ACP child routing. + if (method === 'qwen/status/workspace/mcp') { + workspaceMcpCalls += 1; + return workspaceMcpImpl(); + } + if (method === 'qwen/status/workspace/skills') { + workspaceSkillsCalls += 1; + return workspaceSkillsImpl(); + } + if (method === 'qwen/status/workspace/providers') { + workspaceProvidersCalls += 1; + return workspaceProvidersImpl(); + } + if (method === 'qwen/status/workspace/preflight') { + workspacePreflightCalls += 1; + return workspacePreflightImpl(); + } + return idle(); + }, + async invokeWorkspaceCommand( + method: string, + params?: Record, + ) { + if (method === 'qwen/control/workspace/mcp/restart') { + const serverName = (params?.['serverName'] as string) ?? ''; + const entryIndex = params?.['entryIndex'] as number | undefined; + restartMcpServerCalls.push({ + serverName, + ...(entryIndex !== undefined ? { opts: { entryIndex } } : {}), + }); + return restartMcpServerImpl( + serverName, + undefined, + entryIndex !== undefined ? { entryIndex } : undefined, + ); + } + return {}; + }, async shutdown() { shutdownCalls += 1; }, @@ -1405,22 +1522,11 @@ describe('createServeApp', () => { }); it('returns workspace env status from the bridge', async () => { - const env: ServeWorkspaceEnvStatus = { - v: 1, - workspaceCwd: WS_BOUND, - initialized: true, - acpChannelLive: false, - cells: [ - { kind: 'runtime', name: 'node', status: 'ok', value: '22.4.0' }, - { - kind: 'env_var', - name: 'OPENAI_API_KEY', - status: 'ok', - present: true, - }, - ], - }; - const bridge = fakeBridge({ workspaceEnvImpl: async () => env }); + // Post-workspace-service refactoring: env status is answered + // daemon-locally from the statusProvider (not from ACP). When + // no statusProvider is injected (test default), the workspace + // service returns idle env data (empty cells). + const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, @@ -1431,41 +1537,22 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`); expect(res.status).toBe(200); - expect(res.body).toEqual(env); - expect(bridge.workspaceEnvCalls).toBe(1); - // Strict assertion: env_var cells never carry a value field, even - // when the env var is set, to preserve the presence-only contract. - const envVarCell = (res.body as ServeWorkspaceEnvStatus).cells.find( - (c) => c.kind === 'env_var', - ); - expect(envVarCell).toBeDefined(); - expect('value' in envVarCell!).toBe(false); - }); - - it('returns workspace preflight status from the bridge', async () => { - const preflight: ServeWorkspacePreflightStatus = { + expect(res.body).toMatchObject({ v: 1, workspaceCwd: WS_BOUND, initialized: true, acpChannelLive: false, - cells: [ - { - kind: 'node_version', - status: 'ok', - locality: 'daemon', - detail: { version: '22.4.0', required: '>=22' }, - }, - { - kind: 'auth', - status: 'not_started', - locality: 'acp', - hint: 'spawn a session to populate', - }, - ], - }; - const bridge = fakeBridge({ - workspacePreflightImpl: async () => preflight, }); + // Without a statusProvider, cells are empty (idle fallback). + expect(res.body.cells).toEqual([]); + }); + + it('returns workspace preflight status from the bridge', async () => { + // Post-workspace-service refactoring: preflight stitches daemon + // cells (from statusProvider) and ACP cells (from queryWorkspaceStatus + // when channel is live). Without statusProvider and with no live + // channel, only idle ACP cells are returned. + const bridge = fakeBridge(); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, undefined, @@ -1476,11 +1563,25 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`); expect(res.status).toBe(200); - expect(res.body).toEqual(preflight); - expect(bridge.workspacePreflightCalls).toBe(1); + expect(res.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + acpChannelLive: false, + }); + // Without statusProvider, daemon cells are empty. Without a live + // channel, ACP cells are idle placeholders. + const cells = res.body.cells as Array<{ + kind: string; + status: string; + locality: string; + }>; + expect(cells.length).toBeGreaterThan(0); + expect(cells.every((c) => c.locality === 'acp')).toBe(true); + expect(cells.every((c) => c.status === 'not_started')).toBe(true); }); - it('returns session context and supported commands from the bridge', async () => { + it('returns read-only session snapshots from the bridge', async () => { const context: ServeSessionContextStatus = { v: 1, sessionId: 's-1', @@ -1500,9 +1601,30 @@ describe('createServeApp', () => { ], availableSkills: ['review'], }; + const tasks: ServeSessionTasksStatus = { + v: 1, + sessionId: 's-1', + now: 1_700_000_000_000, + tasks: [ + { + kind: 'shell', + id: 'sh-1', + label: 'npm test', + description: 'npm test', + status: 'running', + startTime: 1_699_999_999_000, + runtimeMs: 1_000, + outputFile: '/tmp/sh-1.log', + command: 'npm test', + cwd: WS_BOUND, + pid: 123, + }, + ], + }; const bridge = fakeBridge({ sessionContextImpl: async () => context, sessionSupportedCommandsImpl: async () => commands, + sessionTasksImpl: async () => tasks, }); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, @@ -1516,13 +1638,109 @@ describe('createServeApp', () => { const commandsRes = await request(app) .get('/session/s-1/supported-commands') .set('Host', `127.0.0.1:${baseOpts.port}`); + const tasksRes = await request(app) + .get('/session/s-1/tasks') + .set('Host', `127.0.0.1:${baseOpts.port}`); expect(contextRes.status).toBe(200); expect(contextRes.body).toEqual(context); expect(commandsRes.status).toBe(200); expect(commandsRes.body).toEqual(commands); + expect(tasksRes.status).toBe(200); + expect(tasksRes.body).toEqual(tasks); expect(bridge.sessionContextCalls).toEqual(['s-1']); expect(bridge.sessionSupportedCommandsCalls).toEqual(['s-1']); + expect(bridge.sessionTasksCalls).toEqual(['s-1']); + }); + + it('returns session context-usage from the bridge', async () => { + const usage: ServeSessionContextUsageStatus = { + v: 1, + sessionId: 's-1', + workspaceCwd: WS_BOUND, + usage: { + modelName: 'qwen3', + totalTokens: 5000, + contextWindowSize: 200000, + breakdown: { + systemPrompt: 2000, + builtinTools: 500, + mcpTools: 200, + memoryFiles: 300, + skills: 500, + messages: 1500, + freeSpace: 195000, + autocompactBuffer: 0, + }, + builtinTools: [{ name: 'Read', tokens: 100 }], + mcpTools: [], + memoryFiles: [], + skills: [], + }, + formattedText: 'Context: 5000/200000 tokens', + }; + const bridge = fakeBridge({ + sessionContextUsageImpl: async () => usage, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + const res = await request(app) + .get('/session/s-1/context-usage') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(res.status).toBe(200); + expect(res.body).toEqual(usage); + expect(bridge.sessionContextUsageCalls).toEqual(['s-1']); + }); + + it('passes detail query param to context-usage bridge call', async () => { + let receivedOpts: { detail?: boolean } | undefined; + const bridge = fakeBridge({ + sessionContextUsageImpl: async (sessionId, opts) => { + receivedOpts = opts; + return { + v: 1 as const, + sessionId, + workspaceCwd: WS_BOUND, + usage: { + modelName: 'qwen3', + totalTokens: 0, + contextWindowSize: 200000, + breakdown: { + systemPrompt: 0, + builtinTools: 0, + mcpTools: 0, + memoryFiles: 0, + skills: 0, + messages: 0, + freeSpace: 200000, + autocompactBuffer: 0, + }, + builtinTools: [], + mcpTools: [], + memoryFiles: [], + skills: [], + showDetails: true, + }, + formattedText: '', + }; + }, + }); + const app = createServeApp( + { ...baseOpts, workspace: WS_BOUND }, + undefined, + { bridge }, + ); + + await request(app) + .get('/session/s-1/context-usage?detail=true') + .set('Host', `127.0.0.1:${baseOpts.port}`); + + expect(receivedOpts).toEqual({ detail: true }); }); it('maps missing sessions on read-only session routes to 404', async () => { @@ -1533,6 +1751,9 @@ describe('createServeApp', () => { sessionSupportedCommandsImpl: async (sessionId) => { throw new SessionNotFoundError(sessionId); }, + sessionTasksImpl: async (sessionId) => { + throw new SessionNotFoundError(sessionId); + }, }); const app = createServeApp( { ...baseOpts, workspace: WS_BOUND }, @@ -1546,11 +1767,16 @@ describe('createServeApp', () => { const commandsRes = await request(app) .get('/session/missing/supported-commands') .set('Host', `127.0.0.1:${baseOpts.port}`); + const tasksRes = await request(app) + .get('/session/missing/tasks') + .set('Host', `127.0.0.1:${baseOpts.port}`); expect(contextRes.status).toBe(404); expect(contextRes.body.sessionId).toBe('missing'); expect(commandsRes.status).toBe(404); expect(commandsRes.body.sessionId).toBe('missing'); + expect(tasksRes.status).toBe(404); + expect(tasksRes.body.sessionId).toBe('missing'); }); }); @@ -2162,7 +2388,7 @@ describe('createServeApp', () => { }); describe('POST /session/:id/prompt', () => { - it('200 with PromptResponse on success; route :id wins over body sessionId', async () => { + it('202 with promptId on success; route :id wins over body sessionId', async () => { const bridge = fakeBridge({ promptImpl: async () => ({ stopReason: 'end_turn' }), }); @@ -2174,14 +2400,18 @@ describe('createServeApp', () => { sessionId: 'spoofed-session-B', prompt: [{ type: 'text', text: 'hi' }], }); - expect(res.status).toBe(200); - expect(res.body).toEqual({ stopReason: 'end_turn' }); + expect(res.status).toBe(202); + expect(res.body.promptId).toBeDefined(); + expect(typeof res.body.promptId).toBe('string'); + expect(typeof res.body.lastEventId).toBe('number'); + // Allow the async bridge call to settle. + await new Promise((r) => setTimeout(r, 20)); expect(bridge.promptCalls).toHaveLength(1); expect(bridge.promptCalls[0]?.sessionId).toBe('session-A'); expect(bridge.promptCalls[0]?.req.sessionId).toBe('session-A'); }); - it('passes client identity context into bridge.sendPrompt', async () => { + it('passes client identity and promptId context into bridge.sendPrompt', async () => { const bridge = fakeBridge(); const app = createServeApp(baseOpts, undefined, { bridge }); const res = await request(app) @@ -2189,30 +2419,12 @@ describe('createServeApp', () => { .set('Host', `127.0.0.1:${baseOpts.port}`) .set('X-Qwen-Client-Id', 'client-1') .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(200); - expect(bridge.promptCalls[0]?.context).toEqual({ + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 20)); + expect(bridge.promptCalls[0]?.context).toMatchObject({ clientId: 'client-1', }); - }); - - it('400 invalid_client_id when the bridge rejects prompt originator', async () => { - const bridge = fakeBridge({ - promptImpl: async (sessionId) => { - throw new InvalidClientIdError(sessionId, 'client-unknown'); - }, - }); - const app = createServeApp(baseOpts, undefined, { bridge }); - const res = await request(app) - .post('/session/session-A/prompt') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('X-Qwen-Client-Id', 'client-unknown') - .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ - code: 'invalid_client_id', - sessionId: 'session-A', - clientId: 'client-unknown', - }); + expect(bridge.promptCalls[0]?.context?.promptId).toBe(res.body.promptId); }); it('400 when prompt body is missing', async () => { @@ -2228,7 +2440,7 @@ describe('createServeApp', () => { it('404 when bridge reports unknown session', async () => { const bridge = fakeBridge({ - promptImpl: async (sessionId) => { + getSessionLastEventIdImpl: (sessionId) => { throw new SessionNotFoundError(sessionId); }, }); @@ -2241,7 +2453,7 @@ describe('createServeApp', () => { expect(res.body.sessionId).toBe('missing'); }); - it('500 on generic bridge errors', async () => { + it('202 even when bridge errors asynchronously (turn_error event covers failure)', async () => { const bridge = fakeBridge({ promptImpl: async () => { throw new Error('agent crashed'); @@ -2252,8 +2464,8 @@ describe('createServeApp', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(500); - expect(res.body).toEqual({ error: 'agent crashed' }); + expect(res.status).toBe(202); + expect(res.body.promptId).toBeDefined(); }); it('passes an AbortSignal into bridge.sendPrompt', async () => { @@ -2271,69 +2483,30 @@ describe('createServeApp', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(200); - // The route always supplies a signal — the AbortController it wires - // to req.on('close'). The bridge must receive it so a future client - // disconnect can be routed into an ACP cancel. (Capture happens at - // call time; supertest's later connection close would flip the - // signal's `aborted` flag if asserted post-hoc.) + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 20)); expect(signalDefined).toBe(true); expect(abortedAtCall).toBe(false); }); - it('aborting the signal mid-prompt asks the bridge to wind down', async () => { - // Bridge waits forever unless aborted, then resolves with a - // cancelled stop reason. Verifies the route's - // req.on('close') → abort.abort() flow propagates. - let promptStarted: (() => void) | undefined; - const promptStartedPromise = new Promise((r) => { - promptStarted = r; - }); + it('non-blocking prompt returns 202 and fires sendPrompt asynchronously', async () => { + let promptResolve: (() => void) | undefined; const bridge = fakeBridge({ - promptImpl: async (_sid, _req, signal) => + promptImpl: async () => new Promise((resolve) => { - promptStarted!(); - const onAbort = () => resolve({ stopReason: 'cancelled' }); - if (signal?.aborted) onAbort(); - else signal?.addEventListener('abort', onAbort, { once: true }); + promptResolve = () => resolve({ stopReason: 'end_turn' }); }), }); - const localHandle = await runQwenServe( - { hostname: '127.0.0.1', port: 0, mode: 'http-bridge' }, - { bridge }, - ); - try { - const port = (localHandle.server.address() as { port: number }).port; - // Use Node's `http` directly — vitest's jsdom env replaces - // AbortController with a polyfill that undici's fetch rejects. - const http = await import('node:http'); - const reqBody = JSON.stringify({ - prompt: [{ type: 'text', text: 'hi' }], - }); - const httpReq = http.request({ - host: '127.0.0.1', - port, - method: 'POST', - path: '/session/sess/prompt', - headers: { - 'content-type': 'application/json', - 'content-length': Buffer.byteLength(reqBody), - }, - }); - // Swallow ECONNRESET / socket-hangup that the destroy below emits. - httpReq.on('error', () => {}); - httpReq.write(reqBody); - httpReq.end(); - // Wait for the bridge to receive the prompt before destroying. - await promptStartedPromise; - httpReq.destroy(); - // Give the daemon a moment to register the close → propagate. - await new Promise((r) => setTimeout(r, 100)); - expect(bridge.promptCalls).toHaveLength(1); - expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); - } finally { - await localHandle.close(); - } + const app = createServeApp(baseOpts, undefined, { bridge }); + const res = await request(app) + .post('/session/session-A/prompt') + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send({ prompt: [{ type: 'text', text: 'hi' }] }); + expect(res.status).toBe(202); + expect(bridge.promptCalls).toHaveLength(1); + // Resolve the async prompt to clean up. + promptResolve!(); + await new Promise((r) => setTimeout(r, 10)); }); }); @@ -2899,10 +3072,9 @@ describe('createServeApp', () => { }); describe('POST /workspace/init (#4175 Wave 4 PR 17)', () => { - const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; - const auth = (req: request.Test): request.Test => + const auth = (req: request.Test, port: number): request.Test => req - .set('Host', `127.0.0.1:${tokenOpts.port}`) + .set('Host', `127.0.0.1:${port}`) .set('Authorization', 'Bearer secret'); it('401 on no-token daemon: strict gate refuses without bearer auth', async () => { @@ -2914,48 +3086,85 @@ describe('createServeApp', () => { .send({}); expect(res.status).toBe(401); expect(res.body.code).toBe('token_required'); - expect(bridge.initWorkspaceCalls).toHaveLength(0); }); it('200 with action:created and force=false on success', async () => { - const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({}); - expect(res.status).toBe(200); - expect(res.body.action).toBe('created'); - expect(res.body.path).toContain('QWEN.md'); - expect(bridge.initWorkspaceCalls[0]).toMatchObject({ - initOpts: { force: false }, - }); + // Use a real temp directory so the workspace service can perform + // filesystem operations. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-test-'), + ); + try { + const bridge = fakeBridge(); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({}); + expect(res.status).toBe(200); + expect(res.body.action).toBe('created'); + expect(res.body.path).toContain('QWEN.md'); + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); it('forwards force:true to the bridge', async () => { - const bridge = fakeBridge({ - initWorkspaceImpl: async () => ({ - path: path.resolve(WS_BOUND, 'QWEN.md'), - action: 'overwrote' as const, - }), - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({ - force: true, - }); - expect(res.status).toBe(200); - expect(res.body.action).toBe('overwrote'); - expect(bridge.initWorkspaceCalls[0]?.initOpts).toEqual({ force: true }); + // Create a workspace with an existing non-empty QWEN.md to trigger + // the conflict → force:true overwrite path. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-force-'), + ); + try { + await fsp.writeFile(path.join(wsRoot, 'QWEN.md'), 'existing content'); + const bridge = fakeBridge(); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({ force: true }); + expect(res.status).toBe(200); + expect(res.body.action).toBe('overwrote'); + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); it('passes client identity into the bridge', async () => { // #4282 fold-in 1 (gpt-5.5 C2): the workspace mutation route // validates `X-Qwen-Client-Id` against `bridge.knownClientIds()`. - // Register `client-1` so the validation succeeds and the - // originator stamp lands on the bridge call. - const bridge = fakeBridge({ knownClientIds: ['client-1'] }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - await auth(request(app).post('/workspace/init')) - .set('X-Qwen-Client-Id', 'client-1') - .send({}); - expect(bridge.initWorkspaceCalls[0]?.originatorClientId).toBe('client-1'); + // Register `client-1` so the validation succeeds and the request + // goes through without a 400. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-client-'), + ); + try { + const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth(request(app).post('/workspace/init'), opts.port) + .set('X-Qwen-Client-Id', 'client-1') + .send({}); + // Verify the request succeeds — the workspace service receives + // the originator through the request context. + expect(res.status).toBe(200); + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); it('400 invalid_client_id when X-Qwen-Client-Id is not in knownClientIds', async () => { @@ -2963,8 +3172,9 @@ describe('createServeApp', () => { // headers with a structured 400 instead of stamping the // originator on the SSE event. const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')) + const opts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth(request(app).post('/workspace/init'), opts.port) .set('X-Qwen-Client-Id', 'forged-client') .send({}); expect(res.status).toBe(400); @@ -2972,77 +3182,120 @@ describe('createServeApp', () => { code: 'invalid_client_id', clientId: 'forged-client', }); - expect(bridge.initWorkspaceCalls).toHaveLength(0); }); it('400 when force is non-boolean', async () => { const bridge = fakeBridge(); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({ - force: 'yes', - }); + const opts: ServeOptions = { ...baseOpts, token: 'secret' }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({ force: 'yes' }); expect(res.status).toBe(400); expect(res.body.code).toBe('invalid_force_flag'); - expect(bridge.initWorkspaceCalls).toHaveLength(0); }); it('409 with structured payload when bridge throws WorkspaceInitConflictError', async () => { - const bridge = fakeBridge({ - initWorkspaceImpl: async () => { - throw new WorkspaceInitConflictError('/work/bound/QWEN.md', 1234); - }, - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({}); - expect(res.status).toBe(409); - expect(res.body).toMatchObject({ - code: 'workspace_init_conflict', - path: '/work/bound/QWEN.md', - existingSize: 1234, - }); + // Create a workspace with existing non-empty content and do NOT + // pass force:true — the workspace service raises 409. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-conflict-'), + ); + try { + await fsp.writeFile( + path.join(wsRoot, 'QWEN.md'), + 'non-empty content here', + ); + const bridge = fakeBridge(); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({}); + expect(res.status).toBe(409); + expect(res.body).toMatchObject({ + code: 'workspace_init_conflict', + }); + expect(res.body.path).toContain('QWEN.md'); + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); it('400 + code:workspace_init_path_escape on WorkspaceInitPathEscapeError (#4297 fold-in 1, addresses #3260501161)', async () => { - // Without a typed mapping these used to fall through to 500, so - // an operator misreading their `context.fileName` would see a - // generic "daemon broken" error. 400 with structured body - // tells the operator exactly what's wrong. - const bridge = fakeBridge({ - initWorkspaceImpl: async () => { - throw new WorkspaceInitPathEscapeError( - '../outside.md', - '/work/bound', - ); - }, - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({}); - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ - code: 'workspace_init_path_escape', - filename: '../outside.md', - boundWorkspace: '/work/bound', - }); + // The workspace service raises path-escape when the configured + // contextFilename resolves outside the workspace. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-escape-'), + ); + try { + const bridge = fakeBridge(); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { + bridge, + contextFilename: '../outside.md', + }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({}); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'workspace_init_path_escape', + filename: '../outside.md', + }); + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); it('400 + code:workspace_init_symlink on WorkspaceInitSymlinkError (#4297 fold-in 1)', async () => { - const bridge = fakeBridge({ - initWorkspaceImpl: async () => { - throw new WorkspaceInitSymlinkError( - '/work/bound/QWEN.md', - 'target', - 'Workspace context file "/work/bound/QWEN.md" is a symlink.', + // Create a workspace where the target context file is a symlink. + const wsRoot = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-symlink-'), + ); + try { + const outsideDir = await fsp.mkdtemp( + path.join(os.tmpdir(), 'qwen-init-outside-'), + ); + try { + await fsp.writeFile(path.join(outsideDir, 'target.md'), 'outside'); + await fsp.symlink( + path.join(outsideDir, 'target.md'), + path.join(wsRoot, 'QWEN.md'), ); - }, - }); - const app = createServeApp(tokenOpts, undefined, { bridge }); - const res = await auth(request(app).post('/workspace/init')).send({}); - expect(res.status).toBe(400); - expect(res.body).toMatchObject({ - code: 'workspace_init_symlink', - target: '/work/bound/QWEN.md', - kind: 'target', - }); + const bridge = fakeBridge(); + const opts: ServeOptions = { + ...baseOpts, + token: 'secret', + workspace: wsRoot, + }; + const app = createServeApp(opts, undefined, { bridge }); + const res = await auth( + request(app).post('/workspace/init'), + opts.port, + ).send({}); + expect(res.status).toBe(400); + expect(res.body).toMatchObject({ + code: 'workspace_init_symlink', + kind: 'target', + }); + } finally { + await fsp.rm(outsideDir, { recursive: true, force: true }); + } + } finally { + await fsp.rm(wsRoot, { recursive: true, force: true }); + } }); }); @@ -3126,14 +3379,15 @@ describe('createServeApp', () => { it('passes client identity into the bridge', async () => { // #4282 fold-in 1 (gpt-5.5 C2): see /workspace/init test above. + // The workspace service receives the originator via the request + // context; verify the request succeeds when the client-id is valid. const bridge = fakeBridge({ knownClientIds: ['client-1'] }); const app = createServeApp(tokenOpts, undefined, { bridge }); - await auth(request(app).post('/workspace/mcp/docs/restart')) + const res = await auth(request(app).post('/workspace/mcp/docs/restart')) .set('X-Qwen-Client-Id', 'client-1') .send({}); - expect(bridge.restartMcpServerCalls[0]?.originatorClientId).toBe( - 'client-1', - ); + expect(res.status).toBe(200); + expect(bridge.restartMcpServerCalls).toHaveLength(1); }); it('400 invalid_client_id on unknown X-Qwen-Client-Id', async () => { @@ -3259,11 +3513,6 @@ describe('createServeApp', () => { ).send({ enabled: false }); expect(res.status).toBe(200); expect(res.body).toEqual({ toolName: 'Bash', enabled: false }); - expect(bridge.setToolEnabledCalls).toHaveLength(1); - expect(bridge.setToolEnabledCalls[0]).toMatchObject({ - toolName: 'Bash', - enabled: false, - }); }); it('200 on enable=true (re-enable a previously disabled tool)', async () => { @@ -3274,19 +3523,19 @@ describe('createServeApp', () => { ).send({ enabled: true }); expect(res.status).toBe(200); expect(res.body).toEqual({ toolName: 'Bash', enabled: true }); - expect(bridge.setToolEnabledCalls[0]?.enabled).toBe(true); }); it('passes client identity into the bridge', async () => { // #4282 fold-in 1 (gpt-5.5 C2): see /workspace/init test above. + // The workspace service receives the originator via the request + // context; verify the request succeeds when the client-id is valid. const bridge = fakeBridge({ knownClientIds: ['client-1'] }); const app = createServeApp(tokenOpts, undefined, { bridge }); - await auth(request(app).post('/workspace/tools/Bash/enable')) + const res = await auth(request(app).post('/workspace/tools/Bash/enable')) .set('X-Qwen-Client-Id', 'client-1') .send({ enabled: false }); - expect(bridge.setToolEnabledCalls[0]?.originatorClientId).toBe( - 'client-1', - ); + expect(res.status).toBe(200); + expect(res.body).toEqual({ toolName: 'Bash', enabled: false }); }); it('400 invalid_client_id on unknown X-Qwen-Client-Id', async () => { @@ -3328,9 +3577,7 @@ describe('createServeApp', () => { request(app).post('/workspace/tools/mcp__github__create_issue/enable'), ).send({ enabled: false }); expect(res.status).toBe(200); - expect(bridge.setToolEnabledCalls[0]?.toolName).toBe( - 'mcp__github__create_issue', - ); + expect(res.body.toolName).toBe('mcp__github__create_issue'); }); it('trims surrounding whitespace before persisting (#4282 fold-in 4 C3)', async () => { @@ -3347,7 +3594,7 @@ describe('createServeApp', () => { request(app).post('/workspace/tools/%20Bash%20/enable'), ).send({ enabled: false }); expect(res.status).toBe(200); - expect(bridge.setToolEnabledCalls[0]?.toolName).toBe('Bash'); + expect(res.body.toolName).toBe('Bash'); }); it('400 when whitespace-only path parameter trims to empty', async () => { @@ -5366,7 +5613,7 @@ describe('GET /session/:id/events (SSE)', () => { yield { id: 1, v: 1, type: 'session_update', data: 'first' }; // `BridgeTimeoutError(label, timeoutMs)` — 2 positional args // (wenshao #4360 review). The resulting message is - // `"HttpAcpBridge initialize timed out after 5000ms"` which + // `"AcpSessionBridge initialize timed out after 5000ms"` which // satisfies the `.toContain('timed out')` assertion below. throw new BridgeTimeoutError('initialize', 5000); }, @@ -6944,12 +7191,11 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { } }); - it('fires the server-side deadline and returns 504 with errorKind', async () => { + it('fires the server-side deadline and aborts the bridge signal', async () => { // 50ms server deadline + a prompt that resolves only on abort: - // the deadline timer must abort the AbortController, the catch - // block must detect the typed reason, and the response must - // carry the structured `errorKind: 'prompt_deadline_exceeded'` - // (not a generic 500 / silent close). + // the deadline timer must abort the AbortController. With non- + // blocking prompt the HTTP response is always 202; the deadline + // outcome is delivered via `turn_error` on the SSE bus. const bridge = fakeBridge({ promptImpl: abortableBridgePromptImpl() }); const app = createServeApp( { ...baseOpts, promptDeadlineMs: 50 }, @@ -6960,16 +7206,11 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'slow' }] }); - expect(res.status).toBe(504); - expect(res.body).toMatchObject({ - code: 'prompt_deadline_exceeded', - errorKind: 'prompt_deadline_exceeded', - deadlineMs: 50, - }); - // The bridge MUST have received an aborted signal so the agent - // can wind down its FIFO slot — otherwise a buggy agent keeps - // the per-session lane blocked forever even though the HTTP - // client got its 504. + expect(res.status).toBe(202); + expect(res.body).toHaveProperty('promptId'); + expect(res.body).toHaveProperty('lastEventId'); + // Wait for the deadline timer to fire asynchronously. + await new Promise((r) => setTimeout(r, 200)); expect(bridge.promptCalls).toHaveLength(1); expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); expect(bridge.promptCalls[0]?.signal?.reason).toBeInstanceOf( @@ -6977,32 +7218,6 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { ); }); - it('still returns typed 504 when deadline stderr logging fails', async () => { - const stderrSpy = vi - .spyOn(process.stderr, 'write') - .mockImplementation(() => { - throw new Error('stderr pipe closed'); - }); - try { - const bridge = fakeBridge({ - promptImpl: () => new Promise(() => {}), - }); - const app = createServeApp( - { ...baseOpts, promptDeadlineMs: 50 }, - undefined, - { bridge }, - ); - const res = await request(app) - .post('/session/session-A/prompt') - .set('Host', `127.0.0.1:${baseOpts.port}`) - .send({ prompt: [{ type: 'text', text: 'slow' }] }); - expect(res.status).toBe(504); - expect(res.body.errorKind).toBe('prompt_deadline_exceeded'); - } finally { - stderrSpy.mockRestore(); - } - }); - it('strips route-only deadlineMs before forwarding the prompt body', async () => { const bridge = fakeBridge({ promptImpl: async () => ({ stopReason: 'end_turn' }), @@ -7022,7 +7237,7 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { _meta: { trace: 'kept' }, extra: 'kept', }); - expect(res.status).toBe(200); + expect(res.status).toBe(202); expect(bridge.promptCalls).toHaveLength(1); expect(bridge.promptCalls[0]?.req).not.toHaveProperty('deadlineMs'); expect(bridge.promptCalls[0]?.req).toMatchObject({ @@ -7034,11 +7249,9 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { }); it('caps a per-prompt `deadlineMs` override at the server flag', async () => { - // Server flag 50ms, request asks for 5000ms — the effective - // deadline must be the smaller 50ms. The way we observe it is - // the same 504-with-deadlineMs:50 response: if the cap was - // incorrectly the request's 5000ms, the test would time out - // long before completing. + // Server flag 50ms, request asks for 5000ms — effective deadline + // must be 50ms. With non-blocking prompt the HTTP response is + // always 202; we verify the abort signal fires within ~50ms. const bridge = fakeBridge({ promptImpl: abortableBridgePromptImpl() }); const app = createServeApp( { ...baseOpts, promptDeadlineMs: 50 }, @@ -7052,14 +7265,17 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { prompt: [{ type: 'text', text: 'slow' }], deadlineMs: 5_000, }); - expect(res.status).toBe(504); - expect(res.body.deadlineMs).toBe(50); + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 200)); + expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); + expect(bridge.promptCalls[0]?.signal?.reason).toBeInstanceOf( + PromptDeadlineExceededError, + ); }); it('uses the per-prompt override when shorter than the server flag', async () => { // Server flag 10s, request 30ms — request wins as the tighter - // bound. Same observability path as above; if the cap was the - // server's 10s the test would hang past its own short timeout. + // bound. Abort signal should fire within ~30ms. const bridge = fakeBridge({ promptImpl: abortableBridgePromptImpl() }); const app = createServeApp( { ...baseOpts, promptDeadlineMs: 10_000 }, @@ -7073,20 +7289,18 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { prompt: [{ type: 'text', text: 'slow' }], deadlineMs: 30, }); - expect(res.status).toBe(504); - expect(res.body.deadlineMs).toBe(30); - }); - - it('still emits 504 when the bridge IGNORES the abort signal (race contract)', async () => { - // The deadline must be a hard server-side guarantee, not contingent - // on the bridge / agent honoring AbortSignal. A buggy agent that - // never resolves its sendPrompt promise would, without the - // Promise.race in the prompt handler, keep the HTTP request open - // indefinitely and never emit the promised 504 — that was the - // Copilot finding on the initial T2.9 commit. This test exercises - // a non-cooperative bridge to lock the contract: deadline 50ms, - // bridge promise never settles, route still returns 504 within a - // reasonable budget. + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 200)); + expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); + expect(bridge.promptCalls[0]?.signal?.reason).toBeInstanceOf( + PromptDeadlineExceededError, + ); + }); + + it('still aborts the signal when the bridge IGNORES the abort (non-cooperative bridge)', async () => { + // With non-blocking prompt the HTTP response is always 202. The + // deadline timer must still fire and abort the signal so the + // bridge can observe it, even if it ignores the abort. const bridge = fakeBridge({ promptImpl: () => new Promise(() => {}), }); @@ -7099,24 +7313,15 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'slow' }] }); - expect(res.status).toBe(504); - expect(res.body).toMatchObject({ - code: 'prompt_deadline_exceeded', - errorKind: 'prompt_deadline_exceeded', - deadlineMs: 50, - }); - // The signal was still aborted with the typed reason as best- - // effort wind-down — the agent has every chance to clean up - // its FIFO slot even though we no longer wait for it. + expect(res.status).toBe(202); + await new Promise((r) => setTimeout(r, 200)); expect(bridge.promptCalls[0]?.signal?.aborted).toBe(true); expect(bridge.promptCalls[0]?.signal?.reason).toBeInstanceOf( PromptDeadlineExceededError, ); }); - it('does not interfere with normal prompt completion when the flag is unset', async () => { - // The deadline path must be 100% off by default. A 200 OK with - // the bridge's stopReason is the bit-for-bit pre-PR contract. + it('returns 202 without deadline when the flag is unset', async () => { const bridge = fakeBridge({ promptImpl: async () => ({ stopReason: 'end_turn' }), }); @@ -7125,14 +7330,13 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(200); - expect(res.body.stopReason).toBe('end_turn'); + expect(res.status).toBe(202); + expect(res.body).toHaveProperty('promptId'); + expect(res.body).toHaveProperty('lastEventId'); }); it('does not fire the deadline when the prompt resolves promptly', async () => { - // 5s deadline + an immediate resolve: the timer must not fire - // and must not corrupt the 200 response. Guards against a - // future regression where the timer races the response. + // 5s deadline + immediate resolve: the timer must not fire. const bridge = fakeBridge({ promptImpl: async () => ({ stopReason: 'end_turn' }), }); @@ -7145,8 +7349,11 @@ describe('T2.9 prompt absolute deadline (issue #4514)', () => { .post('/session/session-A/prompt') .set('Host', `127.0.0.1:${baseOpts.port}`) .send({ prompt: [{ type: 'text', text: 'hi' }] }); - expect(res.status).toBe(200); - expect(res.body.stopReason).toBe('end_turn'); + expect(res.status).toBe(202); + expect(res.body).toHaveProperty('promptId'); + // Give enough time for a timer to fire if it were going to. + await new Promise((r) => setTimeout(r, 100)); + expect(bridge.promptCalls[0]?.signal?.aborted).toBe(false); }); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index 99f3cbeb4a9..29f156939c2 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -4,14 +4,20 @@ * SPDX-License-Identifier: Apache-2.0 */ +import * as crypto from 'node:crypto'; import * as path from 'node:path'; import express from 'express'; -import type { Application } from 'express'; +import type { Application, NextFunction, Request, Response } from 'express'; import type { ApprovalMode } from '@qwen-code/qwen-code-core'; import { APPROVAL_MODES, SessionService, TrustGateError, + emitDaemonLog, + hashDaemonWorkspace, + recordDaemonError, + recordDaemonHttpResponse, + withDaemonRequestSpan, } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import type { DaemonLogger } from './daemonLogger.js'; @@ -40,10 +46,11 @@ import { createBridgeFileSystemAdapter } from './bridgeFileSystemAdapter.js'; import { createDaemonStatusProvider } from './daemonStatusProvider.js'; import { isServeDebugMode } from './debugMode.js'; import { isLoopbackBind } from './loopbackBinds.js'; +import { mountAcpHttp } from './acpHttp/index.js'; import { canonicalizeWorkspace, CancelSentinelCollisionError, - createHttpAcpBridge, + createAcpSessionBridge, InvalidClientIdError, InvalidPermissionOptionError, InvalidSessionMetadataError, @@ -62,8 +69,8 @@ import { WorkspaceInitRaceError, WorkspaceMismatchError, type BridgeSessionSummary, - type HttpAcpBridge, -} from './httpAcpBridge.js'; + type AcpSessionBridge, +} from './acpSessionBridge.js'; import { getAdvertisedServeFeatures, getServeProtocolVersions, @@ -83,6 +90,11 @@ import { } from './fs/index.js'; import { registerWorkspaceFileReadRoutes } from './routes/workspaceFileRead.js'; import { registerWorkspaceFileWriteRoutes } from './routes/workspaceFileWrite.js'; +import { + createDaemonWorkspaceService, + type DaemonWorkspaceService, + type WorkspaceRequestContext, +} from './workspace-service/index.js'; /** * Build a no-op fs-audit emitter that logs a warning every @@ -174,7 +186,7 @@ export function resolveBridgeFsFactory(input: { const WORKSPACE_SESSION_LIST_SIZE = 100; async function listWorkspaceSessionsForResponse( - bridge: HttpAcpBridge, + bridge: AcpSessionBridge, workspaceCwd: string, ): Promise { const persisted = await new SessionService(workspaceCwd).listSessions({ @@ -216,7 +228,7 @@ async function listWorkspaceSessionsForResponse( export interface ServeAppDeps { /** Bridge instance; tests inject a fake. Defaults to a fresh real one. */ - bridge?: HttpAcpBridge; + bridge?: AcpSessionBridge; /** * Pre-canonicalized workspace path. When supplied, `createServeApp` * skips its own `canonicalizeWorkspace` call (which would issue a @@ -262,12 +274,83 @@ export interface ServeAppDeps { */ deviceFlowProviders?: DeviceFlowProvider[]; /** - * Optional daemon logger. When provided, `sendBridgeError` routes - * each 5xx error through `daemonLog.error(...)` (which tees to stderr + - * the daemon log file). When omitted, falls back to existing - * stderr-only behavior. + * Optional daemon logger. */ daemonLog?: DaemonLogger; + workspace?: DaemonWorkspaceService; + persistDisabledTools?: ( + workspace: string, + toolName: string, + enabled: boolean, + ) => Promise; + contextFilename?: string; +} + +function resolveDaemonTelemetryRoute( + req: Request, +): { route: string; sessionId?: string } | undefined { + if (req.method === 'POST' && req.path === '/session') { + return { route: 'POST /session' }; + } + const sessionAction = req.path.match( + /^\/session\/([^/]+)\/(load|resume|prompt|cancel)$/, + ); + const sessionActionId = sessionAction?.[1]; + const sessionActionName = sessionAction?.[2]; + if (sessionActionId && sessionActionName && req.method === 'POST') { + return { + route: `POST /session/:id/${sessionActionName}`, + sessionId: sessionActionId, + }; + } + const deleteSession = req.path.match(/^\/session\/([^/]+)$/); + const deleteSessionId = deleteSession?.[1]; + if (deleteSessionId && req.method === 'DELETE') { + return { route: 'DELETE /session/:id', sessionId: deleteSessionId }; + } + if (req.method === 'GET' && /^\/workspace\/.+\/sessions$/.test(req.path)) { + return { route: 'GET /workspace/:id/sessions' }; + } + return undefined; +} + +function daemonTelemetryMiddleware( + boundWorkspace: string, +): (req: Request, res: Response, next: NextFunction) => void { + const workspaceHash = hashDaemonWorkspace(boundWorkspace); + return (req, res, next) => { + const route = resolveDaemonTelemetryRoute(req); + if (!route) { + next(); + return; + } + void withDaemonRequestSpan( + { + method: req.method, + route: route.route, + workspaceHash, + ...(route.sessionId ? { sessionId: route.sessionId } : {}), + }, + async (span) => + await new Promise((resolve, reject) => { + let done = false; + const finish = () => { + if (done) return; + done = true; + recordDaemonHttpResponse(span, res.statusCode); + resolve(); + }; + res.once('finish', finish); + res.once('close', finish); + try { + next(); + } catch (error) { + recordDaemonError(span, error); + reject(error); + } + }), + ).catch(next); + }; } /** @@ -315,34 +398,6 @@ export function resolvePromptDeadlineMs( return Math.min(serverMs, requestMs); } -/** - * Issue #4514 T2.9. Single source of truth for the prompt-deadline 504 - * response: log the operator-facing stderr breadcrumb (so a 504 spike - * at the load balancer can be grepped back to a session) and emit the - * typed JSON body. Keeping the wire format and log line together - * prevents drift between future prompt-deadline response paths. - */ -function emitPromptDeadline504( - res: import('express').Response, - err: PromptDeadlineExceededError, - sessionId: string, -): void { - try { - writeStderrLine( - `qwen serve: prompt deadline fired (session ${sessionId}) — ` + - `deadlineMs=${err.deadlineMs}`, - ); - } catch { - /* stderr pipe closed; 504 response still going out. */ - } - res.status(504).json({ - error: err.message, - code: 'prompt_deadline_exceeded', - errorKind: 'prompt_deadline_exceeded', - deadlineMs: err.deadlineMs, - }); -} - /** * Build the Express app for `qwen serve`. Pure function — no side effects on * the network or process; `runQwenServe` does the listen/signal handling. @@ -366,6 +421,7 @@ function emitPromptDeadline504( * - `GET /workspace/:id/sessions` * - `GET /session/:id/context` * - `GET /session/:id/supported-commands` + * - `GET /session/:id/tasks` * - `POST /session/:id/prompt` * - `POST /session/:id/cancel` * - `POST /session/:id/heartbeat` @@ -471,7 +527,7 @@ export function createServeApp( }); const bridge = deps.bridge ?? - createHttpAcpBridge({ + createAcpSessionBridge({ maxSessions: opts.maxSessions, // Symmetric with `runQwenServe.ts` — direct embeds / tests that // call `createServeApp` without supplying their own bridge and @@ -639,14 +695,8 @@ export function createServeApp( // detaching `runQwenServe`'s shutdown dispose call. setDeviceFlowRegistry(app, deviceFlowRegistry); - // Daemon logger — when injected via `deps.daemonLog`, 5xx errors logged - // by `sendBridgeError` route through the structured daemon log (which - // already tees to stderr). When absent (tests, direct embeds), the - // legacy `writeStderrLine` path is preserved. const { daemonLog } = deps; - // Curry `daemonLog` into the module-level error helpers so route - // handlers don't repeat the parameter at every call site. const sendBridgeError = ( res: import('express').Response, err: unknown, @@ -658,6 +708,27 @@ export function createServeApp( ctx: { route: string; sessionId?: string }, ) => sendPermissionVoteErrorImpl(res, err, ctx, daemonLog); + const workspace: DaemonWorkspaceService = + deps.workspace ?? + createDaemonWorkspaceService({ + boundWorkspace, + contextFilename: deps.contextFilename ?? 'QWEN.md', + fsFactory, + deviceFlowRegistry, + subagentManager: undefined, + persistDisabledTools: + deps.persistDisabledTools ?? + (async () => { + /* no-op for tests */ + }), + queryWorkspaceStatus: (method, idle) => + bridge.queryWorkspaceStatus(method, idle), + invokeWorkspaceCommand: (method, params, invokeOpts) => + bridge.invokeWorkspaceCommand(method, params, invokeOpts), + publishWorkspaceEvent: (event) => bridge.publishWorkspaceEvent(event), + knownClientIds: () => bridge.knownClientIds(), + }); + // Order matters: rejection guards (CORS / Host allowlist / bearer auth) // run BEFORE the JSON body parser. Otherwise an unauthenticated POST // gets a full 10MB `JSON.parse` before the 401 fires — a trivially @@ -821,6 +892,20 @@ export function createServeApp( requireAuth: opts.requireAuth === true, }); + app.use(daemonTelemetryMiddleware(boundWorkspace)); + + function buildWorkspaceCtx( + req: import('express').Request, + route: string, + clientId?: string, + ): WorkspaceRequestContext { + return { + originatorClientId: clientId, + route, + workspaceCwd: boundWorkspace, + }; + } + app.get('/capabilities', (_req, res) => { const envelope: CapabilitiesEnvelope = { v: CAPABILITIES_SCHEMA_VERSION, @@ -830,6 +915,12 @@ export function createServeApp( // ONLY when the operator opted in. Tag presence = behavior is // on; older daemons without this PR omit the tag and SDKs that // post-PR feature-detect on it stay backward compatible. + // + // F2 (#4175 commit 5): `mcpPoolActive` advertises + // `mcp_workspace_pool` + `mcp_pool_restart` together. Defaults + // to `true` when omitted so daemons that don't explicitly set + // the option still advertise the F2 surface; operators flip it + // to `false` only when `QWEN_SERVE_NO_MCP_POOL=1` is in scope. features: getAdvertisedServeFeatures(undefined, { requireAuth: opts.requireAuth === true, mcpPoolActive: opts.mcpPoolActive !== false, @@ -857,9 +948,10 @@ export function createServeApp( res.status(200).json(envelope); }); - app.get('/workspace/mcp', async (_req, res) => { + app.get('/workspace/mcp', async (req, res) => { try { - res.status(200).json(await bridge.getWorkspaceMcpStatus()); + const ctx = buildWorkspaceCtx(req, 'GET /workspace/mcp'); + res.status(200).json(await workspace.getWorkspaceMcpStatus(ctx)); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/mcp' }); } @@ -888,9 +980,10 @@ export function createServeApp( } }); - app.get('/workspace/skills', async (_req, res) => { + app.get('/workspace/skills', async (req, res) => { try { - res.status(200).json(await bridge.getWorkspaceSkillsStatus()); + const ctx = buildWorkspaceCtx(req, 'GET /workspace/skills'); + res.status(200).json(await workspace.getWorkspaceSkillsStatus(ctx)); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/skills' }); } @@ -904,9 +997,10 @@ export function createServeApp( } }); - app.get('/workspace/providers', async (_req, res) => { + app.get('/workspace/providers', async (req, res) => { try { - res.status(200).json(await bridge.getWorkspaceProvidersStatus()); + const ctx = buildWorkspaceCtx(req, 'GET /workspace/providers'); + res.status(200).json(await workspace.getWorkspaceProvidersStatus(ctx)); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/providers' }); } @@ -943,17 +1037,19 @@ export function createServeApp( // oversight — the audit topic does not yet exist; PR 24 lands the // shared `bridge.emitAudit` infrastructure that this and PR 18's // `fs.access` events will both use. - app.get('/workspace/env', async (_req, res) => { + app.get('/workspace/env', async (req, res) => { try { - res.status(200).json(await bridge.getWorkspaceEnvStatus()); + const ctx = buildWorkspaceCtx(req, 'GET /workspace/env'); + res.status(200).json(await workspace.getWorkspaceEnvStatus(ctx)); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/env' }); } }); - app.get('/workspace/preflight', async (_req, res) => { + app.get('/workspace/preflight', async (req, res) => { try { - res.status(200).json(await bridge.getWorkspacePreflightStatus()); + const ctx = buildWorkspaceCtx(req, 'GET /workspace/preflight'); + res.status(200).json(await workspace.getWorkspacePreflightStatus(ctx)); } catch (err) { sendBridgeError(res, err, { route: 'GET /workspace/preflight' }); } @@ -1401,6 +1497,28 @@ export function createServeApp( } }); + app.get('/session/:id/context-usage', async (req, res) => { + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + try { + res.status(200).json( + await bridge.getSessionContextUsageStatus(sessionId, { + detail: req.query['detail'] === 'true', + }), + ); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/context-usage', + sessionId, + }); + } + }); + app.get('/session/:id/supported-commands', async (req, res) => { const sessionId = req.params['id']; if (!sessionId) { @@ -1421,6 +1539,24 @@ export function createServeApp( } }); + app.get('/session/:id/tasks', async (req, res) => { + const sessionId = req.params['id']; + if (!sessionId) { + res + .status(400) + .json({ error: '`sessionId` route parameter is required' }); + return; + } + try { + res.status(200).json(await bridge.getSessionTasksStatus(sessionId)); + } catch (err) { + sendBridgeError(res, err, { + route: 'GET /session/:id/tasks', + sessionId, + }); + } + }); + app.post('/session/:id/prompt', mutate(), async (req, res) => { const sessionId = req.params['id']; const body = safeBody(req); @@ -1435,12 +1571,6 @@ export function createServeApp( if ( !prompt.every( (item: unknown) => - // `typeof item === 'object'` is true for arrays too, so an - // exclude-arrays check is needed to keep the contract - // ("ACP content block, like {type: 'text', text: '...'}") - // honest. Without `!Array.isArray(item)`, `prompt: [[]]` - // passes validation and a confusing 500 surfaces from the - // ACP SDK layer. typeof item === 'object' && item !== null && !Array.isArray(item), ) ) { @@ -1449,12 +1579,6 @@ export function createServeApp( }); return; } - // T2.9: validate the optional per-prompt `deadlineMs` override BEFORE - // we touch the abort controller — a malformed value is operator- - // visible client error (400) rather than silently dropped (which - // would let the client believe their deadline was active when it - // wasn't). Capping vs the server flag happens later, after we - // know what the server is willing to enforce. const rawRequestDeadline = body['deadlineMs']; let requestDeadlineMs: number | undefined; if (rawRequestDeadline !== undefined && rawRequestDeadline !== null) { @@ -1472,145 +1596,59 @@ export function createServeApp( } requestDeadlineMs = rawRequestDeadline; } - // Propagate HTTP-client disconnect to an ACP cancel notification so - // the agent winds down promptly and the per-session FIFO doesn't - // stay blocked on a dead client. Detached after the prompt settles. - // - // Use `res.on('close')` (NOT `req.on('close')`) — `IncomingMessage`'s - // close event fires once the request body has been fully consumed - // even when the client is still listening for the response, which - // would cancel every ordinary prompt the moment its upload - // finished. `ServerResponse`'s close event only fires when the - // socket goes away. Guard with `!res.writableEnded` so a normal - // response flush (which also triggers `res.close`) doesn't fire - // the abort retroactively. + const clientId = parseClientIdHeader(req, res); + if (clientId === null) return; + + const promptId = crypto.randomUUID(); + const forwardedBody = { ...body }; + delete forwardedBody['deadlineMs']; + + let lastEventId: number; + try { + lastEventId = bridge.getSessionLastEventId(sessionId); + } catch (err) { + sendBridgeError(res, err, { + route: 'POST /session/:id/prompt', + sessionId, + }); + return; + } + const abort = new AbortController(); - const onResClose = () => { - if (!res.writableEnded) abort.abort(); - }; - res.once('close', onResClose); - // T2.9: arm the server-side wallclock deadline (if configured). - // `resolvePromptDeadlineMs` returns `undefined` when the server - // flag is unset, preserving the legacy "client disconnect is - // the only auto-cancel" behavior bit-for-bit. When a deadline IS - // configured we Promise.race the bridge call against an explicit - // rejecting timer — without the race, a non-cooperative agent - // that ignores AbortSignal could keep the HTTP request open - // indefinitely (the FIXME in `httpAcpBridge.ts` `sendPrompt` was - // promised closed by T2.9 in the PR description; relying on the - // bridge alone wouldn't deliver). The race makes the 504 a hard - // guarantee independent of bridge cooperation; `abort.abort` is - // still called as best-effort wind-down so the agent's FIFO slot - // is freed if it does honor the signal. const effectiveDeadlineMs = resolvePromptDeadlineMs( opts.promptDeadlineMs, requestDeadlineMs, ); - const forwardedBody = { ...body }; - delete forwardedBody['deadlineMs']; let deadlineTimer: NodeJS.Timeout | undefined; - const deadlinePromise: Promise | undefined = - effectiveDeadlineMs !== undefined - ? new Promise((_, reject) => { - deadlineTimer = setTimeout(() => { - const err = new PromptDeadlineExceededError(effectiveDeadlineMs); - // Reject FIRST so Promise.race resolves deterministically - // with the typed deadline error; the bridge's own - // AbortError rejection (if the agent honors abort) - // would otherwise race the route's microtask queue and - // surface as a generic AbortError in the catch path. - reject(err); - if (!abort.signal.aborted) abort.abort(err); - }, effectiveDeadlineMs); - // unref so a still-armed timer can't keep the daemon alive - // past shutdown. - deadlineTimer.unref(); - }) - : undefined; - const clientId = parseClientIdHeader(req, res); - if (clientId === null) { - res.off('close', onResClose); - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); - return; + if (effectiveDeadlineMs !== undefined) { + deadlineTimer = setTimeout(() => { + if (!abort.signal.aborted) { + abort.abort(new PromptDeadlineExceededError(effectiveDeadlineMs)); + } + }, effectiveDeadlineMs); + deadlineTimer.unref(); } - try { - // SECURITY NOTE: this `...forwardedBody` passthrough is - // intentional — the bridge / ACP SDK ignores fields it - // doesn't recognize (ACP-spec `_meta` etc are forwarded - // wholesale to the agent, which is the documented behavior). - // `sessionId`, `prompt`, and the route-only `deadlineMs` are - // forced/stripped so the child never sees uncapped client input. - const bridgePromise = bridge.sendPrompt( + + bridge + .sendPrompt( sessionId, { ...forwardedBody, sessionId, prompt, - } as Parameters[1], + } as Parameters[1], abort.signal, - clientId !== undefined ? { clientId } : undefined, - ); - // T2.9: when the deadline race fires first, the underlying - // bridge promise becomes an orphan that may eventually settle - // minutes later (especially against a buggy agent). Tail-attach - // a no-op handler so its eventual rejection doesn't surface as - // an unhandledRejection — the 504 has already been sent and the - // route has no further use for the result. - if (deadlinePromise !== undefined) { - bridgePromise.catch(() => undefined); - } - const result = await (deadlinePromise !== undefined - ? Promise.race([bridgePromise, deadlinePromise]) - : bridgePromise); - res.status(200).json(result); - } catch (err) { - // T2.9: the deadline race won — emit the typed 504 directly. - // This is the primary deadline-exceeded path now that the - // route races the bridge against its own timer. - // - // The `return` MUST fire on every `PromptDeadlineExceededError`, - // including the writableEnded race (client disconnected in the - // same tick the deadline timer fired). Without the early return, - // the typed error would fall through to the AbortError branch - // (false — not a DOMException), then `sendBridgeError`, which - // would call `res.status(500).json(...)` on an already-ended - // response and trip `ERR_STREAM_WRITE_AFTER_END`. wenshao - // review #4530 inline #3 (Critical). - if (err instanceof PromptDeadlineExceededError) { - if (!res.writableEnded) emitPromptDeadline504(res, err, sessionId); - return; - } - // The HTTP client disconnecting fires the abort path above and - // the bridge re-throws as `AbortError`. That's a normal - // wind-down, not an error worth a 500 + stderr stack trace. - // Drop it silently — the socket is already closed so we can't - // send a response anyway, and active clients (e.g. an IDE - // plugin scrubbing a stuck prompt) would otherwise spam the - // daemon log. - // - // BX9_k: narrow the swallow to ONLY the case where WE armed - // the abort. The earlier blanket `err.name === 'AbortError'` - // could also swallow an internal bridge abort (e.g. the child - // process aborting a prompt mid-flight) — leaving the client - // with no response and no log trace. If `abort.signal.aborted` - // is false, the AbortError came from somewhere we didn't - // expect → route it through `sendBridgeError` as a real - // failure. - if ( - err instanceof DOMException && - err.name === 'AbortError' && - abort.signal.aborted - ) { - return; - } - sendBridgeError(res, err, { - route: 'POST /session/:id/prompt', - sessionId, - }); - } finally { - res.off('close', onResClose); - if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); - } + { + ...(clientId !== undefined ? { clientId } : {}), + promptId, + }, + ) + .finally(() => { + if (deadlineTimer !== undefined) clearTimeout(deadlineTimer); + }) + .catch(() => {}); + + res.status(202).json({ promptId, lastEventId }); }); app.post('/session/:id/heartbeat', mutate(), (req, res) => { @@ -1676,7 +1714,7 @@ export function createServeApp( { ...(body as object), sessionId, - } as Parameters[1], + } as Parameters[1], clientId !== undefined ? { clientId } : undefined, ); res.status(204).end(); @@ -1809,7 +1847,7 @@ export function createServeApp( ...(body as object), sessionId, modelId, - } as Parameters[1], + } as Parameters[1], clientId !== undefined ? { clientId } : undefined, ); res.status(200).json(response); @@ -1866,6 +1904,51 @@ export function createServeApp( } }); + app.post('/session/:id/shell', mutate(), async (req, res) => { + const sessionId = req.params['id']; + const body = safeBody(req); + const command = body['command']; + if (typeof command !== 'string' || command.trim().length === 0) { + res.status(400).json({ + error: '`command` is required and must be a non-empty string', + }); + return; + } + const abort = new AbortController(); + const onResClose = () => { + if (!res.writableEnded) abort.abort(); + }; + res.once('close', onResClose); + const clientId = parseClientIdHeader(req, res); + if (clientId === null) { + res.off('close', onResClose); + return; + } + try { + const result = await bridge.executeShellCommand( + sessionId, + command.trim(), + abort.signal, + clientId !== undefined ? { clientId } : undefined, + ); + res.status(200).json(result); + } catch (err) { + if ( + err instanceof DOMException && + err.name === 'AbortError' && + abort.signal.aborted + ) { + return; + } + sendBridgeError(res, err, { + route: 'POST /session/:id/shell', + sessionId, + }); + } finally { + res.off('close', onResClose); + } + }); + app.post( '/session/:id/approval-mode', mutate({ strict: true }), @@ -1983,9 +2066,14 @@ export function createServeApp( entryIndex = parsed; } try { - const result = await bridge.restartMcpServer( - serverName, + const ctx = buildWorkspaceCtx( + req, + 'POST /workspace/mcp/:server/restart', clientId, + ); + const result = await workspace.restartMcpServer( + ctx, + serverName, entryIndex !== undefined ? { entryIndex } : undefined, ); res.status(200).json(result); @@ -1998,8 +2086,8 @@ export function createServeApp( ); app.post('/workspace/init', mutate({ strict: true }), async (req, res) => { - // #4175 Wave 4 PR 17. Scaffold-only init: the bridge writes an - // empty QWEN.md without invoking the LLM. Default refuses + // #4175 Wave 4 PR 17. Scaffold-only init: the workspace service + // writes an empty QWEN.md without invoking the LLM. Default refuses // overwrite (409); body `{force: true}` overrides. const body = safeBody(req); const force = body['force']; @@ -2014,10 +2102,10 @@ export function createServeApp( const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); if (clientId === null) return; try { - const result = await bridge.initWorkspace( - { force: force === true }, - clientId, - ); + const ctx = buildWorkspaceCtx(req, 'POST /workspace/init', clientId); + const result = await workspace.initWorkspace(ctx, { + force: force === true, + }); res.status(200).json(result); } catch (err) { sendBridgeError(res, err, { route: 'POST /workspace/init' }); @@ -2086,10 +2174,15 @@ export function createServeApp( const clientId = parseAndValidateWorkspaceClientId(req, res, bridge); if (clientId === null) return; try { - const result = await bridge.setWorkspaceToolEnabled( + const ctx = buildWorkspaceCtx( + req, + 'POST /workspace/tools/:name/enable', + clientId, + ); + const result = await workspace.setWorkspaceToolEnabled( + ctx, toolName, enabled, - clientId, ); res.status(200).json(result); } catch (err) { @@ -2555,6 +2648,15 @@ export function createServeApp( })(); }); + // Official ACP Streamable HTTP transport (RFD #721) mounted at `/acp` + // alongside the REST surface, sharing this same `bridge` instance. + // Additive + toggleable (`QWEN_SERVE_ACP_HTTP=0` opts out). See + // `docs/design/daemon-acp-http/README.md` §6 for the dual-transport + // decision. Mounted AFTER the REST routes (distinct path, no overlap) + // and BEFORE the final error handler so malformed `/acp` bodies still + // route through the JSON error contract below. + mountAcpHttp(app, bridge, { boundWorkspace, workspace }); + // Final error handler. `express.json()` throws `SyntaxError` (with // `status: 400`) on malformed body — without this 4-arg middleware // Express renders an HTML error page, which trips SDK clients that @@ -2648,7 +2750,7 @@ const INVALID_PERMISSION_OUTCOME_ERROR = '`outcome` must be `{ outcome: "cancelled" }` or `{ outcome: "selected", optionId: string }`'; type PermissionVoteResponse = Parameters< - HttpAcpBridge['respondToPermission'] + AcpSessionBridge['respondToPermission'] >[1]; /** @@ -2914,7 +3016,7 @@ export function detectFromLoopback(req: { function parseAndValidateWorkspaceClientId( req: import('express').Request, res: import('express').Response, - bridge: HttpAcpBridge, + bridge: AcpSessionBridge, ): string | undefined | null { const raw = parseClientIdHeader(req, res); if (raw === null || raw === undefined) return raw; @@ -3413,6 +3515,19 @@ function sendBridgeErrorImpl( // structured daemon logger (which tees to stderr + log file). When // absent (tests, direct embeds), fall back to the legacy stderr-only // `writeStderrLine` path. + recordDaemonError(undefined, err, { + ...(ctx?.route ? { 'http.route': ctx.route } : {}), + ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), + }); + emitDaemonLog('Daemon bridge error.', { + ...(ctx?.route ? { 'http.route': ctx.route } : {}), + ...(ctx?.sessionId ? { 'session.id': ctx.sessionId } : {}), + 'error.type': err instanceof Error ? err.name : typeof err, + 'error.message': (err instanceof Error ? err.message : String(err)).slice( + 0, + 1024, + ), + }); if (daemonLog) { daemonLog.error( err instanceof Error ? err.message : String(err), diff --git a/packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts b/packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts new file mode 100644 index 00000000000..84d22823923 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/agentsService.test.ts @@ -0,0 +1,584 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; + +// Mock the core module so the service implementation can import it +// without pulling in the full dependency tree (undici via config.ts). +// vi.mock is hoisted — the factory must be self-contained. +vi.mock('@qwen-code/qwen-code-core', () => { + class SubagentError extends Error { + code: string; + subagentName?: string; + constructor(message: string, code: string, subagentName?: string) { + super(message); + this.name = 'SubagentError'; + this.code = code; + this.subagentName = subagentName; + } + } + const SubagentErrorCode = { + NOT_FOUND: 'NOT_FOUND', + FILE_ERROR: 'FILE_ERROR', + ALREADY_EXISTS: 'ALREADY_EXISTS', + VALIDATION_ERROR: 'VALIDATION_ERROR', + INVALID_CONFIG: 'INVALID_CONFIG', + INVALID_NAME: 'INVALID_NAME', + TOOL_NOT_FOUND: 'TOOL_NOT_FOUND', + } as const; + return { SubagentError, SubagentErrorCode }; +}); + +// Import SubagentError/SubagentErrorCode from the mock for test assertions. +const { SubagentError, SubagentErrorCode } = (await import( + '@qwen-code/qwen-code-core' +)) as { + SubagentError: new ( + message: string, + code: string, + name?: string, + ) => Error & { code: string; subagentName?: string }; + SubagentErrorCode: Record; +}; + +import { + createAgentsService, + type AgentsServiceDeps, +} from '../agentsService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** Minimal SubagentConfig shape matching core's interface. */ +interface MockSubagentConfig { + name: string; + description: string; + systemPrompt: string; + level: string; + tools?: string[]; + disallowedTools?: string[]; + model?: string; + color?: string; + background?: boolean; + approvalMode?: string; + extensionName?: string; + filePath?: string; + isBuiltin?: boolean; + runConfig?: { max_time_minutes?: number; max_turns?: number }; +} + +function makeSubagentConfig( + overrides?: Partial, +): MockSubagentConfig { + return { + name: 'test-agent', + description: 'A test agent', + systemPrompt: 'You are a test agent.', + level: 'project', + tools: ['Bash'], + ...overrides, + }; +} + +/** Minimal mock matching SubagentManager's CRUD methods. */ +interface MockManager { + listSubagents: ReturnType; + loadSubagent: ReturnType; + createSubagent: ReturnType; + updateSubagent: ReturnType; + deleteSubagent: ReturnType; +} + +function makeMockManager(): MockManager { + return { + listSubagents: vi.fn().mockResolvedValue([makeSubagentConfig()]), + loadSubagent: vi.fn().mockResolvedValue(makeSubagentConfig()), + createSubagent: vi.fn().mockResolvedValue(undefined), + updateSubagent: vi.fn().mockResolvedValue(undefined), + deleteSubagent: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeDeps( + overrides?: Partial>, +): AgentsServiceDeps { + const base = { + subagentManager: makeMockManager(), + boundWorkspace: '/workspace', + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['client-1', 'client-2']), + ...overrides, + }; + return base as unknown as AgentsServiceDeps; +} + +function makeCtx( + overrides?: Partial, +): WorkspaceRequestContext { + return { + originatorClientId: 'client-1', + route: 'POST /workspace/agents', + workspaceCwd: '/workspace', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AgentsService', () => { + describe('listAgents', () => { + it('delegates to subagentManager.listSubagents with force: true', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx(); + + const result = await svc.listAgents(ctx); + + expect(deps.subagentManager.listSubagents).toHaveBeenCalledWith({ + force: true, + }); + expect(result.agents).toHaveLength(1); + expect(result.agents[0]!.name).toBe('test-agent'); + expect(result.workspaceCwd).toBe('/workspace'); + }); + + it('maps SubagentConfig to ServeWorkspaceAgentSummary correctly', async () => { + const config = makeSubagentConfig({ + model: 'gpt-4', + color: 'blue', + background: true, + approvalMode: 'auto-edit', + extensionName: 'ext-1', + filePath: '/workspace/.qwen/agents/test.md', + }); + const manager = makeMockManager(); + (manager.listSubagents as ReturnType).mockResolvedValue([ + config, + ]); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + const result = await svc.listAgents(makeCtx()); + const agent = result.agents[0]!; + + expect(agent.kind).toBe('agent'); + expect(agent.name).toBe('test-agent'); + expect(agent.description).toBe('A test agent'); + expect(agent.level).toBe('project'); + expect(agent.isBuiltin).toBe(false); + expect(agent.hasTools).toBe(true); + expect(agent.model).toBe('gpt-4'); + expect(agent.color).toBe('blue'); + expect(agent.background).toBe(true); + expect(agent.approvalMode).toBe('auto-edit'); + expect(agent.extensionName).toBe('ext-1'); + expect(agent.filePath).toBe('/workspace/.qwen/agents/test.md'); + }); + }); + + describe('getAgent', () => { + it('delegates to subagentManager.loadSubagent', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + + const result = await svc.getAgent(makeCtx(), 'test-agent'); + + expect(deps.subagentManager.loadSubagent).toHaveBeenCalledWith( + 'test-agent', + ); + expect(result).toBeDefined(); + expect(result!.name).toBe('test-agent'); + expect(result!.systemPrompt).toBe('You are a test agent.'); + }); + + it('returns undefined when agent not found', async () => { + const manager = makeMockManager(); + (manager.loadSubagent as ReturnType).mockResolvedValue( + null, + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + const result = await svc.getAgent(makeCtx(), 'nonexistent'); + + expect(result).toBeUndefined(); + }); + + it('returns detail including tools and runConfig', async () => { + const config = makeSubagentConfig({ + tools: ['Bash', 'Read'], + disallowedTools: ['Write'], + runConfig: { max_time_minutes: 10, max_turns: 5 }, + }); + const manager = makeMockManager(); + (manager.loadSubagent as ReturnType).mockResolvedValue( + config, + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + const result = await svc.getAgent(makeCtx(), 'test-agent'); + + expect(result!.tools).toEqual(['Bash', 'Read']); + expect(result!.disallowedTools).toEqual(['Write']); + expect(result!.runConfig).toEqual({ max_time_minutes: 10, max_turns: 5 }); + }); + }); + + describe('createAgent', () => { + it('validates clientId before creating', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'unknown-client' }); + + await expect( + svc.createAgent(ctx, { + name: 'new-agent', + description: 'desc', + systemPrompt: 'prompt', + }), + ).rejects.toThrow('not registered'); + + expect(deps.subagentManager.createSubagent).not.toHaveBeenCalled(); + }); + + it('allows mutation when clientId is undefined', async () => { + const deps = makeDeps(); + const manager = deps.subagentManager as unknown as MockManager; + // collision preflight returns null, post-create reload returns config + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeSubagentConfig()); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + const result = await svc.createAgent(ctx, { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }); + + expect(result.name).toBe('test-agent'); + expect(deps.subagentManager.createSubagent).toHaveBeenCalled(); + }); + + it('allows mutation when clientId is in knownClientIds', async () => { + const deps = makeDeps(); + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeSubagentConfig()); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'client-1' }); + + const result = await svc.createAgent(ctx, { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }); + + expect(result.name).toBe('test-agent'); + }); + + it('delegates to subagentManager.createSubagent with correct config', async () => { + const deps = makeDeps(); + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce( + makeSubagentConfig({ name: 'new-agent', level: 'user' }), + ); + const svc = createAgentsService(deps); + const ctx = makeCtx(); + + await svc.createAgent(ctx, { + name: 'new-agent', + description: 'A new agent', + systemPrompt: 'Do things', + level: 'user', + tools: ['Bash'], + model: 'gpt-4', + }); + + expect(deps.subagentManager.createSubagent).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'new-agent', + description: 'A new agent', + systemPrompt: 'Do things', + level: 'user', + tools: ['Bash'], + model: 'gpt-4', + }), + { level: 'user' }, + ); + }); + + it('publishes agent_changed event after successful creation', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'client-1' }); + + // loadSubagent is called twice: once for collision preflight (return null), + // once for post-create reload (return config). + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) // collision preflight + .mockResolvedValueOnce(makeSubagentConfig()); // post-create reload + + await svc.createAgent(ctx, { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'agent_changed', + data: { change: 'created', name: 'test-agent', level: 'project' }, + originatorClientId: 'client-1', + }); + }); + + it('does not include originatorClientId in event when undefined', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + // loadSubagent: collision preflight (null) + post-create reload + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeSubagentConfig()); + + await svc.createAgent(ctx, { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'agent_changed', + data: { change: 'created', name: 'test-agent', level: 'project' }, + }); + }); + + it('throws when agent already exists at target level', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + // loadSubagent returns an existing config for collision preflight + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType).mockResolvedValue( + makeSubagentConfig(), + ); + + await expect( + svc.createAgent(makeCtx(), { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }), + ).rejects.toThrow('agent_already_exists'); + + expect(manager.createSubagent).not.toHaveBeenCalled(); + }); + + it('defaults level to project when not specified', async () => { + const deps = makeDeps(); + const manager = deps.subagentManager as unknown as MockManager; + (manager.loadSubagent as ReturnType) + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(makeSubagentConfig()); + const svc = createAgentsService(deps); + + await svc.createAgent(makeCtx(), { + name: 'test-agent', + description: 'desc', + systemPrompt: 'prompt', + }); + + expect(deps.subagentManager.createSubagent).toHaveBeenCalledWith( + expect.objectContaining({ level: 'project' }), + { level: 'project' }, + ); + }); + }); + + describe('updateAgent', () => { + it('validates clientId before updating', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'unknown-client' }); + + await expect( + svc.updateAgent(ctx, 'test-agent', { description: 'updated' }), + ).rejects.toThrow('not registered'); + + expect(deps.subagentManager.updateSubagent).not.toHaveBeenCalled(); + }); + + it('allows mutation when clientId is undefined', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + const result = await svc.updateAgent(ctx, 'test-agent', { + description: 'updated', + }); + + expect(result.name).toBe('test-agent'); + }); + + it('throws NOT_FOUND when agent does not exist', async () => { + const manager = makeMockManager(); + (manager.loadSubagent as ReturnType).mockResolvedValue( + null, + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + await expect( + svc.updateAgent(makeCtx(), 'nonexistent', { description: 'x' }), + ).rejects.toThrow('not found'); + }); + + it('delegates to subagentManager.updateSubagent with correct params', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + + await svc.updateAgent(makeCtx(), 'test-agent', { + description: 'updated desc', + systemPrompt: 'new prompt', + }); + + expect(deps.subagentManager.updateSubagent).toHaveBeenCalledWith( + 'test-agent', + expect.objectContaining({ + description: 'updated desc', + systemPrompt: 'new prompt', + }), + 'project', // existing.level + ); + }); + + it('publishes agent_changed event after successful update', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'client-2' }); + + await svc.updateAgent(ctx, 'test-agent', { description: 'updated' }); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'agent_changed', + data: { change: 'updated', name: 'test-agent', level: 'project' }, + originatorClientId: 'client-2', + }); + }); + }); + + describe('deleteAgent', () => { + it('validates clientId before deleting', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'unknown-client' }); + + await expect(svc.deleteAgent(ctx, 'test-agent')).rejects.toThrow( + 'not registered', + ); + + expect(deps.subagentManager.deleteSubagent).not.toHaveBeenCalled(); + }); + + it('allows mutation when clientId is undefined', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + const result = await svc.deleteAgent(ctx, 'test-agent'); + + expect(result.deleted).toBe(true); + }); + + it('returns deleted: true on successful deletion', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + + const result = await svc.deleteAgent(makeCtx(), 'test-agent'); + + expect(result.deleted).toBe(true); + expect(deps.subagentManager.deleteSubagent).toHaveBeenCalledWith( + 'test-agent', + ); + }); + + it('returns deleted: false when agent not found', async () => { + const manager = makeMockManager(); + (manager.deleteSubagent as ReturnType).mockRejectedValue( + new SubagentError( + 'not found', + SubagentErrorCode['NOT_FOUND'], + 'missing', + ), + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + const result = await svc.deleteAgent(makeCtx(), 'missing'); + + expect(result.deleted).toBe(false); + }); + + it('publishes agent_changed event after successful deletion', async () => { + const deps = makeDeps(); + const svc = createAgentsService(deps); + const ctx = makeCtx({ originatorClientId: 'client-1' }); + + await svc.deleteAgent(ctx, 'test-agent'); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'agent_changed', + data: { change: 'deleted', name: 'test-agent' }, + originatorClientId: 'client-1', + }); + }); + + it('does not publish event when agent not found', async () => { + const manager = makeMockManager(); + (manager.deleteSubagent as ReturnType).mockRejectedValue( + new SubagentError( + 'not found', + SubagentErrorCode['NOT_FOUND'], + 'missing', + ), + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + await svc.deleteAgent(makeCtx(), 'missing'); + + expect(deps.publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + + it('re-throws non-NOT_FOUND errors', async () => { + const manager = makeMockManager(); + (manager.deleteSubagent as ReturnType).mockRejectedValue( + new SubagentError( + 'file error', + SubagentErrorCode['FILE_ERROR'], + 'test-agent', + ), + ); + const deps = makeDeps({ subagentManager: manager }); + const svc = createAgentsService(deps); + + await expect(svc.deleteAgent(makeCtx(), 'test-agent')).rejects.toThrow( + 'file error', + ); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/__tests__/authService.test.ts b/packages/cli/src/serve/workspace-service/__tests__/authService.test.ts new file mode 100644 index 00000000000..c3f106dca07 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/authService.test.ts @@ -0,0 +1,200 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createAuthService, type AuthServiceDeps } from '../authService.js'; +import type { WorkspaceRequestContext } from '../types.js'; +import type { + DeviceFlowRegistry, + DeviceFlowPublicView, +} from '../../auth/deviceFlow.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeView(overrides?: Partial): DeviceFlowPublicView { + return { + deviceFlowId: 'df-1', + providerId: 'qwen-oauth', + status: 'pending', + userCode: 'ABCD-1234', + verificationUri: 'https://example.com/device', + createdAt: 1000, + ...overrides, + }; +} + +function makeMockRegistry(): DeviceFlowRegistry { + return { + start: vi.fn().mockResolvedValue({ view: makeView(), attached: false }), + get: vi.fn().mockReturnValue(makeView()), + cancel: vi.fn().mockReturnValue({ alreadyTerminal: false }), + listPending: vi.fn().mockReturnValue([makeView()]), + dispose: vi.fn(), + } as unknown as DeviceFlowRegistry; +} + +function makeDeps(registry?: DeviceFlowRegistry): AuthServiceDeps { + return { registry: registry ?? makeMockRegistry() }; +} + +function makeCtx(overrides?: Partial): WorkspaceRequestContext { + return { + originatorClientId: 'client-1', + sessionId: 'session-1', + route: 'POST /workspace/auth/device-flow', + workspaceCwd: '/workspace', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('AuthService', () => { + describe('startDeviceFlow', () => { + it('delegates to registry.start with providerId and initiatorClientId', async () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + const ctx = makeCtx(); + + const result = await svc.startDeviceFlow(ctx, { providerId: 'qwen-oauth' }); + + expect(registry.start).toHaveBeenCalledWith({ + providerId: 'qwen-oauth', + initiatorClientId: 'client-1', + }); + expect(result.view.deviceFlowId).toBe('df-1'); + expect(result.attached).toBe(false); + }); + + it('omits initiatorClientId when ctx has no originatorClientId', async () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + const ctx = makeCtx({ originatorClientId: undefined }); + + await svc.startDeviceFlow(ctx, { providerId: 'qwen-oauth' }); + + expect(registry.start).toHaveBeenCalledWith({ + providerId: 'qwen-oauth', + }); + }); + + it('returns attached: true when registry reports take-over', async () => { + const registry = makeMockRegistry(); + (registry.start as ReturnType).mockResolvedValue({ + view: makeView(), + attached: true, + }); + const svc = createAuthService(makeDeps(registry)); + + const result = await svc.startDeviceFlow(makeCtx(), { providerId: 'qwen-oauth' }); + + expect(result.attached).toBe(true); + }); + }); + + describe('getDeviceFlow', () => { + it('delegates to registry.get and returns the view', () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.getDeviceFlow(makeCtx(), 'df-1'); + + expect(registry.get).toHaveBeenCalledWith('df-1'); + expect(result?.deviceFlowId).toBe('df-1'); + }); + + it('returns undefined for unknown id', () => { + const registry = makeMockRegistry(); + (registry.get as ReturnType).mockReturnValue(undefined); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.getDeviceFlow(makeCtx(), 'unknown'); + + expect(result).toBeUndefined(); + }); + }); + + describe('cancelDeviceFlow', () => { + it('delegates to registry.cancel with deviceFlowId and originatorClientId', () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + const ctx = makeCtx(); + + const result = svc.cancelDeviceFlow(ctx, 'df-1'); + + expect(registry.cancel).toHaveBeenCalledWith('df-1', 'client-1'); + expect(result).toEqual({ alreadyTerminal: false }); + }); + + it('returns undefined for unknown id', () => { + const registry = makeMockRegistry(); + (registry.cancel as ReturnType).mockReturnValue(undefined); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.cancelDeviceFlow(makeCtx(), 'unknown'); + + expect(result).toBeUndefined(); + }); + + it('returns alreadyTerminal: true for terminal flows', () => { + const registry = makeMockRegistry(); + (registry.cancel as ReturnType).mockReturnValue({ alreadyTerminal: true }); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.cancelDeviceFlow(makeCtx(), 'df-1'); + + expect(result).toEqual({ alreadyTerminal: true }); + }); + }); + + describe('listPendingDeviceFlows', () => { + it('delegates to registry.listPending', () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.listPendingDeviceFlows(makeCtx()); + + expect(registry.listPending).toHaveBeenCalled(); + expect(result).toHaveLength(1); + expect(result[0]!.deviceFlowId).toBe('df-1'); + }); + + it('returns empty array when no pending flows', () => { + const registry = makeMockRegistry(); + (registry.listPending as ReturnType).mockReturnValue([]); + const svc = createAuthService(makeDeps(registry)); + + const result = svc.listPendingDeviceFlows(makeCtx()); + + expect(result).toEqual([]); + }); + }); + + describe('getAuthStatus', () => { + it('returns pending flows from registry', async () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + + const result = await svc.getAuthStatus(makeCtx()); + + expect(result.pendingFlows).toHaveLength(1); + expect(result.pendingFlows[0]!.deviceFlowId).toBe('df-1'); + }); + + it('returns authenticated: false (baseline — no token check yet)', async () => { + const registry = makeMockRegistry(); + const svc = createAuthService(makeDeps(registry)); + + const result = await svc.getAuthStatus(makeCtx()); + + expect(result.authenticated).toBe(false); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts new file mode 100644 index 00000000000..9a6547ba565 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/facade.test.ts @@ -0,0 +1,608 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// --------------------------------------------------------------------------- +// Mock sub-service factories — inline return values (no external refs). +// --------------------------------------------------------------------------- + +vi.mock('../fileService.js', () => ({ + createFileService: vi.fn(() => ({ + resolve: vi.fn(), + stat: vi.fn(), + readText: vi.fn(), + readBytes: vi.fn(), + readBytesWindow: vi.fn(), + list: vi.fn(), + glob: vi.fn(), + writeTextAtomic: vi.fn(), + writeTextOverwrite: vi.fn(), + edit: vi.fn(), + })), +})); + +vi.mock('../authService.js', () => ({ + createAuthService: vi.fn(() => ({ + startDeviceFlow: vi.fn(), + getDeviceFlow: vi.fn(), + cancelDeviceFlow: vi.fn(), + listPendingDeviceFlows: vi.fn().mockReturnValue([]), + getAuthStatus: vi.fn(), + })), +})); + +vi.mock('../agentsService.js', () => ({ + createAgentsService: vi.fn(() => ({ + listAgents: vi.fn(), + getAgent: vi.fn(), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + })), +})); + +vi.mock('../memoryService.js', () => ({ + createMemoryService: vi.fn(() => ({ + list: vi.fn(), + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + })), +})); + +// Mock @qwen-code/qwen-code-core to avoid the undici dependency chain. +// This is required so @qwen-code/acp-bridge/status can load (it imports +// SkillError from core). +vi.mock('@qwen-code/qwen-code-core', () => { + class SkillError extends Error { + code: string; + constructor(message: string, code: string) { + super(message); + this.name = 'SkillError'; + this.code = code; + } + } + return { SkillError }; +}); + +const { createDaemonWorkspaceService } = await import('../index.js'); +import type { + DaemonWorkspaceServiceDeps, + WorkspaceRequestContext, +} from '../types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDeps( + overrides: Partial = {}, +): DaemonWorkspaceServiceDeps { + return { + boundWorkspace: '/workspace', + contextFilename: 'QWEN.md', + fsFactory: { + forRequest: vi.fn(), + } as unknown as DaemonWorkspaceServiceDeps['fsFactory'], + deviceFlowRegistry: undefined, + subagentManager: undefined, + persistDisabledTools: vi.fn().mockResolvedValue(undefined), + queryWorkspaceStatus: vi + .fn() + .mockImplementation((_method: string, idle: () => unknown) => + Promise.resolve(idle()), + ), + invokeWorkspaceCommand: vi.fn().mockResolvedValue({ + serverName: 'test', + restarted: true, + durationMs: 42, + }), + publishWorkspaceEvent: vi.fn(), + knownClientIds: vi.fn().mockReturnValue(new Set(['client-1'])), + ...overrides, + }; +} + +function makeCtx( + overrides: Partial = {}, +): WorkspaceRequestContext { + return { + route: 'TEST /test', + workspaceCwd: '/workspace', + originatorClientId: 'client-1', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('createDaemonWorkspaceService', () => { + describe('sub-service exposure', () => { + it('exposes file, auth, agents, and memory sub-services', () => { + const svc = createDaemonWorkspaceService(makeDeps()); + expect(svc.file).toBeDefined(); + expect(svc.auth).toBeDefined(); + expect(svc.agents).toBeDefined(); + expect(svc.memory).toBeDefined(); + }); + + it('file sub-service has expected methods', () => { + const svc = createDaemonWorkspaceService(makeDeps()); + expect(typeof svc.file.resolve).toBe('function'); + expect(typeof svc.file.readText).toBe('function'); + expect(typeof svc.file.writeTextAtomic).toBe('function'); + }); + + it('auth sub-service has expected methods', () => { + const svc = createDaemonWorkspaceService(makeDeps()); + expect(typeof svc.auth.startDeviceFlow).toBe('function'); + expect(typeof svc.auth.listPendingDeviceFlows).toBe('function'); + }); + + it('agents sub-service has expected methods', () => { + const svc = createDaemonWorkspaceService(makeDeps()); + expect(typeof svc.agents.listAgents).toBe('function'); + expect(typeof svc.agents.createAgent).toBe('function'); + }); + + it('memory sub-service has expected methods', () => { + const svc = createDaemonWorkspaceService(makeDeps()); + expect(typeof svc.memory.list).toBe('function'); + expect(typeof svc.memory.write).toBe('function'); + }); + }); + + describe('status methods', () => { + it('getWorkspaceMcpStatus delegates to queryWorkspaceStatus with correct method', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValue({ v: 1, servers: [] }); + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus }), + ); + + await svc.getWorkspaceMcpStatus(makeCtx()); + + expect(queryWorkspaceStatus).toHaveBeenCalledWith( + 'qwen/status/workspace/mcp', + expect.any(Function), + ); + }); + + it('getWorkspaceMcpStatus idle fallback returns correct envelope', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockImplementation((_m: string, idle: () => unknown) => + Promise.resolve(idle()), + ); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + boundWorkspace: '/my/ws', + }), + ); + + const result = await svc.getWorkspaceMcpStatus(makeCtx()); + + expect(result.workspaceCwd).toBe('/my/ws'); + expect(result.initialized).toBe(false); + expect(result.servers).toEqual([]); + }); + + it('getWorkspaceSkillsStatus delegates with correct method', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValue({ v: 1, skills: [] }); + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus }), + ); + + await svc.getWorkspaceSkillsStatus(makeCtx()); + + expect(queryWorkspaceStatus).toHaveBeenCalledWith( + 'qwen/status/workspace/skills', + expect.any(Function), + ); + }); + + it('getWorkspaceSkillsStatus idle fallback returns correct envelope', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockImplementation((_m: string, idle: () => unknown) => + Promise.resolve(idle()), + ); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + boundWorkspace: '/ws', + }), + ); + + const result = await svc.getWorkspaceSkillsStatus(makeCtx()); + + expect(result.workspaceCwd).toBe('/ws'); + expect(result.initialized).toBe(false); + expect(result.skills).toEqual([]); + }); + + it('getWorkspaceProvidersStatus delegates with correct method', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValue({ v: 1, providers: [] }); + const svc = createDaemonWorkspaceService( + makeDeps({ queryWorkspaceStatus }), + ); + + await svc.getWorkspaceProvidersStatus(makeCtx()); + + expect(queryWorkspaceStatus).toHaveBeenCalledWith( + 'qwen/status/workspace/providers', + expect.any(Function), + ); + }); + + it('getWorkspaceEnvStatus uses statusProvider instead of queryWorkspaceStatus', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockResolvedValue({ v: 1, cells: [] }); + const statusProvider: DaemonWorkspaceServiceDeps['statusProvider'] = { + getEnvStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: '/workspace', + initialized: true, + acpChannelLive: false, + cells: [ + { kind: 'runtime', name: 'node', status: 'ok', present: true }, + ], + }), + getDaemonPreflightCells: vi.fn().mockResolvedValue([]), + }; + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + statusProvider, + }), + ); + + const result = await svc.getWorkspaceEnvStatus(makeCtx()); + + // Env status is daemon-local — queryWorkspaceStatus must NOT be called. + expect(queryWorkspaceStatus).not.toHaveBeenCalled(); + expect(statusProvider.getEnvStatus).toHaveBeenCalledWith( + '/workspace', + false, + ); + expect(result.initialized).toBe(true); + }); + + it('getWorkspaceEnvStatus fallback has acpChannelLive=false when no statusProvider', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockImplementation((_m: string, idle: () => unknown) => + Promise.resolve(idle()), + ); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + statusProvider: undefined, + }), + ); + + const result = await svc.getWorkspaceEnvStatus(makeCtx()); + + expect(result.acpChannelLive).toBe(false); + expect(result.initialized).toBe(true); + }); + + it('getWorkspacePreflightStatus queries ACP only when channel is live', async () => { + const queryWorkspaceStatus = vi.fn().mockResolvedValue({ + cells: [{ kind: 'auth', status: 'ok', locality: 'acp' }], + }); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + isChannelLive: () => true, + }), + ); + + await svc.getWorkspacePreflightStatus(makeCtx()); + + expect(queryWorkspaceStatus).toHaveBeenCalledWith( + 'qwen/status/workspace/preflight', + expect.any(Function), + ); + }); + + it('getWorkspacePreflightStatus idle fallback includes ACP placeholder cells', async () => { + const queryWorkspaceStatus = vi + .fn() + .mockImplementation((_m: string, idle: () => unknown) => + Promise.resolve(idle()), + ); + const svc = createDaemonWorkspaceService( + makeDeps({ + queryWorkspaceStatus, + isChannelLive: () => false, + }), + ); + + const result = await svc.getWorkspacePreflightStatus(makeCtx()); + + expect(result.acpChannelLive).toBe(false); + // When no statusProvider is given, daemon cells are empty; only ACP idle cells. + const acpCells = result.cells.filter((c) => c.locality === 'acp'); + expect(acpCells.length).toBe(6); + expect(acpCells.every((c) => c.status === 'not_started')).toBe(true); + // queryWorkspaceStatus should NOT be called when channel is not live. + expect(queryWorkspaceStatus).not.toHaveBeenCalled(); + }); + }); + + describe('setWorkspaceToolEnabled', () => { + it('calls persistDisabledTools with workspace, toolName, and enabled', async () => { + const persistDisabledTools = vi.fn().mockResolvedValue(undefined); + const svc = createDaemonWorkspaceService( + makeDeps({ + persistDisabledTools, + boundWorkspace: '/my/workspace', + }), + ); + + await svc.setWorkspaceToolEnabled(makeCtx(), 'Bash', false); + + expect(persistDisabledTools).toHaveBeenCalledWith( + '/my/workspace', + 'Bash', + false, + ); + }); + + it('publishes tool_toggled event with originatorClientId', async () => { + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ publishWorkspaceEvent }), + ); + + await svc.setWorkspaceToolEnabled( + makeCtx({ originatorClientId: 'c-42' }), + 'Read', + true, + ); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'tool_toggled', + data: { toolName: 'Read', enabled: true }, + originatorClientId: 'c-42', + }); + }); + + it('returns the toolName and enabled state', async () => { + const svc = createDaemonWorkspaceService(makeDeps()); + + const result = await svc.setWorkspaceToolEnabled( + makeCtx(), + 'WebSearch', + false, + ); + + expect(result).toEqual({ toolName: 'WebSearch', enabled: false }); + }); + }); + + describe('restartMcpServer', () => { + it('calls invokeWorkspaceCommand with correct method and params', async () => { + const invokeWorkspaceCommand = vi.fn().mockResolvedValue({ + serverName: 'myServer', + restarted: true, + durationMs: 100, + }); + const svc = createDaemonWorkspaceService( + makeDeps({ invokeWorkspaceCommand }), + ); + + await svc.restartMcpServer(makeCtx(), 'myServer'); + + expect(invokeWorkspaceCommand).toHaveBeenCalledWith( + 'qwen/control/workspace/mcp/restart', + { serverName: 'myServer' }, + { timeoutMs: 300_000 }, + ); + }); + + it('passes entryIndex when provided', async () => { + const invokeWorkspaceCommand = vi.fn().mockResolvedValue({ + serverName: 's', + restarted: true, + durationMs: 50, + }); + const svc = createDaemonWorkspaceService( + makeDeps({ invokeWorkspaceCommand }), + ); + + await svc.restartMcpServer(makeCtx(), 'poolServer', { entryIndex: 3 }); + + expect(invokeWorkspaceCommand).toHaveBeenCalledWith( + 'qwen/control/workspace/mcp/restart', + { serverName: 'poolServer', entryIndex: 3 }, + { timeoutMs: 300_000 }, + ); + }); + + it('publishes mcp_server_restarted event after success', async () => { + const publishWorkspaceEvent = vi.fn(); + const invokeResult = { serverName: 'x', restarted: true, durationMs: 10 }; + const invokeWorkspaceCommand = vi.fn().mockResolvedValue(invokeResult); + const svc = createDaemonWorkspaceService( + makeDeps({ + invokeWorkspaceCommand, + publishWorkspaceEvent, + }), + ); + + await svc.restartMcpServer(makeCtx({ originatorClientId: 'c-7' }), 'x'); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'mcp_server_restarted', + data: { serverName: 'x', durationMs: 10 }, + originatorClientId: 'c-7', + }); + }); + + it('returns the result from invokeWorkspaceCommand', async () => { + const invokeResult = { + serverName: 'srv', + restarted: false, + skipped: true, + reason: 'disabled', + }; + const invokeWorkspaceCommand = vi.fn().mockResolvedValue(invokeResult); + const svc = createDaemonWorkspaceService( + makeDeps({ invokeWorkspaceCommand }), + ); + + const result = await svc.restartMcpServer(makeCtx(), 'srv'); + + expect(result).toEqual(invokeResult); + }); + }); + + describe('initWorkspace', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'facade-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('creates a new file and returns action=created', async () => { + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + publishWorkspaceEvent, + }), + ); + + const result = await svc.initWorkspace( + makeCtx({ workspaceCwd: tmpDir }), + {}, + ); + + expect(result.action).toBe('created'); + expect(result.path).toBe(path.join(tmpDir, 'QWEN.md')); + const stat = await fs.stat(result.path); + expect(stat.isFile()).toBe(true); + }); + + it('publishes workspace_initialized event on create', async () => { + const publishWorkspaceEvent = vi.fn(); + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + publishWorkspaceEvent, + }), + ); + + await svc.initWorkspace(makeCtx({ originatorClientId: 'c-9' }), {}); + + expect(publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'workspace_initialized', + data: { path: path.join(tmpDir, 'QWEN.md'), action: 'created' }, + originatorClientId: 'c-9', + }); + }); + + it('returns noop when file exists but is whitespace-only', async () => { + const target = path.join(tmpDir, 'QWEN.md'); + await fs.writeFile(target, ' \n ', 'utf8'); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + }), + ); + + const result = await svc.initWorkspace(makeCtx(), {}); + + expect(result.action).toBe('noop'); + }); + + it('throws when file has content and force is not set', async () => { + const target = path.join(tmpDir, 'QWEN.md'); + await fs.writeFile(target, '# Hello', 'utf8'); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + }), + ); + + await expect(svc.initWorkspace(makeCtx(), {})).rejects.toThrow( + /already exists/, + ); + }); + + it('overwrites existing file when force=true', async () => { + const target = path.join(tmpDir, 'QWEN.md'); + await fs.writeFile(target, '# Existing content', 'utf8'); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + }), + ); + + const result = await svc.initWorkspace(makeCtx(), { force: true }); + + expect(result.action).toBe('overwrote'); + const content = await fs.readFile(target, 'utf8'); + expect(content).toBe(''); + }); + + it('throws for escaping filename', async () => { + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: '../escape.md', + }), + ); + + await expect(svc.initWorkspace(makeCtx(), {})).rejects.toThrow( + /resolves outside/, + ); + }); + + it('throws when target is a symlink', async () => { + const realFile = path.join(tmpDir, 'real.md'); + const linkFile = path.join(tmpDir, 'QWEN.md'); + await fs.writeFile(realFile, '', 'utf8'); + await fs.symlink(realFile, linkFile); + + const svc = createDaemonWorkspaceService( + makeDeps({ + boundWorkspace: tmpDir, + contextFilename: 'QWEN.md', + }), + ); + + await expect(svc.initWorkspace(makeCtx(), {})).rejects.toThrow(/symlink/); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts b/packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts new file mode 100644 index 00000000000..821c25fb970 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/fileService.test.ts @@ -0,0 +1,263 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi } from 'vitest'; +import { createFileService, type FileServiceDeps } from '../fileService.js'; +import type { WorkspaceRequestContext } from '../types.js'; +import type { + WorkspaceFileSystem, + WorkspaceFileSystemFactory, + ResolvedPath, + ReadMeta, + ContentHash, + WriteTextAtomicOutcome, +} from '../../fs/index.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeMockFs(): WorkspaceFileSystem { + return { + resolve: vi.fn().mockResolvedValue('/workspace/foo.txt' as ResolvedPath), + stat: vi.fn().mockResolvedValue({ kind: 'file', sizeBytes: 42, modifiedMs: 1000 }), + readText: vi.fn().mockResolvedValue({ + content: 'hello', + meta: { lineEnding: 'lf' } as ReadMeta, + }), + readBytes: vi.fn().mockResolvedValue(Buffer.from('bytes')), + readBytesWindow: vi.fn().mockResolvedValue({ + buffer: Buffer.from('window'), + sizeBytes: 6, + returnedBytes: 6, + offset: 0, + truncated: false, + }), + list: vi.fn().mockResolvedValue([{ name: 'a.ts', kind: 'file', ignored: false }]), + glob: vi.fn().mockResolvedValue(['/workspace/a.ts' as ResolvedPath]), + writeTextAtomic: vi.fn().mockResolvedValue({ + created: true, + sizeBytes: 5, + hash: 'sha256:abc' as ContentHash, + meta: { lineEnding: 'lf' } as ReadMeta, + } satisfies WriteTextAtomicOutcome), + writeTextOverwrite: vi.fn().mockResolvedValue({ + created: false, + sizeBytes: 5, + hash: 'sha256:abc' as ContentHash, + meta: { lineEnding: 'lf' } as ReadMeta, + } satisfies WriteTextAtomicOutcome), + writeText: vi.fn().mockResolvedValue(undefined), + edit: vi.fn().mockResolvedValue({ writtenBytes: 5 }), + editAtomic: vi.fn().mockResolvedValue({ writtenBytes: 5 }), + } as unknown as WorkspaceFileSystem; +} + +function makeDeps(mockFs: WorkspaceFileSystem): FileServiceDeps { + const forRequest = vi.fn().mockReturnValue(mockFs); + return { + fsFactory: { forRequest } as unknown as WorkspaceFileSystemFactory, + boundWorkspace: '/workspace', + }; +} + +function makeCtx(overrides?: Partial): WorkspaceRequestContext { + return { + originatorClientId: 'client-1', + sessionId: 'session-1', + route: 'GET /file', + workspaceCwd: '/workspace', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('FileService', () => { + describe('forRequest context mapping', () => { + it('calls forRequest with correct context fields from WorkspaceRequestContext', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + + await svc.resolve(ctx, 'foo.txt', 'read'); + + expect(deps.fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'client-1', + sessionId: 'session-1', + route: 'GET /file', + }); + }); + + it('passes undefined originatorClientId when not provided (reads work without client identity)', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + await svc.readText(ctx, '/workspace/foo.txt' as ResolvedPath); + + expect(deps.fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: undefined, + sessionId: 'session-1', + route: 'GET /file', + }); + }); + + it('passes undefined sessionId when not provided', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx({ sessionId: undefined }); + + await svc.stat(ctx, '/workspace/foo.txt' as ResolvedPath); + + expect(deps.fsFactory.forRequest).toHaveBeenCalledWith({ + originatorClientId: 'client-1', + sessionId: undefined, + route: 'GET /file', + }); + }); + }); + + describe('method delegation', () => { + it('resolve delegates to WorkspaceFileSystem.resolve', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + + const result = await svc.resolve(ctx, 'foo.txt', 'read'); + + expect(mockFs.resolve).toHaveBeenCalledWith('foo.txt', 'read'); + expect(result).toBe('/workspace/foo.txt'); + }); + + it('stat delegates to WorkspaceFileSystem.stat', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + + const result = await svc.stat(ctx, p); + + expect(mockFs.stat).toHaveBeenCalledWith(p); + expect(result).toEqual({ kind: 'file', sizeBytes: 42, modifiedMs: 1000 }); + }); + + it('readText delegates with options', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + const opts = { maxBytes: 1024 }; + + const result = await svc.readText(ctx, p, opts); + + expect(mockFs.readText).toHaveBeenCalledWith(p, opts); + expect(result.content).toBe('hello'); + }); + + it('readBytes delegates to WorkspaceFileSystem.readBytes', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + + const result = await svc.readBytes(ctx, p); + + expect(mockFs.readBytes).toHaveBeenCalledWith(p, undefined); + expect(result).toEqual(Buffer.from('bytes')); + }); + + it('readBytesWindow delegates with options', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + const opts = { offset: 10, maxBytes: 100 }; + + const result = await svc.readBytesWindow(ctx, p, opts); + + expect(mockFs.readBytesWindow).toHaveBeenCalledWith(p, opts); + expect(result.returnedBytes).toBe(6); + }); + + it('list delegates to WorkspaceFileSystem.list', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace' as ResolvedPath; + + const result = await svc.list(ctx, p); + + expect(mockFs.list).toHaveBeenCalledWith(p, undefined); + expect(result).toHaveLength(1); + expect(result[0]!.name).toBe('a.ts'); + }); + + it('glob delegates to WorkspaceFileSystem.glob', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + + const result = await svc.glob(ctx, '**/*.ts'); + + expect(mockFs.glob).toHaveBeenCalledWith('**/*.ts', undefined); + expect(result).toEqual(['/workspace/a.ts']); + }); + + it('writeTextAtomic delegates to WorkspaceFileSystem.writeTextAtomic', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + const opts = { mode: 'create' as const }; + + const result = await svc.writeTextAtomic(ctx, p, 'new content', opts); + + expect(mockFs.writeTextAtomic).toHaveBeenCalledWith(p, 'new content', opts); + expect(result.created).toBe(true); + }); + + it('writeTextOverwrite delegates to WorkspaceFileSystem.writeTextOverwrite', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + + const result = await svc.writeTextOverwrite(ctx, p, 'overwritten'); + + expect(mockFs.writeTextOverwrite).toHaveBeenCalledWith(p, 'overwritten'); + expect(result.created).toBe(false); + }); + + it('edit delegates to writeTextAtomic (CAS-gated write alias)', async () => { + const mockFs = makeMockFs(); + const deps = makeDeps(mockFs); + const svc = createFileService(deps); + const ctx = makeCtx(); + const p = '/workspace/foo.txt' as ResolvedPath; + const opts = { mode: 'replace' as const, expectedHash: 'sha256:abc' as ContentHash }; + + const result = await svc.edit(ctx, p, 'edited', opts); + + expect(mockFs.writeTextAtomic).toHaveBeenCalledWith(p, 'edited', opts); + expect(result.hash).toBe('sha256:abc'); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts b/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts new file mode 100644 index 00000000000..ef3a41fe9b2 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/integration.test.ts @@ -0,0 +1,422 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Integration tests: verify DaemonWorkspaceService wiring through + * Express routes. Uses `createServeApp` with an injected mock workspace + * service to confirm routes delegate correctly and pass the expected + * `WorkspaceRequestContext`. + */ + +import * as path from 'node:path'; +import { describe, it, expect, vi } from 'vitest'; +import request from 'supertest'; +import { createServeApp } from '../../server.js'; +import type { ServeOptions } from '../../types.js'; +import type { + DaemonWorkspaceService, + WorkspaceRequestContext, +} from '../types.js'; +import type { AcpSessionBridge } from '../../acpSessionBridge.js'; + +// --------------------------------------------------------------------------- +// Test fixtures +// --------------------------------------------------------------------------- + +const WS_BOUND = path.resolve(path.sep, 'work', 'integration-ws'); + +const baseOpts: ServeOptions = { + hostname: '127.0.0.1', + port: 4170, + mode: 'http-bridge', + workspace: WS_BOUND, +}; + +// --------------------------------------------------------------------------- +// Minimal fake bridge — only implements what the routes under test need +// beyond the workspace service itself. +// --------------------------------------------------------------------------- + +function minimalBridge( + overrides: { knownClientIds?: string[] } = {}, +): AcpSessionBridge { + const knownIds = new Set(overrides.knownClientIds ?? []); + return { + permissionPolicy: 'first-responder', + get sessionCount() { + return 0; + }, + get pendingPermissionCount() { + return 0; + }, + spawnOrAttach: vi.fn().mockResolvedValue({ + sessionId: 'fake-0', + workspaceCwd: WS_BOUND, + attached: false, + clientId: 'client-0', + }), + loadSession: vi.fn().mockResolvedValue({ + sessionId: 'fake-0', + workspaceCwd: WS_BOUND, + attached: false, + clientId: 'client-0', + state: {}, + }), + resumeSession: vi.fn().mockResolvedValue({ + sessionId: 'fake-0', + workspaceCwd: WS_BOUND, + attached: false, + clientId: 'client-0', + state: {}, + }), + sendPrompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), + cancelSession: vi.fn().mockResolvedValue(undefined), + subscribeEvents: vi.fn().mockReturnValue( + (async function* () { + /* empty */ + })(), + ), + respondToPermission: vi.fn().mockReturnValue(true), + respondToSessionPermission: vi.fn().mockReturnValue(true), + listWorkspaceSessions: vi.fn().mockReturnValue([]), + getWorkspaceMcpStatus: vi.fn().mockResolvedValue({}), + getWorkspaceSkillsStatus: vi.fn().mockResolvedValue({}), + getWorkspaceProvidersStatus: vi.fn().mockResolvedValue({}), + getWorkspaceEnvStatus: vi.fn().mockResolvedValue({}), + getWorkspacePreflightStatus: vi.fn().mockResolvedValue({}), + getSessionContextStatus: vi.fn().mockResolvedValue({}), + getSessionSupportedCommandsStatus: vi.fn().mockResolvedValue({}), + setSessionModel: vi.fn().mockResolvedValue({}), + setSessionApprovalMode: vi.fn().mockResolvedValue({}), + generateSessionRecap: vi + .fn() + .mockResolvedValue({ sessionId: '', recap: null }), + setWorkspaceToolEnabled: vi + .fn() + .mockResolvedValue({ toolName: '', enabled: true }), + initWorkspace: vi.fn().mockResolvedValue({ path: '', action: 'created' }), + restartMcpServer: vi + .fn() + .mockResolvedValue({ serverName: '', restarted: true, durationMs: 1 }), + closeSession: vi.fn().mockResolvedValue(undefined), + updateSessionMetadata: vi.fn().mockReturnValue({}), + recordHeartbeat: vi.fn().mockReturnValue({ sessionId: '', lastSeenAt: 0 }), + getHeartbeatState: vi.fn().mockReturnValue(undefined), + publishWorkspaceEvent: vi.fn(), + knownClientIds: vi.fn().mockReturnValue(knownIds), + killSession: vi.fn().mockResolvedValue(undefined), + detachClient: vi.fn().mockResolvedValue(undefined), + shutdown: vi.fn().mockResolvedValue(undefined), + killAllSync: vi.fn(), + } as unknown as AcpSessionBridge; +} + +// --------------------------------------------------------------------------- +// Mock workspace service factory +// --------------------------------------------------------------------------- + +function mockWorkspaceService( + overrides: Partial = {}, +): DaemonWorkspaceService { + return { + file: {} as DaemonWorkspaceService['file'], + auth: { + startDeviceFlow: vi.fn(), + getDeviceFlow: vi.fn(), + cancelDeviceFlow: vi.fn(), + listPendingDeviceFlows: vi.fn().mockReturnValue([]), + getAuthStatus: vi + .fn() + .mockResolvedValue({ authenticated: false, pendingFlows: [] }), + } as unknown as DaemonWorkspaceService['auth'], + agents: { + listAgents: vi.fn().mockResolvedValue({ agents: [] }), + getAgent: vi.fn(), + createAgent: vi.fn(), + updateAgent: vi.fn(), + deleteAgent: vi.fn(), + } as unknown as DaemonWorkspaceService['agents'], + memory: { + list: vi.fn().mockResolvedValue({ files: [] }), + read: vi.fn(), + write: vi.fn(), + delete: vi.fn(), + } as unknown as DaemonWorkspaceService['memory'], + getWorkspaceMcpStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + discoveryState: 'completed', + servers: [{ kind: 'mcp_server', name: 'test-server', status: 'ok' }], + }), + getWorkspaceSkillsStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [{ name: 'test-skill', source: 'project' }], + }), + getWorkspaceProvidersStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + providers: [], + }), + getWorkspaceEnvStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + acpChannelLive: true, + cells: [{ kind: 'env_var', name: 'NODE_ENV', status: 'ok' }], + }), + getWorkspacePreflightStatus: vi.fn().mockResolvedValue({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + acpChannelLive: false, + cells: [], + }), + setWorkspaceToolEnabled: vi + .fn() + .mockResolvedValue({ toolName: 'Bash', enabled: true }), + initWorkspace: vi.fn().mockResolvedValue({ + path: path.resolve(WS_BOUND, 'QWEN.md'), + action: 'created', + }), + restartMcpServer: vi.fn().mockResolvedValue({ + serverName: 'test-server', + restarted: true, + durationMs: 42, + }), + ...overrides, + } as DaemonWorkspaceService; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function createTestApp(opts?: { + workspaceOverrides?: Partial; + knownClientIds?: string[]; + token?: string; +}) { + const workspace = mockWorkspaceService(opts?.workspaceOverrides); + const bridge = minimalBridge({ knownClientIds: opts?.knownClientIds }); + const appOpts = opts?.token ? { ...baseOpts, token: opts.token } : baseOpts; + const app = createServeApp(appOpts, undefined, { + bridge, + workspace, + boundWorkspace: WS_BOUND, + }); + return { app, workspace, bridge }; +} + +function hostHeader() { + return { Host: `127.0.0.1:${baseOpts.port}` }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('workspace service REST integration', () => { + // ------------------------------------------------------------------------- + // GET /workspace/mcp + // ------------------------------------------------------------------------- + + describe('GET /workspace/mcp', () => { + it('returns 200 with the result from workspace.getWorkspaceMcpStatus', async () => { + const { app, workspace } = createTestApp(); + const res = await request(app).get('/workspace/mcp').set(hostHeader()); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + servers: [{ kind: 'mcp_server', name: 'test-server', status: 'ok' }], + }); + expect(workspace.getWorkspaceMcpStatus).toHaveBeenCalledTimes(1); + }); + + it('passes correct WorkspaceRequestContext to the service', async () => { + const { app, workspace } = createTestApp(); + await request(app).get('/workspace/mcp').set(hostHeader()); + + const ctx = (workspace.getWorkspaceMcpStatus as ReturnType) + .mock.calls[0][0] as WorkspaceRequestContext; + expect(ctx.route).toBe('GET /workspace/mcp'); + expect(ctx.workspaceCwd).toBe(WS_BOUND); + // No client-id header on GET — should be undefined + expect(ctx.originatorClientId).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // GET /workspace/skills + // ------------------------------------------------------------------------- + + describe('GET /workspace/skills', () => { + it('returns 200 with the result from workspace.getWorkspaceSkillsStatus', async () => { + const { app, workspace } = createTestApp(); + const res = await request(app).get('/workspace/skills').set(hostHeader()); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + skills: [{ name: 'test-skill', source: 'project' }], + }); + expect(workspace.getWorkspaceSkillsStatus).toHaveBeenCalledTimes(1); + }); + + it('passes correct WorkspaceRequestContext to the service', async () => { + const { app, workspace } = createTestApp(); + await request(app).get('/workspace/skills').set(hostHeader()); + + const ctx = ( + workspace.getWorkspaceSkillsStatus as ReturnType + ).mock.calls[0][0] as WorkspaceRequestContext; + expect(ctx.route).toBe('GET /workspace/skills'); + expect(ctx.workspaceCwd).toBe(WS_BOUND); + expect(ctx.originatorClientId).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // GET /workspace/env + // ------------------------------------------------------------------------- + + describe('GET /workspace/env', () => { + it('returns 200 with the result from workspace.getWorkspaceEnvStatus', async () => { + const { app, workspace } = createTestApp(); + const res = await request(app).get('/workspace/env').set(hostHeader()); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + v: 1, + workspaceCwd: WS_BOUND, + initialized: true, + acpChannelLive: true, + cells: [{ kind: 'env_var', name: 'NODE_ENV', status: 'ok' }], + }); + expect(workspace.getWorkspaceEnvStatus).toHaveBeenCalledTimes(1); + }); + + it('passes correct WorkspaceRequestContext to the service', async () => { + const { app, workspace } = createTestApp(); + await request(app).get('/workspace/env').set(hostHeader()); + + const ctx = (workspace.getWorkspaceEnvStatus as ReturnType) + .mock.calls[0][0] as WorkspaceRequestContext; + expect(ctx.route).toBe('GET /workspace/env'); + expect(ctx.workspaceCwd).toBe(WS_BOUND); + expect(ctx.originatorClientId).toBeUndefined(); + }); + }); + + // ------------------------------------------------------------------------- + // POST /workspace/init + // ------------------------------------------------------------------------- + + describe('POST /workspace/init', () => { + it('returns 200 with the result from workspace.initWorkspace', async () => { + const { app, workspace } = createTestApp({ token: 'test-secret' }); + const res = await request(app) + .post('/workspace/init') + .set(hostHeader()) + .set('Authorization', 'Bearer test-secret') + .send({}); + + expect(res.status).toBe(200); + expect(res.body).toMatchObject({ + path: path.resolve(WS_BOUND, 'QWEN.md'), + action: 'created', + }); + expect(workspace.initWorkspace).toHaveBeenCalledTimes(1); + }); + + it('passes force:false opts when body is empty', async () => { + const { app, workspace } = createTestApp({ token: 'test-secret' }); + await request(app) + .post('/workspace/init') + .set(hostHeader()) + .set('Authorization', 'Bearer test-secret') + .send({}); + + const [ctx, opts] = (workspace.initWorkspace as ReturnType) + .mock.calls[0] as [WorkspaceRequestContext, { force?: boolean }]; + expect(ctx.route).toBe('POST /workspace/init'); + expect(ctx.workspaceCwd).toBe(WS_BOUND); + expect(opts).toEqual({ force: false }); + }); + + it('passes force:true when body has force=true', async () => { + const { app, workspace } = createTestApp({ token: 'test-secret' }); + await request(app) + .post('/workspace/init') + .set(hostHeader()) + .set('Authorization', 'Bearer test-secret') + .send({ force: true }); + + const [_ctx, opts] = (workspace.initWorkspace as ReturnType) + .mock.calls[0] as [WorkspaceRequestContext, { force?: boolean }]; + expect(opts).toEqual({ force: true }); + }); + + it('passes client identity through WorkspaceRequestContext', async () => { + const { app, workspace } = createTestApp({ + token: 'test-secret', + knownClientIds: ['my-client'], + }); + await request(app) + .post('/workspace/init') + .set(hostHeader()) + .set('Authorization', 'Bearer test-secret') + .set('X-Qwen-Client-Id', 'my-client') + .send({}); + + const ctx = (workspace.initWorkspace as ReturnType).mock + .calls[0][0] as WorkspaceRequestContext; + expect(ctx.originatorClientId).toBe('my-client'); + expect(ctx.route).toBe('POST /workspace/init'); + expect(ctx.workspaceCwd).toBe(WS_BOUND); + }); + + it('401 without bearer token on token-protected daemon', async () => { + const { app, workspace } = createTestApp({ token: 'test-secret' }); + const res = await request(app) + .post('/workspace/init') + .set(hostHeader()) + .send({}); + + expect(res.status).toBe(401); + expect(workspace.initWorkspace).not.toHaveBeenCalled(); + }); + }); + + // ------------------------------------------------------------------------- + // Cross-cutting: workspace service is NOT called on the bridge + // ------------------------------------------------------------------------- + + describe('workspace service isolation from bridge', () => { + it('GET /workspace/mcp uses injected workspace service, not bridge', async () => { + const { app, workspace } = createTestApp(); + await request(app).get('/workspace/mcp').set(hostHeader()); + + // The workspace service should be called + expect(workspace.getWorkspaceMcpStatus).toHaveBeenCalledTimes(1); + }); + + it('GET /workspace/skills uses injected workspace service, not bridge', async () => { + const { app, workspace } = createTestApp(); + await request(app).get('/workspace/skills').set(hostHeader()); + + expect(workspace.getWorkspaceSkillsStatus).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts b/packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts new file mode 100644 index 00000000000..5935a187490 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/__tests__/memoryService.test.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import * as os from 'node:os'; + +// Mock @qwen-code/qwen-code-core so the service can import it without +// pulling in the full dependency tree. +vi.mock('@qwen-code/qwen-code-core', () => ({ + Storage: { + getGlobalQwenDir: () => '/mock-home/.qwen', + }, + getAllGeminiMdFilenames: () => ['QWEN.md', 'AGENTS.md'], + writeWorkspaceContextFile: vi.fn(), +})); + +// Mock @qwen-code/acp-bridge/status +vi.mock('@qwen-code/acp-bridge/status', () => { + const STATUS_SCHEMA_VERSION = 1; + return { + STATUS_SCHEMA_VERSION, + createIdleWorkspaceMemoryStatus: (workspaceCwd: string) => ({ + v: STATUS_SCHEMA_VERSION, + workspaceCwd, + initialized: false, + files: [], + totalBytes: 0, + fileCount: 0, + ruleCount: 0, + }), + }; +}); + +// Import the mocked modules so we can control behavior in tests +const { writeWorkspaceContextFile } = (await import( + '@qwen-code/qwen-code-core' +)) as unknown as { writeWorkspaceContextFile: ReturnType }; + +import { + createMemoryService, + type MemoryServiceDeps, +} from '../memoryService.js'; +import type { WorkspaceRequestContext } from '../types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeDeps(overrides?: Partial): MemoryServiceDeps { + return { + boundWorkspace: '/workspace', + publishWorkspaceEvent: vi.fn(), + knownClientIds: () => new Set(['client-1', 'client-2']), + ...overrides, + }; +} + +function makeCtx( + overrides?: Partial, +): WorkspaceRequestContext { + return { + originatorClientId: 'client-1', + route: 'POST /workspace/memory', + workspaceCwd: '/workspace', + ...overrides, + }; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('MemoryService', () => { + let tmpDir: string; + + beforeEach(async () => { + vi.clearAllMocks(); + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'memoryService-')); + }); + + describe('list', () => { + it('returns idle status when no memory files exist', async () => { + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.list(makeCtx({ workspaceCwd: tmpDir })); + + expect(result.initialized).toBe(false); + expect(result.files).toHaveLength(0); + expect(result.totalBytes).toBe(0); + }); + + it('discovers workspace memory files', async () => { + // Create a QWEN.md in the workspace dir + const qwenMd = path.join(tmpDir, 'QWEN.md'); + await fs.writeFile(qwenMd, '# Memory\nSome content'); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.list(makeCtx({ workspaceCwd: tmpDir })); + + expect(result.initialized).toBe(true); + expect(result.files.length).toBeGreaterThanOrEqual(1); + const found = result.files.find((f) => f.path === qwenMd); + expect(found).toBeDefined(); + expect(found!.scope).toBe('workspace'); + expect(found!.bytes).toBeGreaterThan(0); + }); + + it('returns fileCount and totalBytes', async () => { + const content = '# Memory content here'; + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), content); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.list(makeCtx({ workspaceCwd: tmpDir })); + + expect(result.fileCount).toBe(1); + expect(result.totalBytes).toBe(Buffer.byteLength(content, 'utf8')); + }); + }); + + describe('read', () => { + it('reads workspace memory file content', async () => { + const content = '# Workspace Memory\n- entry 1'; + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), content); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.read( + makeCtx({ workspaceCwd: tmpDir }), + 'workspace', + ); + + expect(result.content).toBe(content); + expect(result.path).toBe(path.join(tmpDir, 'QWEN.md')); + }); + + it('throws when file does not exist', async () => { + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + await expect( + svc.read(makeCtx({ workspaceCwd: tmpDir }), 'workspace'), + ).rejects.toThrow(); + }); + }); + + describe('write', () => { + it('validates clientId before writing', async () => { + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: 'unknown-client' }); + + await expect( + svc.write(ctx, { + scope: 'workspace', + content: 'new entry', + mode: 'append', + }), + ).rejects.toThrow('not registered'); + + expect(writeWorkspaceContextFile).not.toHaveBeenCalled(); + }); + + it('allows mutation when clientId is undefined', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 42, + changed: true, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + const result = await svc.write(ctx, { + scope: 'workspace', + content: 'new entry', + mode: 'append', + }); + + expect(result.path).toBe('/workspace/QWEN.md'); + expect(writeWorkspaceContextFile).toHaveBeenCalled(); + }); + + it('allows mutation when clientId is in knownClientIds', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 100, + changed: true, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: 'client-2' }); + + const result = await svc.write(ctx, { + scope: 'workspace', + content: 'content', + mode: 'replace', + }); + + expect(result.path).toBe('/workspace/QWEN.md'); + expect(result.scope).toBe('workspace'); + expect(result.bytes).toBe(100); + }); + + it('delegates to writeWorkspaceContextFile with correct params', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 50, + changed: true, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + + await svc.write(makeCtx(), { + scope: 'workspace', + content: '- new memory entry', + mode: 'append', + }); + + expect(writeWorkspaceContextFile).toHaveBeenCalledWith({ + scope: 'workspace', + mode: 'append', + content: '- new memory entry', + projectRoot: '/workspace', + }); + }); + + it('publishes memory_changed event after successful write', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 50, + changed: true, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: 'client-1' }); + + await svc.write(ctx, { + scope: 'workspace', + content: 'entry', + mode: 'append', + }); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'memory_changed', + data: { + scope: 'workspace', + filePath: '/workspace/QWEN.md', + mode: 'append', + bytesWritten: 50, + }, + originatorClientId: 'client-1', + }); + }); + + it('does not publish event when write did not change anything', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 0, + changed: false, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + + await svc.write(makeCtx(), { + scope: 'workspace', + content: ' ', + mode: 'append', + }); + + expect(deps.publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + + it('does not include originatorClientId in event when undefined', async () => { + (writeWorkspaceContextFile as ReturnType).mockResolvedValue( + { + filePath: '/workspace/QWEN.md', + bytesWritten: 20, + changed: true, + }, + ); + + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + await svc.write(ctx, { + scope: 'workspace', + content: 'entry', + mode: 'append', + }); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'memory_changed', + data: expect.any(Object), + }); + }); + }); + + describe('delete', () => { + it('validates clientId before deleting', async () => { + const deps = makeDeps(); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: 'unknown-client' }); + + await expect(svc.delete(ctx, 'workspace')).rejects.toThrow( + 'not registered', + ); + }); + + it('allows mutation when clientId is undefined', async () => { + // Create a file to delete + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), 'content'); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + const result = await svc.delete(ctx, 'workspace'); + + expect(result.deleted).toBe(true); + }); + + it('returns deleted: true when file exists', async () => { + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), 'content'); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.delete(makeCtx(), 'workspace'); + + expect(result.deleted).toBe(true); + }); + + it('returns deleted: false when file does not exist', async () => { + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + const result = await svc.delete(makeCtx(), 'workspace'); + + expect(result.deleted).toBe(false); + }); + + it('publishes memory_changed event after successful deletion', async () => { + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), 'content'); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: 'client-1' }); + + await svc.delete(ctx, 'workspace'); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'memory_changed', + data: { + change: 'deleted', + key: 'workspace', + filePath: path.join(tmpDir, 'QWEN.md'), + }, + originatorClientId: 'client-1', + }); + }); + + it('does not publish event when file does not exist', async () => { + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + + await svc.delete(makeCtx(), 'workspace'); + + expect(deps.publishWorkspaceEvent).not.toHaveBeenCalled(); + }); + + it('does not include originatorClientId in event when undefined', async () => { + await fs.writeFile(path.join(tmpDir, 'QWEN.md'), 'content'); + + const deps = makeDeps({ boundWorkspace: tmpDir }); + const svc = createMemoryService(deps); + const ctx = makeCtx({ originatorClientId: undefined }); + + await svc.delete(ctx, 'workspace'); + + expect(deps.publishWorkspaceEvent).toHaveBeenCalledWith({ + type: 'memory_changed', + data: expect.any(Object), + }); + }); + }); +}); diff --git a/packages/cli/src/serve/workspace-service/agentsService.ts b/packages/cli/src/serve/workspace-service/agentsService.ts new file mode 100644 index 00000000000..9994b7159c4 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/agentsService.ts @@ -0,0 +1,259 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * AgentsService — workspace agent CRUD delegating to SubagentManager. + * + * Validates `originatorClientId` on mutations (create/update/delete) + * against `deps.knownClientIds()` and publishes workspace events + * after successful state changes. + */ + +import { + SubagentError, + SubagentErrorCode, + type SubagentConfig, + type SubagentLevel, + type SubagentManager, +} from '@qwen-code/qwen-code-core'; + +import { + STATUS_SCHEMA_VERSION, + type ServeWorkspaceAgentDetail, + type ServeWorkspaceAgentSummary, + type ServeWorkspaceAgentsStatus, +} from '@qwen-code/acp-bridge/status'; + +import type { + AgentsService, + CreateAgentParams, + UpdateAgentParams, + WorkspaceRequestContext, +} from './types.js'; + +import { validateClientId as validateClientIdShared } from './validation.js'; + +// --------------------------------------------------------------------------- +// Dependencies +// --------------------------------------------------------------------------- + +export interface AgentsServiceDeps { + /** The daemon-scoped SubagentManager instance. */ + subagentManager: SubagentManager; + /** Absolute path to the workspace root. */ + boundWorkspace: string; + /** Publish a workspace-wide event to all sessions' SSE buses. */ + publishWorkspaceEvent: (event: { + type: string; + data: unknown; + originatorClientId?: string; + }) => void; + /** Set of all currently known client ids across live sessions. */ + knownClientIds: () => ReadonlySet; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createAgentsService(deps: AgentsServiceDeps): AgentsService { + const { + subagentManager, + boundWorkspace, + publishWorkspaceEvent, + knownClientIds, + } = deps; + + function validateClientId(ctx: WorkspaceRequestContext): void { + validateClientIdShared(ctx, knownClientIds); + } + + function toSummary(config: SubagentConfig): ServeWorkspaceAgentSummary { + const summary: ServeWorkspaceAgentSummary = { + kind: 'agent', + name: config.name, + description: config.description, + level: config.level, + isBuiltin: config.isBuiltin === true || config.level === 'builtin', + hasTools: Array.isArray(config.tools) && config.tools.length > 0, + }; + if (config.model) summary.model = config.model; + if (config.color) summary.color = config.color; + if (config.background !== undefined) summary.background = config.background; + if (config.approvalMode) summary.approvalMode = config.approvalMode; + if (config.extensionName) summary.extensionName = config.extensionName; + if (config.filePath) summary.filePath = config.filePath; + return summary; + } + + function toDetail(config: SubagentConfig): ServeWorkspaceAgentDetail { + const detail: ServeWorkspaceAgentDetail = { + ...toSummary(config), + systemPrompt: config.systemPrompt, + }; + if (config.tools) detail.tools = [...config.tools]; + if (config.disallowedTools) { + detail.disallowedTools = [...config.disallowedTools]; + } + if (config.runConfig) { + const runConfig: ServeWorkspaceAgentDetail['runConfig'] = {}; + if (typeof config.runConfig.max_time_minutes === 'number') { + runConfig.max_time_minutes = config.runConfig.max_time_minutes; + } + if (typeof config.runConfig.max_turns === 'number') { + runConfig.max_turns = config.runConfig.max_turns; + } + detail.runConfig = runConfig; + } + return detail; + } + + return { + async listAgents( + _ctx: WorkspaceRequestContext, + ): Promise { + const agents = await subagentManager.listSubagents({ force: true }); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + agents: agents.map(toSummary), + }; + }, + + async getAgent( + ctx: WorkspaceRequestContext, + agentName: string, + ): Promise { + const config = await subagentManager.loadSubagent(agentName); + if (!config) return undefined; + return toDetail(config); + }, + + async createAgent( + ctx: WorkspaceRequestContext, + params: CreateAgentParams, + ): Promise { + validateClientId(ctx); + + const level: SubagentLevel = params.level === 'user' ? 'user' : 'project'; + + const config: SubagentConfig = { + name: params.name, + description: params.description, + systemPrompt: params.systemPrompt, + level, + }; + if (params.tools) config.tools = params.tools; + if (params.disallowedTools) + config.disallowedTools = params.disallowedTools; + if (params.model) config.model = params.model; + if (params.color) config.color = params.color; + if (params.background !== undefined) + config.background = params.background; + if (params.approvalMode) config.approvalMode = params.approvalMode; + if (params.runConfig) config.runConfig = params.runConfig; + + // Collision preflight: reject if agent already exists at the target level. + const existing = await subagentManager.loadSubagent(params.name, level); + if (existing) { + throw new Error(`agent_already_exists: ${params.name}`); + } + + await subagentManager.createSubagent(config, { level }); + + const created = await subagentManager.loadSubagent(params.name, level); + if (!created) { + throw new Error('Agent creation succeeded but reload failed'); + } + + publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'created', name: params.name, level }, + originatorClientId: ctx.originatorClientId, + }); + + return toDetail(created); + }, + + async updateAgent( + ctx: WorkspaceRequestContext, + agentName: string, + params: UpdateAgentParams, + ): Promise { + validateClientId(ctx); + + const existing = await subagentManager.loadSubagent(agentName); + if (!existing) { + throw new SubagentError( + `Subagent "${agentName}" not found`, + SubagentErrorCode.NOT_FOUND, + agentName, + ); + } + + const updates: Partial = {}; + if (params.description !== undefined) + updates.description = params.description; + if (params.systemPrompt !== undefined) + updates.systemPrompt = params.systemPrompt; + if (params.tools !== undefined) updates.tools = params.tools; + if (params.disallowedTools !== undefined) + updates.disallowedTools = params.disallowedTools; + if (params.model !== undefined) updates.model = params.model; + if (params.color !== undefined) updates.color = params.color; + if (params.background !== undefined) + updates.background = params.background; + if (params.approvalMode !== undefined) + updates.approvalMode = params.approvalMode; + if (params.runConfig !== undefined) updates.runConfig = params.runConfig; + + await subagentManager.updateSubagent(agentName, updates, existing.level); + + const updated = await subagentManager.loadSubagent( + agentName, + existing.level, + ); + if (!updated) { + throw new Error('Agent update succeeded but reload failed'); + } + + publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'updated', name: agentName, level: existing.level }, + originatorClientId: ctx.originatorClientId, + }); + + return toDetail(updated); + }, + + async deleteAgent( + ctx: WorkspaceRequestContext, + agentName: string, + ): Promise<{ deleted: boolean }> { + validateClientId(ctx); + + try { + await subagentManager.deleteSubagent(agentName); + } catch (err) { + if ( + err instanceof SubagentError && + err.code === SubagentErrorCode.NOT_FOUND + ) { + return { deleted: false }; + } + throw err; + } + + publishWorkspaceEvent({ + type: 'agent_changed', + data: { change: 'deleted', name: agentName }, + originatorClientId: ctx.originatorClientId, + }); + + return { deleted: true }; + }, + }; +} diff --git a/packages/cli/src/serve/workspace-service/authService.ts b/packages/cli/src/serve/workspace-service/authService.ts new file mode 100644 index 00000000000..d714b0c25b7 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/authService.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * AuthService — thin delegation layer wrapping DeviceFlowRegistry. + * + * Accepts `WorkspaceRequestContext` and maps to the appropriate + * `DeviceFlowRegistry` calls, threading `ctx.originatorClientId` + * as the clientId parameter where needed. + */ + +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; + +import type { + AuthService, + AuthStartDeviceFlowParams, + AuthStartDeviceFlowResult, + AuthCancelDeviceFlowResult, + WorkspaceRequestContext, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Dependencies +// --------------------------------------------------------------------------- + +export interface AuthServiceDeps { + registry: DeviceFlowRegistry; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createAuthService(deps: AuthServiceDeps): AuthService { + const { registry } = deps; + + return { + async startDeviceFlow( + ctx: WorkspaceRequestContext, + params: AuthStartDeviceFlowParams, + ): Promise { + const result = await registry.start({ + providerId: params.providerId, + ...(ctx.originatorClientId !== undefined + ? { initiatorClientId: ctx.originatorClientId } + : {}), + }); + return { view: result.view, attached: result.attached }; + }, + + getDeviceFlow(_ctx: WorkspaceRequestContext, deviceFlowId: string) { + return registry.get(deviceFlowId); + }, + + cancelDeviceFlow( + ctx: WorkspaceRequestContext, + deviceFlowId: string, + ): AuthCancelDeviceFlowResult | undefined { + return registry.cancel(deviceFlowId, ctx.originatorClientId); + }, + + listPendingDeviceFlows(_ctx: WorkspaceRequestContext) { + return registry.listPending(); + }, + + async getAuthStatus(_ctx: WorkspaceRequestContext) { + const pendingFlows = registry.listPending(); + return { authenticated: false, pendingFlows }; + }, + }; +} diff --git a/packages/cli/src/serve/workspace-service/fileService.ts b/packages/cli/src/serve/workspace-service/fileService.ts new file mode 100644 index 00000000000..93738b8755d --- /dev/null +++ b/packages/cli/src/serve/workspace-service/fileService.ts @@ -0,0 +1,87 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * FileService — thin delegation layer wrapping WorkspaceFileSystemFactory. + * + * Accepts `WorkspaceRequestContext` and constructs the appropriate + * `RequestContext` to call `fsFactory.forRequest(ctx)`, then delegates + * to the returned `WorkspaceFileSystem`. + */ + +import type { + WorkspaceFileSystemFactory, + WorkspaceFileSystem, + RequestContext, +} from '../fs/index.js'; + +import type { FileService, WorkspaceRequestContext } from './types.js'; + +// --------------------------------------------------------------------------- +// Dependencies +// --------------------------------------------------------------------------- + +export interface FileServiceDeps { + fsFactory: WorkspaceFileSystemFactory; + boundWorkspace: string; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createFileService(deps: FileServiceDeps): FileService { + function scopedFs(ctx: WorkspaceRequestContext): WorkspaceFileSystem { + const reqCtx: RequestContext = { + originatorClientId: ctx.originatorClientId, + sessionId: ctx.sessionId, + route: ctx.route, + }; + return deps.fsFactory.forRequest(reqCtx); + } + + return { + async resolve(ctx, input, intent) { + return scopedFs(ctx).resolve(input, intent); + }, + + async stat(ctx, p) { + return scopedFs(ctx).stat(p); + }, + + async readText(ctx, p, opts?) { + return scopedFs(ctx).readText(p, opts); + }, + + async readBytes(ctx, p, opts?) { + return scopedFs(ctx).readBytes(p, opts); + }, + + async readBytesWindow(ctx, p, opts?) { + return scopedFs(ctx).readBytesWindow(p, opts); + }, + + async list(ctx, p, opts?) { + return scopedFs(ctx).list(p, opts); + }, + + async glob(ctx, pattern, opts?) { + return scopedFs(ctx).glob(pattern, opts); + }, + + async writeTextAtomic(ctx, p, content, opts) { + return scopedFs(ctx).writeTextAtomic(p, content, opts); + }, + + async writeTextOverwrite(ctx, p, content) { + return scopedFs(ctx).writeTextOverwrite(p, content); + }, + + async edit(ctx, p, content, opts) { + return scopedFs(ctx).writeTextAtomic(p, content, opts); + }, + }; +} diff --git a/packages/cli/src/serve/workspace-service/index.ts b/packages/cli/src/serve/workspace-service/index.ts new file mode 100644 index 00000000000..24c370cafe9 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/index.ts @@ -0,0 +1,602 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * DaemonWorkspaceService facade factory. + * + * Public entry point that wires up all four sub-services (file, auth, + * agents, memory) and exposes workspace-scoped methods: status queries, + * tool toggle, init, and MCP server restart. + */ + +import { promises as fs, constants as fsConstants } from 'node:fs'; +import * as path from 'node:path'; + +import { + SERVE_STATUS_EXT_METHODS, + SERVE_CONTROL_EXT_METHODS, + STATUS_SCHEMA_VERSION, + createIdleWorkspaceMcpStatus, + createIdleWorkspaceSkillsStatus, + createIdleWorkspaceProvidersStatus, + createIdleEnvStatus, + createIdleAcpPreflightCells, + type ServeWorkspacePreflightStatus, +} from '@qwen-code/acp-bridge/status'; + +import { + WorkspaceInitPathEscapeError, + WorkspaceInitSymlinkError, + WorkspaceInitConflictError, + WorkspaceInitRaceError, + McpServerNotFoundError, + McpServerRestartFailedError, +} from '@qwen-code/acp-bridge/bridgeErrors'; + +import { mapDomainErrorToErrorKind } from '@qwen-code/acp-bridge/status'; + +import { writeStderrLine } from '../../utils/stdioHelpers.js'; + +import { createFileService } from './fileService.js'; +import { createAuthService } from './authService.js'; +import { createAgentsService } from './agentsService.js'; +import { createMemoryService } from './memoryService.js'; + +import type { DeviceFlowRegistry } from '../auth/deviceFlow.js'; + +import type { + DaemonWorkspaceService, + DaemonWorkspaceServiceDeps, + WorkspaceRequestContext, + RestartMcpServerResult, +} from './types.js'; + +// Re-export types for consumers. +export type { + DaemonWorkspaceService, + DaemonWorkspaceServiceDeps, + WorkspaceRequestContext, + RestartMcpServerResult, +} from './types.js'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** + * Walk up from `inputPath` until we find an ancestor that exists on disk, + * then `realpath` it. Mirrors `canonicalizeExistingAncestor` in bridge.ts. + */ +async function canonicalizeExistingAncestor( + inputPath: string, +): Promise { + let current = inputPath; + while (true) { + try { + return await fs.realpath(current); + } catch (err) { + const code = (err as NodeJS.ErrnoException | null | undefined)?.code; + if (code !== 'ENOENT' && code !== 'ENOTDIR' && code !== 'ELOOP') { + throw err; + } + const parent = path.dirname(current); + if (parent === current) throw err; + current = parent; + } + } +} + +/** + * Post-open parent re-verification. After a successful `fs.open(..., 'wx')` + * or `O_NOFOLLOW` open, re-canonicalize the parent directory and verify it + * still resolves within the workspace. Closes the TOCTOU window between + * the pre-open canonicalize and the open. On failure, closes the fd + * (for creates) and throws `WorkspaceInitSymlinkError`. + */ +async function verifyParentPostOpen( + target: string, + wsCanonical: string, + fh: import('node:fs/promises').FileHandle, +): Promise { + const parentCanonical = await canonicalizeExistingAncestor( + path.dirname(target), + ); + const within = + parentCanonical === wsCanonical || + parentCanonical.startsWith(wsCanonical + path.sep); + if (within) return; + // Close the fd before throwing. Do NOT fs.unlink(target) — the path + // now resolves through a potentially attacker-controlled parent symlink. + await fh.close().catch(() => {}); + throw new WorkspaceInitSymlinkError( + target, + 'parent', + `Workspace context file ${JSON.stringify(target)}'s parent moved ` + + `outside the workspace between the pre-open canonicalize and ` + + `the post-open verify (parent canonicalizes to ${JSON.stringify(parentCanonical)}, ` + + `workspace canonicalizes to ${JSON.stringify(wsCanonical)}). ` + + `Refusing to write — investigate the concurrent writer or the ` + + `parent-directory permissions.`, + ); +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createDaemonWorkspaceService( + deps: DaemonWorkspaceServiceDeps, +): DaemonWorkspaceService { + const { + boundWorkspace, + contextFilename, + fsFactory, + deviceFlowRegistry, + subagentManager, + statusProvider, + isChannelLive, + persistDisabledTools, + queryWorkspaceStatus, + invokeWorkspaceCommand, + publishWorkspaceEvent, + knownClientIds, + } = deps; + + // -- Sub-services -- + const file = createFileService({ + fsFactory, + boundWorkspace, + }); + + // Device-flow registry may be absent during early boot (it's constructed + // inside createServeApp and injected later). When absent, create a stub + // that throws descriptive errors at call time rather than crashing on + // undefined property access. + const auth = deviceFlowRegistry + ? createAuthService({ registry: deviceFlowRegistry }) + : createAuthService({ + registry: new Proxy({} as DeviceFlowRegistry, { + get(_target, prop) { + return () => { + throw new Error( + `DeviceFlowRegistry not available: cannot call '${String(prop)}' ` + + `— the registry has not been injected yet.`, + ); + }; + }, + }), + }); + + // SubagentManager may also be absent during early boot. + const agents = subagentManager + ? createAgentsService({ + subagentManager: + subagentManager as import('@qwen-code/qwen-code-core').SubagentManager, + boundWorkspace, + publishWorkspaceEvent, + knownClientIds, + }) + : createAgentsService({ + subagentManager: new Proxy( + {} as import('@qwen-code/qwen-code-core').SubagentManager, + { + get(_target, prop) { + return () => { + throw new Error( + `SubagentManager not available: cannot call '${String(prop)}' ` + + `— the manager has not been injected yet.`, + ); + }; + }, + }, + ), + boundWorkspace, + publishWorkspaceEvent, + knownClientIds, + }); + + const memory = createMemoryService({ + boundWorkspace, + publishWorkspaceEvent, + knownClientIds, + }); + + // -- Facade -- + return { + file, + auth, + agents, + memory, + + // -- Status queries (delegate to ACP child via queryWorkspaceStatus) -- + + async getWorkspaceMcpStatus(_ctx: WorkspaceRequestContext) { + return queryWorkspaceStatus(SERVE_STATUS_EXT_METHODS.workspaceMcp, () => + createIdleWorkspaceMcpStatus(boundWorkspace), + ); + }, + + async getWorkspaceSkillsStatus(_ctx: WorkspaceRequestContext) { + return queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceSkills, + () => createIdleWorkspaceSkillsStatus(boundWorkspace), + ); + }, + + async getWorkspaceProvidersStatus(_ctx: WorkspaceRequestContext) { + return queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspaceProviders, + () => createIdleWorkspaceProvidersStatus(boundWorkspace), + ); + }, + + async getWorkspaceEnvStatus(_ctx: WorkspaceRequestContext) { + // Env status is answered daemon-locally from process state — no ACP + // query needed. The old bridge used statusProvider.getEnvStatus() + // directly; replicate that behavior here. + const acpChannelLive = isChannelLive?.() ?? false; + if (!statusProvider) { + return createIdleEnvStatus(boundWorkspace, acpChannelLive); + } + try { + return await statusProvider.getEnvStatus( + boundWorkspace, + acpChannelLive, + ); + } catch (err) { + writeStderrLine( + `qwen serve: getEnvStatus failed: ${err instanceof Error ? err.message : String(err)}`, + ); + return createIdleEnvStatus(boundWorkspace, acpChannelLive); + } + }, + + async getWorkspacePreflightStatus(_ctx: WorkspaceRequestContext) { + // Preflight stitches two halves: + // 1. Daemon cells from statusProvider.getDaemonPreflightCells() — always local + // 2. ACP cells from queryWorkspaceStatus (live ACP child) or idle placeholders + const acpChannelLive = isChannelLive?.() ?? false; + const idleCells = createIdleAcpPreflightCells(); + + // Get daemon cells (local, no ACP query). + let daemonCells: ServeWorkspacePreflightStatus['cells'] = []; + if (statusProvider) { + try { + daemonCells = + await statusProvider.getDaemonPreflightCells(boundWorkspace); + } catch (err) { + // Daemon cells failing is non-fatal; proceed with empty. + writeStderrLine( + `qwen serve: getDaemonPreflightCells failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + } + + // Get ACP cells — either from live child or idle placeholders. + let acpCells: ServeWorkspacePreflightStatus['cells'] = idleCells; + let errors: ServeWorkspacePreflightStatus['errors'] | undefined; + + if (acpChannelLive) { + try { + const acpResult = await queryWorkspaceStatus( + SERVE_STATUS_EXT_METHODS.workspacePreflight, + () => ({ cells: idleCells }), + ); + // The ACP response may contain only ACP-locality cells. + if (acpResult && 'cells' in acpResult) { + const result = acpResult as { + cells: ServeWorkspacePreflightStatus['cells']; + errors?: ServeWorkspacePreflightStatus['errors']; + }; + // Filter to only ACP cells from the ACP response (daemon cells come from our provider). + acpCells = result.cells.filter((c) => c.locality === 'acp'); + errors = result.errors; + } + } catch (err) { + // ACP query failed — fall back to idle placeholders and report error. + acpCells = idleCells; + const errorKind = mapDomainErrorToErrorKind(err); + errors = [ + { + kind: 'preflight', + status: 'error', + error: err instanceof Error ? err.message : String(err), + ...(errorKind ? { errorKind } : {}), + }, + ]; + } + } + + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true, + acpChannelLive, + cells: [...daemonCells, ...acpCells], + ...(errors ? { errors } : {}), + } as ServeWorkspacePreflightStatus; + }, + + // -- Mutations -- + + async setWorkspaceToolEnabled( + ctx: WorkspaceRequestContext, + toolName: string, + enabled: boolean, + ) { + await persistDisabledTools(boundWorkspace, toolName, enabled); + publishWorkspaceEvent({ + type: 'tool_toggled', + data: { toolName, enabled }, + originatorClientId: ctx.originatorClientId, + }); + return { toolName, enabled }; + }, + + async initWorkspace( + ctx: WorkspaceRequestContext, + opts: { force?: boolean }, + ) { + // Resolve the context filename against the workspace root. + const filename = contextFilename; + const target = path.resolve(boundWorkspace, filename); + + // Textual boundary check: reject paths that escape the workspace. + const withinWorkspace = + target === boundWorkspace || + target.startsWith(boundWorkspace + path.sep); + if (!withinWorkspace) { + throw new WorkspaceInitPathEscapeError(filename, boundWorkspace); + } + + // Symlink check on parent path: canonicalize and verify. + const wsCanonical = await fs.realpath(boundWorkspace); + const parentCanonical = await canonicalizeExistingAncestor( + path.dirname(target), + ); + const parentWithinWorkspace = + parentCanonical === wsCanonical || + parentCanonical.startsWith(wsCanonical + path.sep); + if (!parentWithinWorkspace) { + throw new WorkspaceInitSymlinkError( + target, + 'parent', + `Configured workspace context filename ${JSON.stringify(filename)} ` + + `has a parent path that resolves outside the bound workspace ` + + `(parent canonicalizes to ${JSON.stringify(parentCanonical)}, ` + + `workspace canonicalizes to ${JSON.stringify(wsCanonical)}). ` + + `Refusing to write — replace any symlinked parent directory ` + + `with a real directory before re-running init.`, + ); + } + + // Symlink check on the target itself. + try { + const lst = await fs.lstat(target); + if (lst.isSymbolicLink()) { + throw new WorkspaceInitSymlinkError( + target, + 'target', + `Workspace context file ${JSON.stringify(target)} is a symlink. ` + + `Refusing to follow it for write — replace the symlink with a ` + + `regular file (or remove it) before re-running init.`, + ); + } + } catch (err) { + if (err instanceof WorkspaceInitSymlinkError) throw err; + const code = (err as { code?: unknown } | null | undefined)?.code; + if (code !== 'ENOENT') throw err; + // ENOENT — target doesn't exist; fresh create is fine. + } + + // Determine action based on existing file state. + let action: 'created' | 'overwrote' | 'noop' = 'created'; + try { + const existing = await fs.readFile(target, 'utf8'); + if (existing.trim().length > 0) { + const existingSize = Buffer.byteLength(existing, 'utf8'); + if (opts.force !== true) { + throw new WorkspaceInitConflictError(target, existingSize); + } + action = 'overwrote'; + } else { + // Whitespace-only file: treat as noop. + action = 'noop'; + } + } catch (err) { + if (err instanceof WorkspaceInitConflictError) throw err; + const code = (err as { code?: unknown } | null | undefined)?.code; + if (code !== 'ENOENT') throw err; + // ENOENT — fall through to create. + } + + // Write the file. + if (action === 'created') { + // Atomic exclusive create to close TOCTOU window. + let fh: import('node:fs/promises').FileHandle; + try { + fh = await fs.open(target, 'wx'); + } catch (err) { + const code = (err as { code?: unknown } | null | undefined)?.code; + if (code === 'EEXIST') { + throw new WorkspaceInitRaceError( + target, + 'eexist', + `Workspace context file ${JSON.stringify(target)} appeared ` + + `between our absence check and the create — refusing to ` + + `proceed (a regular file or symlink was just placed at the ` + + `target path, and following it could escape the workspace).`, + ); + } + throw err; + } + try { + // Post-open parent re-verification narrows the parent-symlink + // TOCTOU window between `canonicalizeExistingAncestor` and + // `fs.open`. Must verify before writing content. + await verifyParentPostOpen(target, wsCanonical, fh); + await fh.writeFile('', 'utf8'); + } finally { + await fh.close(); + } + } else if (action === 'overwrote') { + // Use O_WRONLY | O_NOFOLLOW to avoid following symlinks that + // may have been swapped in between our lstat check and this open. + let overwriteFh: import('node:fs/promises').FileHandle; + try { + overwriteFh = await fs.open( + target, + fsConstants.O_WRONLY | (fsConstants.O_NOFOLLOW ?? 0), + ); + } catch (err) { + const code = (err as { code?: unknown } | null | undefined)?.code; + if (code === 'ELOOP') { + throw new WorkspaceInitSymlinkError( + target, + 'target', + `Workspace context file ${JSON.stringify(target)} could not ` + + `be opened with O_NOFOLLOW (ELOOP); the path may have been ` + + `swapped to a symlink between the content check and the ` + + `overwrite. Refusing to follow it.`, + ); + } + if (code === 'ENOENT') { + throw new WorkspaceInitRaceError( + target, + 'enoent', + `Workspace context file ${JSON.stringify(target)} was deleted ` + + `between the content check and the overwrite (likely a ` + + `concurrent writer). Refusing to recreate blindly; rerun init.`, + ); + } + throw err; + } + try { + // Post-open parent re-verification (same as create path). + await verifyParentPostOpen(target, wsCanonical, overwriteFh); + // Truncate AFTER verify, using the fd we already hold. + await overwriteFh.truncate(0); + } finally { + await overwriteFh.close(); + } + } + // action === 'noop' — no write needed. + + publishWorkspaceEvent({ + type: 'workspace_initialized', + data: { path: target, action }, + originatorClientId: ctx.originatorClientId, + }); + + return { path: target, action }; + }, + + async restartMcpServer( + ctx: WorkspaceRequestContext, + serverName: string, + opts?: { entryIndex?: number }, + ) { + const params: Record = { serverName }; + if (opts?.entryIndex !== undefined) { + params['entryIndex'] = opts.entryIndex; + } + + let result: RestartMcpServerResult; + try { + result = await invokeWorkspaceCommand( + SERVE_CONTROL_EXT_METHODS.workspaceMcpRestart, + params, + { timeoutMs: 300_000 }, + ); + } catch (err) { + // Translate structured ACP error payloads into typed bridge errors. + const data = (err as { data?: unknown })?.data; + if (data && typeof data === 'object') { + const kind = (data as { errorKind?: unknown }).errorKind; + const sn = (data as { serverName?: unknown }).serverName; + if (kind === 'mcp_server_not_found' && typeof sn === 'string') { + throw new McpServerNotFoundError(sn); + } + if (kind === 'mcp_restart_failed' && typeof sn === 'string') { + const status = (data as { mcpStatus?: unknown }).mcpStatus; + throw new McpServerRestartFailedError( + sn, + typeof status === 'string' ? status : 'unknown', + ); + } + } + throw err; + } + + // Pool-mode: fan out per-entry events. + if ('entries' in result) { + const entries = Array.isArray(result.entries) ? result.entries : []; + if (!Array.isArray(result.entries)) { + writeStderrLine( + `qwen serve: pool restart response carried 'entries' field ` + + `but it is not an array (server=${serverName}); ` + + `treating as empty.`, + ); + } + for (const entry of entries) { + if ( + typeof entry !== 'object' || + entry === null || + typeof (entry as { entryIndex?: unknown }).entryIndex !== 'number' + ) { + writeStderrLine( + `qwen serve: skipping malformed pool restart entry ` + + `(server=${serverName}): ${JSON.stringify(entry)}`, + ); + continue; + } + if (entry.restarted) { + publishWorkspaceEvent({ + type: 'mcp_server_restarted', + data: { + serverName, + durationMs: entry.durationMs ?? 0, + entryIndex: entry.entryIndex, + }, + originatorClientId: ctx.originatorClientId, + }); + } else { + publishWorkspaceEvent({ + type: 'mcp_server_restart_refused', + data: { + serverName, + reason: 'restart_failed', + entryIndex: entry.entryIndex, + ...(entry.reason ? { details: entry.reason } : {}), + }, + originatorClientId: ctx.originatorClientId, + }); + } + } + } else if (result.restarted === true) { + publishWorkspaceEvent({ + type: 'mcp_server_restarted', + data: { + serverName: result.serverName, + durationMs: result.durationMs, + }, + originatorClientId: ctx.originatorClientId, + }); + } else { + publishWorkspaceEvent({ + type: 'mcp_server_restart_refused', + data: { + serverName: result.serverName, + reason: (result as { reason?: string }).reason, + }, + originatorClientId: ctx.originatorClientId, + }); + } + + return result; + }, + }; +} diff --git a/packages/cli/src/serve/workspace-service/memoryService.ts b/packages/cli/src/serve/workspace-service/memoryService.ts new file mode 100644 index 00000000000..0a2dabeb755 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/memoryService.ts @@ -0,0 +1,211 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * MemoryService — workspace memory (QWEN.md / AGENTS.md) read/write + * operations with clientId validation and workspace event publishing. + * + * Delegates to `writeWorkspaceContextFile` for mutations and uses + * filesystem-based discovery (same logic as `workspaceMemory.ts` + * routes) for reads. Validates `originatorClientId` on write/delete + * mutations against `deps.knownClientIds()`. + */ + +import { promises as fs } from 'node:fs'; +import * as path from 'node:path'; +import { + Storage, + getAllGeminiMdFilenames, + writeWorkspaceContextFile, +} from '@qwen-code/qwen-code-core'; + +import { + createIdleWorkspaceMemoryStatus, + type ServeWorkspaceMemoryFile, + type ServeWorkspaceMemoryStatus, + STATUS_SCHEMA_VERSION, +} from '@qwen-code/acp-bridge/status'; + +import type { + MemoryService, + WriteMemoryParams, + WriteMemoryResult, + WorkspaceRequestContext, +} from './types.js'; + +import { validateClientId as validateClientIdShared } from './validation.js'; + +// --------------------------------------------------------------------------- +// Dependencies +// --------------------------------------------------------------------------- + +export interface MemoryServiceDeps { + /** Absolute path to the workspace root. */ + boundWorkspace: string; + /** Publish a workspace-wide event to all sessions' SSE buses. */ + publishWorkspaceEvent: (event: { + type: string; + data: unknown; + originatorClientId?: string; + }) => void; + /** Set of all currently known client ids across live sessions. */ + knownClientIds: () => ReadonlySet; +} + +// --------------------------------------------------------------------------- +// Factory +// --------------------------------------------------------------------------- + +export function createMemoryService(deps: MemoryServiceDeps): MemoryService { + const { boundWorkspace, publishWorkspaceEvent, knownClientIds } = deps; + + function validateClientId(ctx: WorkspaceRequestContext): void { + validateClientIdShared(ctx, knownClientIds); + } + + /** Resolve the memory file path for a given scope key ('global' | 'workspace'). */ + function resolveMemoryFilePath(key: string): string { + const filenames = getAllGeminiMdFilenames(); + const filename = filenames[0] ?? 'QWEN.md'; + if (key === 'global') { + return path.join(Storage.getGlobalQwenDir(), filename); + } + return path.join(boundWorkspace, filename); + } + + return { + async list( + _ctx: WorkspaceRequestContext, + ): Promise { + const filenames = new Set(getAllGeminiMdFilenames()); + const files: ServeWorkspaceMemoryFile[] = []; + + // Discover workspace-root memory files + for (const filename of filenames) { + const candidate = path.join(boundWorkspace, filename); + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) { + files.push({ + kind: 'memory_file', + path: candidate, + scope: 'workspace', + bytes: stat.size, + }); + } + } catch { + // ENOENT is expected — file just doesn't exist yet + } + } + + // Discover global memory files + const globalDir = Storage.getGlobalQwenDir(); + for (const filename of filenames) { + const candidate = path.join(globalDir, filename); + try { + const stat = await fs.stat(candidate); + if (stat.isFile()) { + files.push({ + kind: 'memory_file', + path: candidate, + scope: 'global', + bytes: stat.size, + }); + } + } catch { + // ENOENT is expected + } + } + + if (files.length === 0) { + return createIdleWorkspaceMemoryStatus(boundWorkspace); + } + + const totalBytes = files.reduce((acc, f) => acc + f.bytes, 0); + return { + v: STATUS_SCHEMA_VERSION, + workspaceCwd: boundWorkspace, + initialized: true, + files, + totalBytes, + fileCount: files.length, + ruleCount: 0, + }; + }, + + async read( + ctx: WorkspaceRequestContext, + key: string, + ): Promise<{ content: string; path: string }> { + const filePath = resolveMemoryFilePath(key); + const content = await fs.readFile(filePath, 'utf8'); + return { content, path: filePath }; + }, + + async write( + ctx: WorkspaceRequestContext, + params: WriteMemoryParams, + ): Promise { + validateClientId(ctx); + + const result = await writeWorkspaceContextFile({ + scope: params.scope, + mode: params.mode, + content: params.content, + projectRoot: boundWorkspace, + }); + + if (result.changed) { + publishWorkspaceEvent({ + type: 'memory_changed', + data: { + scope: params.scope, + filePath: result.filePath, + mode: params.mode, + bytesWritten: result.bytesWritten, + }, + originatorClientId: ctx.originatorClientId, + }); + } + + return { + path: result.filePath, + scope: params.scope, + bytes: result.bytesWritten, + }; + }, + + async delete( + ctx: WorkspaceRequestContext, + key: string, + ): Promise<{ deleted: boolean }> { + validateClientId(ctx); + + const filePath = resolveMemoryFilePath(key); + + try { + await fs.unlink(filePath); + } catch (err) { + if ( + typeof err === 'object' && + err !== null && + (err as { code?: string }).code === 'ENOENT' + ) { + return { deleted: false }; + } + throw err; + } + + publishWorkspaceEvent({ + type: 'memory_changed', + data: { change: 'deleted', key, filePath }, + originatorClientId: ctx.originatorClientId, + }); + + return { deleted: true }; + }, + }; +} diff --git a/packages/cli/src/serve/workspace-service/types.ts b/packages/cli/src/serve/workspace-service/types.ts new file mode 100644 index 00000000000..ef415ff64cd --- /dev/null +++ b/packages/cli/src/serve/workspace-service/types.ts @@ -0,0 +1,489 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type definitions for the DaemonWorkspaceService layer. + * + * Each sub-service gets a `WorkspaceRequestContext` as its first + * parameter so audit, client-identity, and route metadata flow + * naturally without threading individual fields. + */ + +import type { + ServeWorkspaceMcpStatus, + ServeWorkspaceSkillsStatus, + ServeWorkspaceProvidersStatus, + ServeWorkspaceEnvStatus, + ServeWorkspacePreflightStatus, + ServeWorkspaceMemoryStatus, + ServeWorkspaceAgentsStatus, + ServeWorkspaceAgentDetail, + ServeContextFileScope, + DaemonStatusProvider, +} from '@qwen-code/acp-bridge'; + +import type { + WorkspaceFileSystemFactory, + ResolvedPath, + FsStat, + FsEntry, + ReadMeta, + ReadTextOptions, + ReadBytesOptions, + ReadBytesOutcome, + ListOptions, + GlobOptions, + WriteTextAtomicOptions, + WriteTextAtomicOutcome, +} from '../fs/index.js'; + +import type { + DeviceFlowRegistry, + DeviceFlowPublicView, + DeviceFlowProviderId, +} from '../auth/deviceFlow.js'; + +// --------------------------------------------------------------------------- +// WorkspaceRequestContext +// --------------------------------------------------------------------------- + +/** + * Per-request context threaded to all sub-service methods. Extends the + * filesystem `RequestContext` with optional fields the workspace layer + * needs for audit correlation and client-identity gating. + * + * `originatorClientId` is optional because file reads work without a + * registered client (e.g. stateless GET routes that don't carry the + * header). `sessionId` is optional for audit correlation on + * workspace-scoped routes that have no session context. + */ +export interface WorkspaceRequestContext { + /** Daemon-stamped client identity (from X-Qwen-Client-Id header). */ + originatorClientId?: string; + /** ACP session id for cross-correlating audit + session events. */ + sessionId?: string; + /** Route name like 'GET /workspace/memory' for audit. */ + route: string; + /** Absolute path to the workspace root — trust boundary. */ + workspaceCwd: string; +} + +// --------------------------------------------------------------------------- +// FileService +// --------------------------------------------------------------------------- + +/** + * Workspace filesystem operations. Thin delegation layer over + * `WorkspaceFileSystem` that accepts `WorkspaceRequestContext` + * instead of requiring callers to construct a `RequestContext` + * themselves. + */ +export interface FileService { + resolve( + ctx: WorkspaceRequestContext, + input: string, + intent: 'read' | 'write' | 'stat' | 'list' | 'glob', + ): Promise; + + stat(ctx: WorkspaceRequestContext, p: ResolvedPath): Promise; + + readText( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + opts?: ReadTextOptions, + ): Promise<{ content: string; meta: ReadMeta }>; + + readBytes( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + opts?: ReadBytesOptions, + ): Promise; + + readBytesWindow( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + opts?: ReadBytesOptions, + ): Promise; + + list( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + opts?: ListOptions, + ): Promise; + + glob( + ctx: WorkspaceRequestContext, + pattern: string, + opts?: GlobOptions, + ): Promise; + + writeTextAtomic( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + content: string, + opts: WriteTextAtomicOptions, + ): Promise; + + writeTextOverwrite( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + content: string, + ): Promise; + + edit( + ctx: WorkspaceRequestContext, + p: ResolvedPath, + content: string, + opts: WriteTextAtomicOptions, + ): Promise; +} + +// --------------------------------------------------------------------------- +// AuthService +// --------------------------------------------------------------------------- + +/** Parameters for starting a device flow. */ +export interface AuthStartDeviceFlowParams { + providerId: DeviceFlowProviderId; +} + +/** Result of starting (or attaching to) a device flow. */ +export interface AuthStartDeviceFlowResult { + view: DeviceFlowPublicView; + attached: boolean; +} + +/** Result of cancelling a device flow. */ +export interface AuthCancelDeviceFlowResult { + alreadyTerminal: boolean; +} + +/** + * Authentication operations scoped to the workspace daemon. Wraps + * `DeviceFlowRegistry` and auth-status queries. + */ +export interface AuthService { + /** Start a new device flow (or attach to an existing one for the same provider). */ + startDeviceFlow( + ctx: WorkspaceRequestContext, + params: AuthStartDeviceFlowParams, + ): Promise; + + /** Get the public view of a device flow by id. */ + getDeviceFlow( + ctx: WorkspaceRequestContext, + deviceFlowId: string, + ): DeviceFlowPublicView | undefined; + + /** Cancel a pending device flow. Returns undefined for unknown ids. */ + cancelDeviceFlow( + ctx: WorkspaceRequestContext, + deviceFlowId: string, + ): AuthCancelDeviceFlowResult | undefined; + + /** List currently pending device flows. */ + listPendingDeviceFlows(ctx: WorkspaceRequestContext): DeviceFlowPublicView[]; + + /** Get overall auth status for the workspace. */ + getAuthStatus( + ctx: WorkspaceRequestContext, + ): Promise<{ authenticated: boolean; pendingFlows: DeviceFlowPublicView[] }>; +} + +// --------------------------------------------------------------------------- +// AgentsService +// --------------------------------------------------------------------------- + +/** Parameters for creating a new agent. */ +export interface CreateAgentParams { + name: string; + description: string; + systemPrompt: string; + level?: 'project' | 'user'; + tools?: string[]; + disallowedTools?: string[]; + model?: string; + color?: string; + background?: boolean; + approvalMode?: string; + runConfig?: { max_time_minutes?: number; max_turns?: number }; +} + +/** Parameters for updating an existing agent. */ +export interface UpdateAgentParams { + description?: string; + systemPrompt?: string; + tools?: string[]; + disallowedTools?: string[]; + model?: string; + color?: string; + background?: boolean; + approvalMode?: string; + runConfig?: { max_time_minutes?: number; max_turns?: number }; +} + +/** + * Workspace agent CRUD operations. Wraps `SubagentManager` for + * daemon-scoped agent management. + */ +export interface AgentsService { + /** List all agents (project + user + builtin). */ + listAgents(ctx: WorkspaceRequestContext): Promise; + + /** Get full detail for a specific agent by name. */ + getAgent( + ctx: WorkspaceRequestContext, + agentName: string, + ): Promise; + + /** Create a new agent definition. */ + createAgent( + ctx: WorkspaceRequestContext, + params: CreateAgentParams, + ): Promise; + + /** Update an existing agent definition. */ + updateAgent( + ctx: WorkspaceRequestContext, + agentName: string, + params: UpdateAgentParams, + ): Promise; + + /** Delete an agent definition. Idempotent — no-throw for missing agents. */ + deleteAgent( + ctx: WorkspaceRequestContext, + agentName: string, + ): Promise<{ deleted: boolean }>; +} + +// --------------------------------------------------------------------------- +// MemoryService +// --------------------------------------------------------------------------- + +/** Parameters for writing workspace memory. */ +export interface WriteMemoryParams { + scope: ServeContextFileScope; + content: string; + mode: 'append' | 'replace'; +} + +/** Result of a memory write operation. */ +export interface WriteMemoryResult { + path: string; + scope: ServeContextFileScope; + bytes: number; +} + +/** + * Workspace memory (QWEN.md / AGENTS.md) read + write operations. + */ +export interface MemoryService { + /** List memory entries (file list + totals). */ + list(ctx: WorkspaceRequestContext): Promise; + + /** Read a specific memory entry by key/path. */ + read( + ctx: WorkspaceRequestContext, + key: string, + ): Promise<{ content: string; path: string }>; + + /** Write content to a workspace or global memory file. */ + write( + ctx: WorkspaceRequestContext, + params: WriteMemoryParams, + ): Promise; + + /** Delete a memory entry. */ + delete( + ctx: WorkspaceRequestContext, + key: string, + ): Promise<{ deleted: boolean }>; +} + +// --------------------------------------------------------------------------- +// DaemonWorkspaceService (facade) +// --------------------------------------------------------------------------- + +/** + * Callback shape for querying workspace status from the ACP child. + * Used by the facade to delegate child-dependent status queries + * without taking a direct reference to the bridge (avoiding circular + * dependency). + */ +export type QueryWorkspaceStatusFn = ( + method: string, + idle: () => T, +) => Promise; + +/** + * Callback shape for invoking workspace-level mutation commands + * through the ACP child. Analogous to `QueryWorkspaceStatusFn` but + * for state-changing operations (e.g. restart MCP server, toggle tool). + */ +export type InvokeWorkspaceCommandFn = ( + method: string, + params?: Record, + opts?: { timeoutMs?: number }, +) => Promise; + +/** + * The unified facade for workspace-scoped daemon operations. Routes + * delegate here instead of reaching into the bridge for workspace + * concerns. + */ +export interface DaemonWorkspaceService { + readonly file: FileService; + readonly auth: AuthService; + readonly agents: AgentsService; + readonly memory: MemoryService; + + // -- Workspace status (delegated to ACP child via callbacks) -- + + /** MCP server status for the bound workspace. */ + getWorkspaceMcpStatus( + ctx: WorkspaceRequestContext, + ): Promise; + + /** Skill status for the bound workspace. */ + getWorkspaceSkillsStatus( + ctx: WorkspaceRequestContext, + ): Promise; + + /** Model-provider status for the bound workspace. */ + getWorkspaceProvidersStatus( + ctx: WorkspaceRequestContext, + ): Promise; + + /** Environment snapshot for the bound workspace. */ + getWorkspaceEnvStatus( + ctx: WorkspaceRequestContext, + ): Promise; + + /** Preflight diagnostics for the bound workspace. */ + getWorkspacePreflightStatus( + ctx: WorkspaceRequestContext, + ): Promise; + + // -- Workspace mutations -- + + /** Toggle a tool enabled/disabled in workspace settings. */ + setWorkspaceToolEnabled( + ctx: WorkspaceRequestContext, + toolName: string, + enabled: boolean, + ): Promise<{ toolName: string; enabled: boolean }>; + + /** Scaffold (init) a QWEN.md file in the workspace. */ + initWorkspace( + ctx: WorkspaceRequestContext, + opts: { force?: boolean }, + ): Promise<{ path: string; action: 'created' | 'overwrote' | 'noop' }>; + + /** Restart a configured MCP server. */ + restartMcpServer( + ctx: WorkspaceRequestContext, + serverName: string, + opts?: { entryIndex?: number }, + ): Promise; +} + +// -- Result types for workspace mutations -- + +/** Discriminated union for MCP server restart outcomes. */ +export type RestartMcpServerResult = + | { serverName: string; restarted: true; durationMs: number } + | { + serverName: string; + restarted: false; + skipped: true; + reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; + } + | { + serverName: string; + entries: Array<{ + entryIndex: number; + restarted: boolean; + durationMs?: number; + reason?: string; + }>; + }; + +// --------------------------------------------------------------------------- +// DaemonWorkspaceServiceDeps +// --------------------------------------------------------------------------- + +/** + * Construction-time dependencies for `DaemonWorkspaceService`. + * + * Uses callback functions for bridge interactions (not the bridge type + * directly) to avoid circular dependencies between the workspace + * service and the bridge. + */ +export interface DaemonWorkspaceServiceDeps { + /** Canonical absolute path of the bound workspace. */ + boundWorkspace: string; + + /** Context filename (e.g. 'QWEN.md') from workspace settings. */ + contextFilename: string; + + /** Factory for per-request filesystem instances. */ + fsFactory: WorkspaceFileSystemFactory; + + /** Device-flow auth registry. Optional — auth routes are a no-op when absent. */ + deviceFlowRegistry?: DeviceFlowRegistry; + + /** Subagent manager for agents CRUD. Optional — agents routes return empty when absent. */ + subagentManager?: unknown; + + /** + * Daemon-host status provider for env + preflight cells. + * When present, `getWorkspaceEnvStatus` returns daemon-local process state + * without querying ACP. When absent, falls back to idle placeholders. + */ + statusProvider?: DaemonStatusProvider; + + /** + * Returns whether the ACP channel is currently live. Used by + * `getWorkspaceEnvStatus` to populate the `acpChannelLive` field + * without requiring an ACP round-trip. + */ + isChannelLive?: () => boolean; + + /** Persist tool enable/disable to workspace settings file. */ + persistDisabledTools: ( + workspace: string, + toolName: string, + enabled: boolean, + ) => Promise; + + /** + * Query workspace status from the ACP child. The bridge owns the + * child lifecycle; this callback abstracts that dependency. + */ + queryWorkspaceStatus: QueryWorkspaceStatusFn; + + /** + * Invoke a workspace-level mutation command through the ACP child. + * For commands like tool-toggle, MCP restart, init-workspace. + */ + invokeWorkspaceCommand: InvokeWorkspaceCommandFn; + + /** + * Publish a workspace-wide event to all sessions' SSE buses. + * Used after mutations that affect all connected clients. + */ + publishWorkspaceEvent: (event: { + type: string; + data: unknown; + originatorClientId?: string; + }) => void; + + /** + * Set of all currently known client ids across live sessions. + * Used for client-id validation on mutation routes. + */ + knownClientIds: () => ReadonlySet; +} diff --git a/packages/cli/src/serve/workspace-service/validation.ts b/packages/cli/src/serve/workspace-service/validation.ts new file mode 100644 index 00000000000..429cd0e9941 --- /dev/null +++ b/packages/cli/src/serve/workspace-service/validation.ts @@ -0,0 +1,30 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared validation helpers for workspace sub-services. + */ + +import type { WorkspaceRequestContext } from './types.js'; + +/** + * Validate that the originator client id (if present) belongs to a + * currently registered session. Throws when the id is set but unknown, + * which prevents stale or forged client ids from mutating workspace + * state. + */ +export function validateClientId( + ctx: WorkspaceRequestContext, + knownClientIds: () => ReadonlySet, +): void { + const clientId = ctx.originatorClientId; + if (clientId === undefined) return; + if (!knownClientIds().has(clientId)) { + throw new Error( + `Client id "${clientId}" is not registered for this workspace`, + ); + } +} diff --git a/packages/cli/src/serve/workspaceAgents.test.ts b/packages/cli/src/serve/workspaceAgents.test.ts index 908f68920eb..aa674532730 100644 --- a/packages/cli/src/serve/workspaceAgents.test.ts +++ b/packages/cli/src/serve/workspaceAgents.test.ts @@ -20,7 +20,7 @@ import { } from 'vitest'; import { Storage, QWEN_DIR } from '@qwen-code/qwen-code-core'; import { createMutationGate } from './auth.js'; -import type { HttpAcpBridge } from './httpAcpBridge.js'; +import type { AcpSessionBridge } from './acpSessionBridge.js'; import type { BridgeEvent } from './eventBus.js'; import { mountWorkspaceAgentsRoutes } from './workspaceAgents.js'; @@ -28,7 +28,7 @@ type RecordedEvent = Omit; function buildBridgeStub( opts: { knownIds?: Iterable } = {}, -): HttpAcpBridge & { +): AcpSessionBridge & { events: RecordedEvent[]; } { const events: RecordedEvent[] = []; @@ -102,11 +102,11 @@ function buildBridgeStub( pendingPermissionCount: 0, killAllSync: () => {}, shutdown: async () => {}, - } as unknown as HttpAcpBridge & { events: RecordedEvent[] }; + } as unknown as AcpSessionBridge & { events: RecordedEvent[] }; } function buildApp(opts: { - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; boundWorkspace: string; strictNoToken?: boolean; }) { diff --git a/packages/cli/src/serve/workspaceAgents.ts b/packages/cli/src/serve/workspaceAgents.ts index ec0b7c950de..2ca90ca35df 100644 --- a/packages/cli/src/serve/workspaceAgents.ts +++ b/packages/cli/src/serve/workspaceAgents.ts @@ -18,7 +18,7 @@ import { } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { isServeDebugMode } from './debugMode.js'; -import { InvalidClientIdError, type HttpAcpBridge } from './httpAcpBridge.js'; +import { InvalidClientIdError, type AcpSessionBridge } from './acpSessionBridge.js'; /** * Pattern for the route-layer `:agentType` URL parameter. Matches the @@ -91,7 +91,7 @@ import { */ export interface WorkspaceAgentsRouteDeps { - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; boundWorkspace: string; mutate: (opts?: { strict?: boolean }) => RequestHandler; parseClientId: (req: Request, res: Response) => string | undefined | null; @@ -1330,5 +1330,5 @@ export function createDaemonSubagentManager( // Re-export the bridge error type used by route helpers so test files // can import it from a single module without reaching into -// httpAcpBridge directly. +// acpSessionBridge directly. export { InvalidClientIdError }; diff --git a/packages/cli/src/serve/workspaceMemory.test.ts b/packages/cli/src/serve/workspaceMemory.test.ts index eadc2a4529d..080aa1c5744 100644 --- a/packages/cli/src/serve/workspaceMemory.test.ts +++ b/packages/cli/src/serve/workspaceMemory.test.ts @@ -20,7 +20,7 @@ import { } from 'vitest'; import { Storage } from '@qwen-code/qwen-code-core'; import { createMutationGate } from './auth.js'; -import { InvalidClientIdError, type HttpAcpBridge } from './httpAcpBridge.js'; +import { InvalidClientIdError, type AcpSessionBridge } from './acpSessionBridge.js'; import type { BridgeEvent } from './eventBus.js'; import { mountWorkspaceMemoryRoutes } from './workspaceMemory.js'; @@ -30,7 +30,7 @@ function buildBridgeStub( opts: { knownIds?: Iterable; } = {}, -): HttpAcpBridge & { events: RecordedEvent[] } { +): AcpSessionBridge & { events: RecordedEvent[] } { const events: RecordedEvent[] = []; const known = new Set(opts.knownIds ?? []); return { @@ -104,11 +104,11 @@ function buildBridgeStub( pendingPermissionCount: 0, killAllSync: () => {}, shutdown: async () => {}, - } as unknown as HttpAcpBridge & { events: RecordedEvent[] }; + } as unknown as AcpSessionBridge & { events: RecordedEvent[] }; } function buildApp(opts: { - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; boundWorkspace: string; strictNoToken?: boolean; }) { diff --git a/packages/cli/src/serve/workspaceMemory.ts b/packages/cli/src/serve/workspaceMemory.ts index e7a0e7ef2c6..d79bf1ecccb 100644 --- a/packages/cli/src/serve/workspaceMemory.ts +++ b/packages/cli/src/serve/workspaceMemory.ts @@ -16,7 +16,7 @@ import { } from '@qwen-code/qwen-code-core'; import { writeStderrLine } from '../utils/stdioHelpers.js'; import { isServeDebugMode } from './debugMode.js'; -import type { HttpAcpBridge } from './httpAcpBridge.js'; +import type { AcpSessionBridge } from './acpSessionBridge.js'; import { createIdleWorkspaceMemoryStatus, STATUS_SCHEMA_VERSION, @@ -57,7 +57,7 @@ import { */ export interface WorkspaceMemoryRouteDeps { - bridge: HttpAcpBridge; + bridge: AcpSessionBridge; boundWorkspace: string; /** * `mutate({ strict: true })`-style middleware factory from PR 15. diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index a58fc596815..993d35e1251 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -332,7 +332,10 @@ function fmtCategoryRow( contextWindowSize: number, indent = ' ', ): string { - const percentage = ((tokens / contextWindowSize) * 100).toFixed(1); + const percentage = + contextWindowSize > 0 + ? ((tokens / contextWindowSize) * 100).toFixed(1) + : '0.0'; const right = `${fmtTokens(tokens)} tokens (${percentage}%)`; const leftPart = `${indent}${label}`; const totalWidth = 56; diff --git a/packages/cli/src/ui/components/shared/text-buffer.test.ts b/packages/cli/src/ui/components/shared/text-buffer.test.ts index da7cbf60401..567727bf7bb 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.test.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.test.ts @@ -578,6 +578,322 @@ describe('useTextBuffer', () => { act(() => result.current.insert(shortText, { paste: true })); expect(getBufferState(result).text).toBe(shortText); }); + + it('should prepend @ to multiple quoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/file2.txt' '/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple unquoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/path/to/file1.txt' || + p === '/path/to/file2.txt' || + p === '/path/to/file3.txt', + }), + ); + const filePaths = + '/path/to/file1.txt /path/to/file2.txt /path/to/file3.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple file paths separated by newlines', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + '/path/to/file1.txt\n/path/to/file2.txt\n/path/to/file3.txt'; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should prepend @ to multiple quoted file paths separated by newlines', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt'\n'/path/to/file2.txt'\n'/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should handle mixed quoted and unquoted file paths separated by spaces', () => { + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + const filePaths = + "'/path/to/file1.txt' /path/to/file2.txt '/path/to/file3.txt'"; + act(() => result.current.insert(filePaths, { paste: true })); + expect(getBufferState(result).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should preserve original content when not all tokens are valid paths', () => { + // When any token is not a valid path, preserve the original paste + // to prevent silent data loss (wenshao #4544 review). + const { result: result2 } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (path: string) => + path.includes('file1') || path.includes('file2'), + }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/invalid.txt' '/path/to/file2.txt'"; + act(() => result2.current.insert(filePaths, { paste: true })); + // Content preserved unchanged because not all tokens are valid paths + expect(getBufferState(result2).text).toBe(filePaths); + }); + + it('should transform when all tokens are valid paths', () => { + // When every token is a valid path, transform all of them + const { result: result3 } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (path: string) => + path.includes('file1') || + path.includes('file2') || + path.includes('file3'), + }), + ); + const filePaths = + "'/path/to/file1.txt' '/path/to/file2.txt' '/path/to/file3.txt'"; + act(() => result3.current.insert(filePaths, { paste: true })); + expect(getBufferState(result3).text).toBe( + '@/path/to/file1.txt @/path/to/file2.txt @/path/to/file3.txt ', + ); + }); + + it('should handle quoted paths with spaces via greedy matching', () => { + // Critical 3: Test greedy multi-token matching and escapePath integration + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/path/to/my file.txt' || p === '/path/to/another file.txt', + }), + ); + act(() => + result.current.insert( + "'/path/to/my file.txt' '/path/to/another file.txt'", + { paste: true }, + ), + ); + expect(getBufferState(result).text).toBe( + '@/path/to/my\\ file.txt @/path/to/another\\ file.txt ', + ); + }); + + it('should handle unquoted paths with spaces via greedy matching', () => { + // Critical 3: Test unquoted paths with spaces + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/path/to/my file.txt', + }), + ); + act(() => result.current.insert('/path/to/my file.txt', { paste: true })); + expect(getBufferState(result).text).toBe('@/path/to/my\\ file.txt '); + }); + + it('should handle CRLF-separated paths', () => { + // Suggestion 6: Test CRLF normalization + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: () => true }), + ); + act(() => + result.current.insert('/a.txt\r\n/b.txt\r\n/c.txt', { paste: true }), + ); + expect(getBufferState(result).text).toBe('@/a.txt @/b.txt @/c.txt '); + }); + + it('should preserve newline paste content when no valid paths found', () => { + // Suggestion 6: Test null return from tryExtractFilePaths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => false, + }), + ); + const text = 'line one\nline two'; + act(() => result.current.insert(text, { paste: true })); + expect(getBufferState(result).text).toBe(text); + }); + + it('should preserve newline paste content in shell mode', () => { + // Suggestion 6: Test shellModeActive + newline paste + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: () => true, + shellModeActive: true, + }), + ); + const text = '/a.txt\n/b.txt\n/c.txt'; + act(() => result.current.insert(text, { paste: true })); + expect(getBufferState(result).text).toBe(text); + }); + + it('should escape commas in paths for parseAllAtCommands compatibility', () => { + // Suggestion 7: Test comma escaping + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/path/to/report,v2.txt', + }), + ); + act(() => + result.current.insert("'/path/to/report,v2.txt'", { paste: true }), + ); + // Comma should be escaped so parseAllAtCommands doesn't truncate + expect(getBufferState(result).text).toBe('@/path/to/report\\,v2.txt '); + }); + + it('should escape shell metacharacters like parentheses in paths', () => { + // Suggestion 4 (wenshao #4544): Test shell metacharacters in paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/Downloads/report(v2).txt' || + p === '/data[2024].csv' || + p === '/report;v2.txt', + }), + ); + // Test parentheses + act(() => + result.current.insert("'/Downloads/report(v2).txt'", { paste: true }), + ); + expect(getBufferState(result).text).toBe( + '@/Downloads/report\\(v2\\).txt ', + ); + + // Reset buffer and test brackets + act(() => result.current.setText('')); + act(() => result.current.insert("'/data[2024].csv'", { paste: true })); + expect(getBufferState(result).text).toBe('@/data\\[2024\\].csv '); + + // Reset buffer and test semicolon + act(() => result.current.setText('')); + act(() => result.current.insert("'/report;v2.txt'", { paste: true })); + expect(getBufferState(result).text).toBe('@/report\\;v2.txt '); + }); + + it('should handle relative paths like ./src/index.ts', () => { + // Suggestion 1 (wenshao #4544): looksLikePath should support relative paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === './src/index.ts' || + p === '../lib/utils.ts' || + p === '~/notes.md', + }), + ); + const filePaths = './src/index.ts ../lib/utils.ts ~/notes.md'; + act(() => result.current.insert(filePaths, { paste: true })); + // Paths with ~ are escaped by escapePath + expect(getBufferState(result).text).toBe( + '@./src/index.ts @../lib/utils.ts @\\~/notes.md ', + ); + }); + + it('should handle unquoted paths with spaces via longest-match-first greedy matching', () => { + // Suggestion 2 (wenshao #4544): longest-match-first greedy matching + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === '/tmp/a b.txt' || p === '/tmp/a' || p === 'b.txt', + }), + ); + // Without longest-match-first, this would match "/tmp/a" + "b.txt" (invalid) + // With longest-match-first, this matches "/tmp/a b.txt" + act(() => result.current.insert('/tmp/a b.txt', { paste: true })); + expect(getBufferState(result).text).toBe('@/tmp/a\\ b.txt '); + }); + + it('should handle unquoted invalid paths without crashing', () => { + // Suggestion 4 (wenshao #4544): cover the !found branch + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => p === '/valid/file.txt', + }), + ); + const filePaths = '/valid/file.txt /nonexistent/path'; + act(() => result.current.insert(filePaths, { paste: true })); + // Content preserved unchanged because not all tokens are valid paths + expect(getBufferState(result).text).toBe(filePaths); + }); + + it('should handle Windows drive-letter paths', () => { + // Suggestion 6 (wenshao #4544): test drive-letter branch of looksLikePath + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === 'C:\\Users\\file.txt' || p === 'D:\\data\\report.csv', + }), + ); + act(() => + result.current.insert('C:\\Users\\file.txt D:\\data\\report.csv', { + paste: true, + }), + ); + expect(getBufferState(result).text).toBe( + '@C:\\Users\\file.txt @D:\\data\\report.csv ', + ); + }); + + it('should handle quoted Windows paths with spaces', () => { + // Suggestion 3 (wenshao #4544): test quoted Windows paths + const { result } = renderHook(() => + useTextBuffer({ + viewport, + isValidPath: (p: string) => + p === 'C:\\Users\\my file.txt' || p === 'D:\\data\\report.csv', + }), + ); + act(() => + result.current.insert( + "'C:\\Users\\my file.txt' 'D:\\data\\report.csv'", + { + paste: true, + }, + ), + ); + // escapePath escapes spaces, so "my file" becomes "my\ file" + expect(getBufferState(result).text).toBe( + '@C:\\Users\\my\\ file.txt @D:\\data\\report.csv ', + ); + }); + + it('should prepend @ to a bare filename when isValidPath returns true', () => { + // Suggestion 3 (wenshao #4544): test bare filename for single-token segments + const { result } = renderHook(() => + useTextBuffer({ viewport, isValidPath: (p) => p === 'README.md' }), + ); + act(() => result.current.insert('README.md', { paste: true })); + expect(getBufferState(result).text).toBe('@README.md '); + }); }); describe('Shell Mode Behavior', () => { diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index 35be9f30c1c..48e827b8f15 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -12,6 +12,7 @@ import { useState, useCallback, useEffect, useMemo, useReducer } from 'react'; import { createDebugLogger, unescapePath, + escapePath, getExternalEditorCommand, type EditorType, } from '@qwen-code/qwen-code-core'; @@ -1899,6 +1900,166 @@ export function textBufferReducer( // --- End of reducer logic --- +// --- Path extraction helpers (pure functions, outside useTextBuffer) --- + +/** + * Check if a string looks like a path prefix (starts with /, ./, ../, ~/, ., .., or drive letter). + * Strips surrounding quotes first to handle quoted paths. + * Used to pre-filter tokens before expensive fs calls. + */ +function looksLikePath(str: string): boolean { + // Strip surrounding quotes first to handle quoted paths + const unquoted = str.replace(/^'(.*)'$/, '$1'); + // Also handle tokens that are the start of a quoted path split by whitespace + const inner = unquoted.startsWith("'") ? unquoted.slice(1) : unquoted; + return ( + inner.startsWith('/') || + inner.startsWith('./') || + inner.startsWith('../') || + inner.startsWith('~/') || + inner.startsWith('.') || + /^[A-Za-z]:/.test(inner) + ); +} + +/** + * Extract file paths from content and prepend @ prefix. + * Handles quoted paths, unquoted paths, whitespace-separated, and newline-separated. + * Supports file paths with spaces using greedy matching. + * IMPORTANT: Escapes shell-special characters (spaces, commas, parentheses, + * brackets, semicolons, etc.) with backslash so that the downstream + * `parseAllAtCommands` parser correctly includes the entire path. + * + * Only transforms when ALL non-whitespace tokens are valid paths. If any + * non-path, non-separator token exists, returns null to preserve original + * content (prevents silent data loss). + */ +function tryExtractFilePaths( + content: string, + isValidPath: (p: string) => boolean, +): string[] | null { + const normalized = content.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); + const lines = normalized.split(/\n/).filter((s) => s.trim().length > 0); + + const validPaths: string[] = []; + const hadNonPathToken = { value: false }; + + for (const line of lines) { + // Short-circuit: once any token is flagged as non-path, the result will be null + if (hadNonPathToken.value) break; + + // Use a regex that only matches quoted content starting with path-like chars + // to avoid false matches on English contractions (e.g., "don't"). + const quotedPathRegex = /'((?:[~/.]|[A-Za-z]:)[^']*)'/g; + let lastIndex = 0; + let match; + let hasQuotedPaths = false; + + while ((match = quotedPathRegex.exec(line)) !== null) { + const gap = line.slice(lastIndex, match.index).trim(); + if (gap) { + const gapPaths = extractPathsFromSegment( + gap, + isValidPath, + hadNonPathToken, + ); + validPaths.push(...gapPaths); + } + const unescaped = unescapePath(match[1]); + if (isValidPath(unescaped)) { + validPaths.push(`@${escapePath(unescaped)}`); + } else { + // Quoted path found but not a valid path — mark as non-path + hadNonPathToken.value = true; + } + lastIndex = quotedPathRegex.lastIndex; + hasQuotedPaths = true; + } + + if (hasQuotedPaths) { + const trailing = line.slice(lastIndex).trim(); + if (trailing) { + const trailingPaths = extractPathsFromSegment( + trailing, + isValidPath, + hadNonPathToken, + ); + validPaths.push(...trailingPaths); + } + } else { + const linePaths = extractPathsFromSegment( + line.trim(), + isValidPath, + hadNonPathToken, + ); + validPaths.push(...linePaths); + } + } + + // Only return paths if we extracted at least one AND the content looks like + // a pure list of paths (all non-whitespace tokens are valid paths). + // This prevents silent data loss when pasting prose mixed with paths. + if (validPaths.length > 0 && !hadNonPathToken.value) { + return validPaths; + } + + return null; +} + +/** + * Extract file paths from a whitespace-separated segment. + * Tries longest possible path first (greedy) so paths with spaces are matched + * before shorter prefixes. + * Pre-filters tokens that don't look like paths to avoid O(n²) fs calls. + * Sets `hadNonPathToken` to true if any token was skipped (not a valid path). + */ +function extractPathsFromSegment( + segment: string, + isValidPath: (p: string) => boolean, + hadNonPathToken: { value: boolean }, +): string[] { + const tokens = segment.split(/\s+/).filter(Boolean); + const paths: string[] = []; + let i = 0; + while (i < tokens.length) { + // Short-circuit: once any token is flagged as non-path, the result will be null + if (hadNonPathToken.value) break; + + // Pre-filter: skip tokens that can't possibly be paths. + // For single-token segments, let isValidPath decide (preserves + // old behavior for bare filenames like README.md). + if (tokens.length > 1 && !looksLikePath(tokens[i])) { + hadNonPathToken.value = true; + i++; + continue; + } + let found = false; + // Try longest-match-first so paths with spaces are tried before shorter + // prefixes (e.g., "/tmp/a b.txt" before "/tmp/a"). + for (let j = tokens.length; j >= i + 1; j--) { + const candidate = tokens.slice(i, j).join(' '); + let unquoted = candidate; + const quoteMatch = unquoted.match(/^'(.*)'$/); + if (quoteMatch) { + unquoted = quoteMatch[1]; + } + const unescaped = unescapePath(unquoted); + if (isValidPath(unescaped)) { + paths.push(`@${escapePath(unescaped)}`); + i = j; + found = true; + break; + } + } + if (!found) { + // Token looked like a path but isn't valid — mark as non-path + hadNonPathToken.value = true; + i++; + } + } + return paths; +} + export function useTextBuffer({ initialText = '', initialCursorOffset = 0, @@ -1993,6 +2154,18 @@ export function useTextBuffer({ const insert = useCallback( (ch: string, { paste = false }: { paste?: boolean } = {}): void => { + // Handle pastes that contain newlines (e.g., file paths separated by newlines). + // We need to process these before the newline check below, which would + // otherwise cause an early return and skip the @-path detection. + if (paste && /[\n\r]/.test(ch) && !shellModeActive) { + const validPaths = tryExtractFilePaths(ch, isValidPath); + if (validPaths) { + ch = `${validPaths.join(' ')} `; + } + dispatch({ type: 'insert', payload: ch }); + return; + } + if (/[\n\r]/.test(ch)) { dispatch({ type: 'insert', payload: ch }); return; @@ -2000,19 +2173,13 @@ export function useTextBuffer({ const minLengthToInferAsDragDrop = 3; if ( + paste && ch.length >= minLengthToInferAsDragDrop && - !shellModeActive && - paste + !shellModeActive ) { - let potentialPath = ch.trim(); - const quoteMatch = potentialPath.match(/^'(.*)'$/); - if (quoteMatch) { - potentialPath = quoteMatch[1]; - } - - potentialPath = potentialPath.trim(); - if (isValidPath(unescapePath(potentialPath))) { - ch = `@${potentialPath} `; + const validPaths = tryExtractFilePaths(ch.trim(), isValidPath); + if (validPaths) { + ch = `${validPaths.join(' ')} `; } } diff --git a/packages/cli/vitest.config.ts b/packages/cli/vitest.config.ts index 1e23b80833b..a119c29f185 100644 --- a/packages/cli/vitest.config.ts +++ b/packages/cli/vitest.config.ts @@ -25,6 +25,46 @@ export default defineConfig({ __dirname, '../acp-bridge/src/internal/testUtils.ts', ), + // Same rationale as above: bridgeErrors and status subpaths + // resolve to dist/ via package.json exports, but tests in the + // monorepo worktree need the live source (dist may be stale or + // absent during development). + '@qwen-code/acp-bridge/bridgeErrors': path.resolve( + __dirname, + '../acp-bridge/src/bridgeErrors.ts', + ), + '@qwen-code/acp-bridge/status': path.resolve( + __dirname, + '../acp-bridge/src/status.ts', + ), + '@qwen-code/acp-bridge/bridge': path.resolve( + __dirname, + '../acp-bridge/src/bridge.ts', + ), + '@qwen-code/acp-bridge/spawnChannel': path.resolve( + __dirname, + '../acp-bridge/src/spawnChannel.ts', + ), + '@qwen-code/acp-bridge/bridgeClient': path.resolve( + __dirname, + '../acp-bridge/src/bridgeClient.ts', + ), + '@qwen-code/acp-bridge/bridgeOptions': path.resolve( + __dirname, + '../acp-bridge/src/bridgeOptions.ts', + ), + '@qwen-code/acp-bridge/bridgeTypes': path.resolve( + __dirname, + '../acp-bridge/src/bridgeTypes.ts', + ), + '@qwen-code/acp-bridge/bridgeFileSystem': path.resolve( + __dirname, + '../acp-bridge/src/bridgeFileSystem.ts', + ), + '@qwen-code/acp-bridge/workspacePaths': path.resolve( + __dirname, + '../acp-bridge/src/workspacePaths.ts', + ), }, }, test: { diff --git a/packages/core/src/telemetry/daemon-tracing.test.ts b/packages/core/src/telemetry/daemon-tracing.test.ts new file mode 100644 index 00000000000..51630b9015c --- /dev/null +++ b/packages/core/src/telemetry/daemon-tracing.test.ts @@ -0,0 +1,102 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { + SpanStatusCode, + trace, + type Span, + type Tracer, +} from '@opentelemetry/api'; + +vi.mock('./sdk.js', () => ({ + isTelemetrySdkInitialized: () => true, +})); +import { + DAEMON_TRACEPARENT_META_KEY, + DAEMON_TRACESTATE_META_KEY, + createDaemonBridgeTelemetry, + extractDaemonTraceContext, + hashDaemonWorkspace, + injectDaemonTraceContext, +} from './daemon-tracing.js'; + +describe('daemon-tracing', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('extracts daemon trace context from reserved prompt metadata keys', () => { + const traceId = '1'.repeat(32); + const spanId = '2'.repeat(16); + const extracted = extractDaemonTraceContext({ + _meta: { + [DAEMON_TRACEPARENT_META_KEY]: `00-${traceId}-${spanId}-01`, + [DAEMON_TRACESTATE_META_KEY]: 'vendor=value', + }, + }); + + expect(extracted).toBeDefined(); + expect(trace.getSpanContext(extracted!)?.traceId).toBe(traceId); + expect(trace.getSpanContext(extracted!)?.spanId).toBe(spanId); + }); + + it('strips reserved metadata when no active daemon span exists', () => { + const injected = injectDaemonTraceContext({ + prompt: [], + _meta: { + keep: true, + [DAEMON_TRACEPARENT_META_KEY]: 'client-spoof', + }, + }); + + const meta = injected._meta as Record; + expect(meta['keep']).toBe(true); + expect(meta[DAEMON_TRACEPARENT_META_KEY]).toBeUndefined(); + expect(meta[DAEMON_TRACESTATE_META_KEY]).toBeUndefined(); + expect(extractDaemonTraceContext(injected)).toBeUndefined(); + }); + + it('hashes workspace paths without exposing the raw path', () => { + const hash = hashDaemonWorkspace('/tmp/project'); + + expect(hash).toMatch(/^[0-9a-f]{16}$/); + expect(hash).not.toContain('project'); + }); + + it('emits bridge events as standalone spans without an active span', () => { + const addEvent = vi.fn(); + const setStatus = vi.fn(); + const end = vi.fn(); + const startSpan = vi.fn( + () => ({ addEvent, setStatus, end }) as unknown as Span, + ); + vi.spyOn(trace, 'getSpan').mockReturnValue(undefined); + vi.spyOn(trace, 'getTracer').mockReturnValue({ + startSpan, + } as unknown as Tracer); + + createDaemonBridgeTelemetry().event('channel.exited', { + 'qwen-code.daemon.channel.session_count': 2, + }); + + expect(startSpan).toHaveBeenCalledWith( + 'qwen-code.daemon.bridge', + expect.objectContaining({ + attributes: expect.objectContaining({ + 'event.name': 'channel.exited', + 'qwen-code.daemon.operation': 'event.channel.exited', + 'qwen-code.daemon.channel.session_count': 2, + }), + }), + ); + expect(addEvent).toHaveBeenCalledWith('channel.exited', { + 'qwen-code.daemon.channel.session_count': 2, + }); + expect(setStatus).toHaveBeenCalledWith({ code: SpanStatusCode.OK }); + expect(end).toHaveBeenCalled(); + }); +}); diff --git a/packages/core/src/telemetry/daemon-tracing.ts b/packages/core/src/telemetry/daemon-tracing.ts new file mode 100644 index 00000000000..478d7465e13 --- /dev/null +++ b/packages/core/src/telemetry/daemon-tracing.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createHash } from 'node:crypto'; +import { + context as otelContext, + propagation, + ROOT_CONTEXT, + SpanKind, + SpanStatusCode, + trace, + type Context, + type Span, +} from '@opentelemetry/api'; +import { logs, type LogAttributes } from '@opentelemetry/api-logs'; +import { SERVICE_NAME } from './constants.js'; +import { isTelemetrySdkInitialized } from './sdk.js'; +import { truncateSpanError } from './session-tracing.js'; + +export const DAEMON_TRACEPARENT_META_KEY = 'qwen.telemetry.traceparent'; +export const DAEMON_TRACESTATE_META_KEY = 'qwen.telemetry.tracestate'; + +const SPAN_DAEMON_REQUEST = 'qwen-code.daemon.request'; +const SPAN_DAEMON_BRIDGE = 'qwen-code.daemon.bridge'; +const EVENT_DAEMON_ERROR = 'qwen-code.daemon.error'; + +type DaemonAttributes = Record; + +interface CapturedDaemonContext { + context: Context; +} + +export interface DaemonRequestSpanOptions { + method: string; + route: string; + workspaceHash?: string; + sessionId?: string; +} + +function errorMessage(error: unknown): string { + if (error instanceof Error) return error.message; + return String(error); +} + +function errorType(error: unknown): string { + if (error instanceof Error) return error.name || 'Error'; + return typeof error; +} + +const INVALID_TRACE_ID = '0'.repeat(32); +const INVALID_SPAN_ID = '0'.repeat(16); + +function activeSpanContextIsValid(): boolean { + const span = trace.getSpan(otelContext.active()); + if (!span) return false; + const ctx = span.spanContext(); + return ctx.traceId !== INVALID_TRACE_ID && ctx.spanId !== INVALID_SPAN_ID; +} + +function stripReservedTraceMeta(meta: unknown): Record { + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return {}; + const record = meta as Record; + if ( + !(DAEMON_TRACEPARENT_META_KEY in record) && + !(DAEMON_TRACESTATE_META_KEY in record) + ) { + return { ...record }; + } + const out = { ...record }; + delete out[DAEMON_TRACEPARENT_META_KEY]; + delete out[DAEMON_TRACESTATE_META_KEY]; + return out; +} + +export function hashDaemonWorkspace(workspace: string): string { + return createHash('sha256').update(workspace).digest('hex').slice(0, 16); +} + +export async function withDaemonSpan( + name: string, + attributes: DaemonAttributes, + fn: (span: Span) => Promise, + options: { autoOkOnSuccess?: boolean } = {}, +): Promise { + if (!isTelemetrySdkInitialized()) { + return await fn(undefined as unknown as Span); + } + const autoOkOnSuccess = options.autoOkOnSuccess ?? true; + const tracer = trace.getTracer(SERVICE_NAME); + return await tracer.startActiveSpan( + name, + { kind: SpanKind.INTERNAL, attributes }, + async (span) => { + try { + const result = await fn(span); + if (autoOkOnSuccess) { + span.setStatus({ code: SpanStatusCode.OK }); + } + return result; + } catch (error) { + recordDaemonError(span, error); + throw error; + } finally { + span.end(); + } + }, + ); +} + +export async function withDaemonRequestSpan( + options: DaemonRequestSpanOptions, + fn: (span: Span) => Promise, +): Promise { + return await withDaemonSpan( + SPAN_DAEMON_REQUEST, + { + 'http.request.method': options.method, + 'http.route': options.route, + 'qwen-code.daemon.operation': 'http_request', + ...(options.workspaceHash + ? { 'qwen-code.workspace.hash': options.workspaceHash } + : {}), + ...(options.sessionId ? { 'session.id': options.sessionId } : {}), + }, + fn, + { autoOkOnSuccess: false }, + ); +} + +export async function withDaemonBridgeSpan( + operation: string, + attributes: DaemonAttributes, + fn: () => Promise, +): Promise { + return await withDaemonSpan( + SPAN_DAEMON_BRIDGE, + { + 'qwen-code.daemon.operation': operation, + ...attributes, + }, + async () => await fn(), + ); +} + +export function recordDaemonHttpResponse( + span: Span | undefined, + statusCode: number, +): void { + try { + span?.setAttribute('http.response.status_code', statusCode); + } catch { + // Telemetry must not affect request handling. + } +} + +export function recordDaemonError( + span: Span | undefined, + error: unknown, + attributes: DaemonAttributes = {}, +): void { + const target = span ?? trace.getSpan(otelContext.active()); + if (!target) return; + try { + const message = truncateSpanError(errorMessage(error)); + target.recordException(error instanceof Error ? error : new Error(message)); + target.setAttributes({ + 'error.type': errorType(error), + 'error.message': message, + ...attributes, + }); + target.setStatus({ code: SpanStatusCode.ERROR, message }); + } catch { + // Telemetry must not affect request handling. + } +} + +export function emitDaemonLog( + body: string, + attributes: LogAttributes = {}, +): void { + if (!isTelemetrySdkInitialized()) return; + try { + logs.getLogger(SERVICE_NAME).emit({ + body, + timestamp: new Date(), + attributes: { + 'event.name': EVENT_DAEMON_ERROR, + ...attributes, + }, + }); + } catch { + // Telemetry must not affect daemon behavior. + } +} + +export function captureDaemonTelemetryContext(): CapturedDaemonContext { + return { context: otelContext.active() }; +} + +export async function runWithDaemonTelemetryContext( + captured: unknown, + fn: () => Promise, +): Promise { + const ctx = + captured && + typeof captured === 'object' && + 'context' in captured && + (captured as CapturedDaemonContext).context + ? (captured as CapturedDaemonContext).context + : undefined; + if (!ctx) return await fn(); + return await otelContext.with(ctx, fn); +} + +export function injectDaemonTraceContext(request: T): T { + const currentMeta = (request as { _meta?: unknown })._meta; + + if (!activeSpanContextIsValid()) { + return currentMeta + ? { ...request, _meta: stripReservedTraceMeta(currentMeta) } + : request; + } + + const nextMeta = stripReservedTraceMeta(currentMeta); + try { + const carrier: Record = {}; + propagation.inject(otelContext.active(), carrier); + if (carrier['traceparent']) { + nextMeta[DAEMON_TRACEPARENT_META_KEY] = carrier['traceparent']; + } + if (carrier['tracestate']) { + nextMeta[DAEMON_TRACESTATE_META_KEY] = carrier['tracestate']; + } + } catch { + // Telemetry must not affect prompt forwarding. + } + + if (!currentMeta && !nextMeta[DAEMON_TRACEPARENT_META_KEY]) { + return request; + } + + return { + ...request, + _meta: nextMeta, + }; +} + +export function extractDaemonTraceContext( + source: unknown, +): Context | undefined { + const meta = (source as { _meta?: unknown } | undefined)?._meta; + if (!meta || typeof meta !== 'object' || Array.isArray(meta)) { + return undefined; + } + const record = meta as Record; + const traceparent = record[DAEMON_TRACEPARENT_META_KEY]; + if (typeof traceparent !== 'string' || traceparent.length === 0) { + return undefined; + } + const carrier: Record = { traceparent }; + const tracestate = record[DAEMON_TRACESTATE_META_KEY]; + if (typeof tracestate === 'string' && tracestate.length > 0) { + carrier['tracestate'] = tracestate; + } + const extracted = propagation.extract(ROOT_CONTEXT, carrier); + if (trace.getSpanContext(extracted)) return extracted; + + const parts = traceparent.split('-'); + const traceId = parts[1]; + const spanId = parts[2]; + const flags = parts[3]; + if ( + parts[0] !== '00' || + !traceId?.match(/^[0-9a-f]{32}$/) || + !spanId?.match(/^[0-9a-f]{16}$/) || + !flags?.match(/^[0-9a-f]{2}$/) || + traceId === INVALID_TRACE_ID || + spanId === INVALID_SPAN_ID + ) { + return undefined; + } + return trace.setSpan( + ROOT_CONTEXT, + trace.wrapSpanContext({ + traceId, + spanId, + traceFlags: Number.parseInt(flags, 16), + isRemote: true, + }), + ); +} + +export function createDaemonBridgeTelemetry(): { + captureContext(): unknown; + runWithContext(captured: unknown, fn: () => Promise): Promise; + withSpan( + operation: string, + attributes: DaemonAttributes, + fn: () => Promise, + ): Promise; + event(name: string, attributes: DaemonAttributes): void; + injectPromptContext(request: T): T; +} { + return { + captureContext: captureDaemonTelemetryContext, + runWithContext: runWithDaemonTelemetryContext, + withSpan: withDaemonBridgeSpan, + event(name, attributes) { + if (!isTelemetrySdkInitialized()) return; + try { + const activeSpan = trace.getSpan(otelContext.active()); + if (activeSpan) { + activeSpan.addEvent(name, attributes); + return; + } + const span = trace + .getTracer(SERVICE_NAME) + .startSpan(SPAN_DAEMON_BRIDGE, { + kind: SpanKind.INTERNAL, + attributes: { + 'event.name': name, + 'qwen-code.daemon.operation': `event.${name}`, + ...attributes, + }, + }); + span.addEvent(name, attributes); + span.setStatus({ code: SpanStatusCode.OK }); + span.end(); + } catch { + // Telemetry must not affect bridge behavior. + } + }, + injectPromptContext: injectDaemonTraceContext, + }; +} diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 6ad5cb13c34..527c18c7d0d 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -139,6 +139,7 @@ export { sanitizeHookName } from './sanitize.js'; export { startInteractionSpan, endInteractionSpan, + withInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, startToolSpan, @@ -156,6 +157,7 @@ export { export type { StartInteractionOptions, EndInteractionOptions, + InteractionSpanResultStatus, LLMRequestMetadata, ToolSpanMetadata, ToolBlockedDecision, @@ -164,6 +166,23 @@ export type { StartHookSpanOptions, HookSpanMetadata, } from './session-tracing.js'; +export type { TelemetryRuntimeConfig } from './runtime-config.js'; +export { + DAEMON_TRACEPARENT_META_KEY, + DAEMON_TRACESTATE_META_KEY, + captureDaemonTelemetryContext, + createDaemonBridgeTelemetry, + emitDaemonLog, + extractDaemonTraceContext, + hashDaemonWorkspace, + injectDaemonTraceContext, + recordDaemonError, + recordDaemonHttpResponse, + runWithDaemonTelemetryContext, + withDaemonBridgeSpan, + withDaemonRequestSpan, + withDaemonSpan, +} from './daemon-tracing.js'; export { addUserPromptAttributes, addSystemPromptAttributes, diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 7d9de142ee5..7bf0fe91bde 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -8,6 +8,7 @@ import type { Attributes, Meter, Counter, Histogram } from '@opentelemetry/api'; import { diag, metrics, ValueType } from '@opentelemetry/api'; import { SERVICE_NAME, EVENT_CHAT_COMPRESSION } from './constants.js'; import type { Config } from '../config/config.js'; +import type { TelemetryRuntimeConfig } from './runtime-config.js'; import type { ModelSlashCommandEvent } from './types.js'; const TOOL_CALL_COUNT = `${SERVICE_NAME}.tool.call.count`; @@ -59,7 +60,7 @@ const baseMetricDefinition = { // can enable QWEN_TELEMETRY_METRICS_INCLUDE_SESSION_ID or // telemetry.metrics.includeSessionId. Spans and logs always carry // session.id for trace/log correlation. - getCommonAttributes: (config: Config): Attributes => { + getCommonAttributes: (config: TelemetryRuntimeConfig): Attributes => { const out: Attributes = {}; if (config.getTelemetryMetricsIncludeSessionId()) { out['session.id'] = config.getSessionId(); @@ -397,7 +398,7 @@ export function getMeter(): Meter | undefined { return cliMeter; } -export function initializeMetrics(config: Config): void { +export function initializeMetrics(config: TelemetryRuntimeConfig): void { if (isMetricsInitialized) return; const meter = getMeter(); @@ -639,7 +640,9 @@ export function recordModelSlashCommand( // Performance Monitoring Functions -export function initializePerformanceMonitoring(config: Config): void { +export function initializePerformanceMonitoring( + config: TelemetryRuntimeConfig, +): void { const meter = getMeter(); if (!meter) return; diff --git a/packages/core/src/telemetry/runtime-config.ts b/packages/core/src/telemetry/runtime-config.ts new file mode 100644 index 00000000000..1ffcc221379 --- /dev/null +++ b/packages/core/src/telemetry/runtime-config.ts @@ -0,0 +1,24 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { TelemetryTarget } from './index.js'; + +export interface TelemetryRuntimeConfig { + getTelemetryEnabled(): boolean; + getTelemetryOtlpEndpoint(): string | undefined; + getTelemetryOtlpProtocol(): 'grpc' | 'http'; + getTelemetryOtlpTracesEndpoint(): string | undefined; + getTelemetryOtlpLogsEndpoint(): string | undefined; + getTelemetryOtlpMetricsEndpoint(): string | undefined; + getTelemetryTarget(): TelemetryTarget; + getTelemetryOutfile(): string | undefined; + getTelemetryIncludeSensitiveSpanAttributes(): boolean; + getTelemetryResourceAttributes(): Record; + getTelemetryMetricsIncludeSessionId(): boolean; + getTelemetryResourceAttributeWarnings(): readonly string[]; + getCliVersion(): string | undefined; + getSessionId(): string; +} diff --git a/packages/core/src/telemetry/sdk.ts b/packages/core/src/telemetry/sdk.ts index 1412d06de82..7e8be6229cc 100644 --- a/packages/core/src/telemetry/sdk.ts +++ b/packages/core/src/telemetry/sdk.ts @@ -20,7 +20,7 @@ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-node'; import { BatchLogRecordProcessor } from '@opentelemetry/sdk-logs'; import { PeriodicExportingMetricReader } from '@opentelemetry/sdk-metrics'; import { HttpInstrumentation } from '@opentelemetry/instrumentation-http'; -import type { Config } from '../config/config.js'; +import type { TelemetryRuntimeConfig } from './runtime-config.js'; import { SERVICE_NAME } from './constants.js'; import { initializeMetrics } from './metrics.js'; import { @@ -147,7 +147,7 @@ function validateUrl(url: string | undefined): string | undefined { } } -export function initializeTelemetry(config: Config): void { +export function initializeTelemetry(config: TelemetryRuntimeConfig): void { if (telemetryInitialized || !config.getTelemetryEnabled()) { return; } diff --git a/packages/core/src/telemetry/session-tracing.test.ts b/packages/core/src/telemetry/session-tracing.test.ts index 75cb6b97750..eb96b7e0fda 100644 --- a/packages/core/src/telemetry/session-tracing.test.ts +++ b/packages/core/src/telemetry/session-tracing.test.ts @@ -5,7 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { SpanStatusCode } from '@opentelemetry/api'; +import { SpanStatusCode, type Context } from '@opentelemetry/api'; const mockState = vi.hoisted(() => ({ sdkInitialized: true, @@ -125,6 +125,7 @@ import type { Config } from '../config/config.js'; import { startInteractionSpan, endInteractionSpan, + withInteractionSpan, startLLMRequestSpan, endLLMRequestSpan, startToolSpan, @@ -141,6 +142,7 @@ import { runTTLSweepForTesting, truncateSpanError, } from './session-tracing.js'; +import { setSessionContext } from './session-context.js'; function createMockConfig( overrides: Partial<{ @@ -190,6 +192,31 @@ describe('session-tracing', () => { expect(mockSpans[0]!.statuses[0]!.code).toBe(SpanStatusCode.OK); }); + it('runs scoped interaction spans without mutating the global interaction context', async () => { + const config = createMockConfig({ sessionId: 'scoped-session' }); + const result = await withInteractionSpan( + config, + { + promptId: 'prompt-scoped', + model: 'test-model', + messageType: 'acp_prompt', + parentContext: { parent: 'daemon' } as never, + }, + async () => 'done', + ); + + expect(result).toBe('done'); + expect(mockSpans).toHaveLength(1); + expect(mockSpans[0]!.name).toBe('qwen-code.interaction'); + expect(mockSpans[0]!.parentContext).toEqual({ parent: 'daemon' }); + expect(mockSpans[0]!.attributes['session.id']).toBe('scoped-session'); + expect(mockSpans[0]!.attributes['qwen-code.message_type']).toBe( + 'acp_prompt', + ); + expect(mockSpans[0]!.ended).toBe(true); + expect(mockSpans[0]!.statuses.at(-1)?.code).toBe(SpanStatusCode.OK); + }); + it('ends interaction span with error status', () => { const config = createMockConfig(); startInteractionSpan(config, { @@ -280,6 +307,52 @@ describe('session-tracing', () => { }); }); + describe('interaction span — trace context (#4486)', () => { + it('attaches to the session root context returned by getSessionContext', () => { + const fakeRoot = { __sessionRoot: true } as unknown as Context; + setSessionContext(fakeRoot, 'test-session'); + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toBe(fakeRoot); + }); + + it('anchors at session root even when an unrelated OTel span is active', () => { + const fakeRoot = { __sessionRoot: true } as unknown as Context; + setSessionContext(fakeRoot, 'test-session'); + mockState.activeOtelSpan = { name: 'unrelated-wrapper-span' }; + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toBe(fakeRoot); + }); + + it('falls back to otelContext.active() when no session context is set', () => { + // Intentionally NOT calling setSessionContext — exercises the fallback. + const fakeActive = { kind: 'fake-active-span' }; + mockState.activeOtelSpan = fakeActive; + + startInteractionSpan(createMockConfig({ sessionId: 'test-session' }), { + promptId: 'p', + model: 'm', + messageType: 'userQuery', + }); + + const span = mockSpans.find((s) => s.name === 'qwen-code.interaction'); + expect(span?.parentContext).toMatchObject({ __activeSpan: fakeActive }); + }); + }); + describe('LLM request spans', () => { it('creates and ends an LLM request span', () => { const span = startLLMRequestSpan('test-model', 'prompt-llm'); diff --git a/packages/core/src/telemetry/session-tracing.ts b/packages/core/src/telemetry/session-tracing.ts index 97089df54e1..bc5fc68ea02 100644 --- a/packages/core/src/telemetry/session-tracing.ts +++ b/packages/core/src/telemetry/session-tracing.ts @@ -26,7 +26,7 @@ import { } from './constants.js'; import { clearDetailedSpanState } from './detailed-span-attributes.js'; import { isTelemetrySdkInitialized } from './sdk.js'; -import { getSessionContext } from './session-context.js'; +import { getSessionContext, setSessionContext } from './session-context.js'; import { createDebugLogger } from '../utils/debugLogger.js'; const debugLogger = createDebugLogger('SESSION_TRACING'); @@ -43,6 +43,8 @@ export interface EndInteractionOptions { errorMessage?: string; } +export type InteractionSpanResultStatus = 'ok' | 'cancelled'; + export interface LLMRequestMetadata { inputTokens?: number; outputTokens?: number; @@ -291,10 +293,14 @@ export function startInteractionSpan( 'interaction.sequence': interactionSequence, }; - const span = getTracer().startSpan(SPAN_INTERACTION, { - kind: SpanKind.INTERNAL, - attributes, - }); + // Pin to session root directly — resolveParentContext() would prefer + // any active OTel span, but interaction is a turn boundary (#4486). + const sessionCtx = getSessionContext() ?? otelContext.active(); + const span = getTracer().startSpan( + SPAN_INTERACTION, + { kind: SpanKind.INTERNAL, attributes }, + sessionCtx, + ); const spanId = getSpanId(span); const spanContextObj: SpanContext = { @@ -341,6 +347,83 @@ export function endInteractionSpan( interactionContext.enterWith(undefined); } +export async function withInteractionSpan( + config: Config, + options: StartInteractionOptions & { parentContext?: Context }, + fn: () => Promise, + getResultStatus?: (result: T) => InteractionSpanResultStatus, +): Promise { + if (!isTelemetrySdkInitialized()) return await fn(); + + ensureCleanupInterval(); + interactionSequence++; + + const attributes: Attributes = { + 'session.id': config.getSessionId(), + 'qwen-code.prompt_id': options.promptId, + 'qwen-code.message_type': options.messageType, + 'qwen-code.model': options.model, + 'qwen-code.approval_mode': config.getApprovalMode(), + 'interaction.sequence': interactionSequence, + }; + + const parentContext = + options.parentContext ?? resolveParentContext(undefined); + const span = getTracer().startSpan( + SPAN_INTERACTION, + { + kind: SpanKind.INTERNAL, + attributes, + }, + parentContext, + ); + const spanId = getSpanId(span); + const spanContextObj: SpanContext = { + span, + startTime: Date.now(), + attributes: attributes as Record, + type: 'interaction', + }; + activeSpans.set(spanId, new WeakRef(spanContextObj)); + strongSpans.set(spanId, spanContextObj); + + const activeContext = trace.setSpan(parentContext, span); + return await otelContext.with(activeContext, async () => + interactionContext.run(spanContextObj, async () => { + let terminalStatus: InteractionStatus = 'ok'; + try { + const result = await fn(); + terminalStatus = getResultStatus?.(result) ?? 'ok'; + return result; + } catch (error) { + terminalStatus = 'error'; + span.setStatus({ + code: SpanStatusCode.ERROR, + message: truncateSpanError( + error instanceof Error ? error.message : String(error), + ), + }); + throw error; + } finally { + if (!spanContextObj.ended) { + spanContextObj.ended = true; + const duration = Date.now() - spanContextObj.startTime; + span.setAttributes({ + 'interaction.duration_ms': duration, + 'qwen-code.turn_status': terminalStatus, + }); + if (terminalStatus === 'ok') { + span.setStatus({ code: SpanStatusCode.OK }); + } + span.end(); + activeSpans.delete(spanId); + strongSpans.delete(spanId); + } + } + }), + ); +} + // --- LLM Request Spans --- export function startLLMRequestSpan(model: string, promptId: string): Span { @@ -957,6 +1040,8 @@ export function clearSessionTracingForTesting(): void { interactionSequence = 0; lastInteractionCtx = undefined; clearDetailedSpanState(); + // Reach into session-context module to prevent cross-test leakage (#4486). + setSessionContext(undefined); } /** diff --git a/packages/core/src/utils/paths.test.ts b/packages/core/src/utils/paths.test.ts index 11824de98de..5a823442981 100644 --- a/packages/core/src/utils/paths.test.ts +++ b/packages/core/src/utils/paths.test.ts @@ -186,8 +186,8 @@ describe('escapePath', () => { }); it('should handle paths with only special characters', () => { - expect(escapePath(' ()[]{};&|*?$`\'"#!~<>')).toBe( - '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>', + expect(escapePath(' ()[]{};&|*?$`\'"#!~<>,')).toBe( + '\\ \\(\\)\\[\\]\\{\\}\\;\\&\\|\\*\\?\\$\\`\\\'\\"\\#\\!\\~\\<\\>\\,', ); }); }); diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index e11fa0b3ffc..1fbbea042ff 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -44,7 +44,7 @@ export function _resetValidatePathCacheForTest(): void { * Includes: spaces, parentheses, brackets, braces, semicolons, ampersands, pipes, * asterisks, question marks, dollar signs, backticks, quotes, hash, and other shell metacharacters. */ -export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/; // Single shared list of path-argument keys used across file tools. // file_path (Edit, ReadFile, WriteFile), path (Glob, Grep, Ls, RipGrep), diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index bf654737678..192a06790da 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -19,6 +19,9 @@ }, "./package.json": "./package.json" }, + "bin": { + "qwen-serve-mcp": "./dist/daemon-mcp/serve-bridge/bin.js" + }, "files": [ "dist", "README.md" diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index e738d84175e..a53106f4e2f 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -125,6 +125,19 @@ await esbuild.build({ treeShaking: true, }); +// Build serve-bridge CLI bin entry +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'daemon-mcp', 'serve-bridge', 'bin.ts')], + bundle: true, + format: 'esm', + platform: 'node', + target: 'node22', + outfile: join(rootDir, 'dist', 'daemon-mcp', 'serve-bridge', 'bin.js'), + external: ['@modelcontextprotocol/sdk'], + sourcemap: false, + banner: { js: '#!/usr/bin/env node' }, +}); + // Copy LICENSE from root directory to dist const licenseSource = join(rootDir, '..', '..', 'LICENSE'); const licenseTarget = join(rootDir, 'dist', 'LICENSE'); diff --git a/packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts b/packages/sdk-typescript/src/daemon-mcp/SdkControlServerTransport.ts similarity index 100% rename from packages/sdk-typescript/src/mcp/SdkControlServerTransport.ts rename to packages/sdk-typescript/src/daemon-mcp/SdkControlServerTransport.ts diff --git a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts b/packages/sdk-typescript/src/daemon-mcp/createSdkMcpServer.ts similarity index 98% rename from packages/sdk-typescript/src/mcp/createSdkMcpServer.ts rename to packages/sdk-typescript/src/daemon-mcp/createSdkMcpServer.ts index cf2482d6976..fc7eb204ac1 100644 --- a/packages/sdk-typescript/src/mcp/createSdkMcpServer.ts +++ b/packages/sdk-typescript/src/daemon-mcp/createSdkMcpServer.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/sdk-typescript/src/daemon-mcp/formatters.ts b/packages/sdk-typescript/src/daemon-mcp/formatters.ts new file mode 100644 index 00000000000..a4f74b82ae7 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/formatters.ts @@ -0,0 +1,33 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Tool result formatting utilities for MCP responses. + */ + +export interface ToolResult { + content: Array<{ type: 'text'; text: string }>; + isError?: boolean; +} + +export function formatJsonResult(data: unknown): ToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} + +export function formatToolError(error: Error | string): ToolResult { + const message = error instanceof Error ? error.message : error; + return { + content: [{ type: 'text', text: message }], + isError: true, + }; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/README.md b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/README.md new file mode 100644 index 00000000000..2ab6a212a88 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/README.md @@ -0,0 +1,200 @@ +# qwen-serve-bridge MCP Server + +将 `qwen serve` 的 HTTP API 封装为 MCP (Model Context Protocol) Server,方便任何支持 MCP 的客户端直接调用。 + +## 快速开始 + +### 1. 启动 qwen serve daemon + +```bash +# 基本启动 +qwen serve +# 默认监听 http://127.0.0.1:4170 + +# 带 token 和 workspace 启动 +QWEN_SERVER_TOKEN= qwen serve \ + --port 4170 \ + --workspace /path/to/your/project +``` + +### 2. 运行 MCP Server(stdio 模式) + +```bash +QWEN_DAEMON_URL=http://127.0.0.1:4170 \ +QWEN_DAEMON_TOKEN= \ +qwen-serve-mcp +``` + +### 环境变量 + +| 变量 | 说明 | 默认值 | +|------|------|--------| +| `QWEN_DAEMON_URL` | daemon 基础 URL | `http://127.0.0.1:4170` | +| `QWEN_DAEMON_TOKEN` | Bearer token(daemon 启动时未设置 token 则无需传) | 无 | +| `QWEN_WORKSPACE_CWD` | 默认工作区路径 | 无 | + +## 在 MCP 客户端中配置 + +### 方式一:通过 npx(推荐,无需本地安装) + +适用于任何外部项目,无需本地源码: + +```json +{ + "mcpServers": { + "qwen-serve-bridge": { + "type": "stdio", + "command": "npx", + "args": ["-y", "-p", "@qwen-code/sdk", "qwen-serve-mcp"], + "env": { + "QWEN_DAEMON_URL": "http://127.0.0.1:4170", + "QWEN_DAEMON_TOKEN": "" + } + } + } +} +``` + +### 方式二:全局安装后使用 + +```bash +npm install -g @qwen-code/sdk +``` + +```json +{ + "mcpServers": { + "qwen-serve-bridge": { + "type": "stdio", + "command": "qwen-serve-mcp", + "env": { + "QWEN_DAEMON_URL": "http://127.0.0.1:4170", + "QWEN_DAEMON_TOKEN": "" + } + } + } +} +``` + +### 方式三:指定本地路径(开发调试用) + +适用于本地开发 qwen-code 源码时: + +```json +{ + "mcpServers": { + "qwen-serve-bridge": { + "type": "stdio", + "command": "node", + "args": ["/path/to/qwen-code/packages/sdk-typescript/dist/daemon-mcp/serve-bridge/bin.js"], + "env": { + "QWEN_DAEMON_URL": "http://127.0.0.1:4170", + "QWEN_DAEMON_TOKEN": "", + "QWEN_WORKSPACE_CWD": "/path/to/your/project" + } + } + } +} +``` + +> **注意**:方式三需要指定 Node >=22 的完整路径(如 `~/.nvm/versions/node/v22.x.x/bin/node`), +> 除非系统默认 Node 版本已经 >=22。 + +### 编程式使用 + +```typescript +import { createServeBridgeMcpServer } from '@qwen-code/sdk'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + +const server = createServeBridgeMcpServer({ + daemonUrl: 'http://127.0.0.1:4170', + token: process.env.QWEN_DAEMON_TOKEN, + workspaceCwd: '/path/to/workspace', +}); + +const transport = new StdioServerTransport(); +await server.instance.connect(transport); +``` + +## 提供的工具(共 31 个) + +### Infrastructure(2) + +| 工具名 | 说明 | +|--------|------| +| `health` | 检查 daemon 是否存活 | +| `capabilities` | 获取功能/版本信息 | + +### Session Lifecycle(6) + +| 工具名 | 说明 | +|--------|------| +| `session_create` | 创建/附加会话(自动设为默认会话) | +| `session_load` | 恢复会话(含历史回放) | +| `session_resume` | 恢复会话(无历史) | +| `session_close` | 关闭会话 | +| `session_update_metadata` | 更新会话元数据 | +| `session_list` | 列出工作区会话 | + +### Agent Interaction(4) + +| 工具名 | 说明 | +|--------|------| +| `prompt` | 发送 prompt 到 Agent(核心工具,可能耗时较长) | +| `prompt_cancel` | 取消正在执行的 prompt | +| `session_set_model` | 切换模型 | +| `session_context` | 获取会话状态 | + +### Workspace Read(10) + +| 工具名 | 说明 | +|--------|------| +| `file_read` | 读取文本文件 | +| `file_read_bytes` | 读取二进制文件(base64) | +| `file_stat` | 文件元信息 | +| `dir_list` | 目录列表 | +| `glob` | Glob 模式匹配 | +| `workspace_mcp_status` | MCP 服务器状态 | +| `workspace_skills` | 技能列表 | +| `workspace_providers` | 模型提供商状态 | +| `workspace_env` | 运行时环境快照 | +| `workspace_preflight` | 就绪检查 | + +### Workspace Write(9) + +| 工具名 | 说明 | +|--------|------| +| `file_write` | 写文件(支持 hash 校验的原子写入) | +| `file_edit` | 编辑文件(精确匹配替换) | +| `session_set_approval_mode` | 变更审批模式 | +| `workspace_tool_toggle` | 启用/禁用工具 | +| `workspace_init` | 初始化 QWEN.md | +| `workspace_mcp_restart` | 重启 MCP 服务器 | +| `workspace_memory_read` | 读工作区记忆 | +| `workspace_memory_write` | 写工作区记忆 | +| `workspace_agents_manage` | Agent CRUD 管理 | + +## 会话管理 + +MCP 协议是无状态的,但大部分工具需要 `session_id`。本 MCP Server 采用**默认会话**机制: + +1. 调用 `session_create` 后自动记住创建的会话 ID +2. 后续工具调用若省略 `session_id`,自动使用默认会话 +3. 也可显式传入 `session_id` 操作多个会话 +4. `session_close` 关闭默认会话时自动清除缓存 + +## 验证 + +```bash +# 发送 MCP initialize + tools/list 请求 +printf '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","method":"tools/list","id":2}\n' \ + | node dist/daemon-mcp/serve-bridge/bin.js + +# 调用 health 工具 +printf '{"jsonrpc":"2.0","method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}},"id":1}\n{"jsonrpc":"2.0","method":"notifications/initialized"}\n{"jsonrpc":"2.0","method":"tools/call","params":{"name":"health","arguments":{}},"id":3}\n' \ + | node dist/daemon-mcp/serve-bridge/bin.js +``` + +预期输出: +- `tools/list` 返回 31 个工具定义 +- `health` 返回 `{"content":[{"type":"text","text":"{\"status\":\"ok\"}"}]}` diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/bin.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/bin.ts new file mode 100644 index 00000000000..e0ada0b959a --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/bin.ts @@ -0,0 +1,58 @@ +#!/usr/bin/env node +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Standalone stdio entry point for the qwen-serve-bridge MCP server. + * + * Usage: + * QWEN_DAEMON_URL=http://127.0.0.1:4170 \ + * QWEN_DAEMON_TOKEN= \ + * node dist/daemon-mcp/serve-bridge/bin.js + * + * Environment variables: + * QWEN_DAEMON_URL - Daemon base URL (default: http://127.0.0.1:4170) + * QWEN_DAEMON_TOKEN - Bearer token for auth (optional for loopback) + * QWEN_WORKSPACE_CWD - Default workspace path for session creation + */ + +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { createServeBridgeMcpServer } from './createServeBridgeMcpServer.js'; + +const server = createServeBridgeMcpServer({ + daemonUrl: process.env['QWEN_DAEMON_URL'] ?? 'http://127.0.0.1:4170', + token: process.env['QWEN_DAEMON_TOKEN'], + workspaceCwd: process.env['QWEN_WORKSPACE_CWD'], + allowGlobalScope: process.env['QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE'] === 'true', +}); + +const transport = new StdioServerTransport(); + +// Graceful shutdown on signals +async function shutdown() { + try { + await server.instance.close(); + } catch (e) { + process.stderr.write(`[qwen-serve-bridge] close error: ${e}\n`); + } + process.exit(0); +} + +process.on('SIGINT', shutdown); +process.on('SIGTERM', shutdown); + +// Prevent silent crashes from unhandled rejections +process.on('unhandledRejection', (err) => { + const detail = + err instanceof Error ? (err.stack ?? err.message) : String(err); + process.stderr.write(`[qwen-serve-bridge] unhandled rejection: ${detail}\n`); + process.exit(1); +}); + +// Exit cleanly when stdio pipe closes (parent process gone) +process.stdin.on('close', shutdown); + +await server.instance.connect(transport); diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/createServeBridgeMcpServer.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/createServeBridgeMcpServer.ts new file mode 100644 index 00000000000..93674e27348 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/createServeBridgeMcpServer.ts @@ -0,0 +1,81 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Factory: wraps `qwen serve` HTTP API as an MCP server. + */ + +import { DaemonClient } from '../../daemon/DaemonClient.js'; +import { createSdkMcpServer } from '../createSdkMcpServer.js'; +import type { McpSdkServerConfigWithInstance } from '../createSdkMcpServer.js'; +import type { ServeBridgeMcpServerOptions, BridgeState } from './types.js'; +import { startSessionCleanup, stopEventStream } from './sse.js'; +import { allTools } from './tools/index.js'; + +/** Strip trailing slashes without regex (avoids CodeQL ReDoS flag). */ +function stripTrailingSlashes(url: string): string { + let end = url.length; + while (end > 0 && url.charCodeAt(end - 1) === 0x2f) end--; + return end === url.length ? url : url.slice(0, end); +} + +/** + * Create an MCP server that proxies `qwen serve` HTTP endpoints as MCP tools. + * + * @example + * ```typescript + * import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; + * import { createServeBridgeMcpServer } from '@qwen-code/sdk'; + * + * const server = createServeBridgeMcpServer({ + * daemonUrl: 'http://127.0.0.1:4170', + * token: process.env.QWEN_DAEMON_TOKEN, + * }); + * + * const transport = new StdioServerTransport(); + * await server.instance.connect(transport); + * ``` + */ +export function createServeBridgeMcpServer( + opts: ServeBridgeMcpServerOptions, +): McpSdkServerConfigWithInstance { + const state: BridgeState = { + client: new DaemonClient({ + baseUrl: opts.daemonUrl, + token: opts.token, + }), + daemonUrl: stripTrailingSlashes(opts.daemonUrl), + token: opts.token, + defaultSessionId: undefined, + workspaceCwd: opts.workspaceCwd, + eventStreams: new Map(), + allowGlobalScope: opts.allowGlobalScope ?? false, + }; + + const tools = allTools(state); + + // Start periodic cleanup of idle SSE connections + const stopCleanup = startSessionCleanup(state); + + const server = createSdkMcpServer({ + name: 'qwen-serve-bridge', + version: '1.0.0', + tools, + }); + + // Stop cleanup timer and abort all active SSE streams when server closes. + // Use the SDK's onclose lifecycle hook (Protocol.onclose) instead of + // monkey-patching close() — the SDK calls onclose after transport shutdown + // and internal state cleanup, which is the supported extension point. + server.instance.server.onclose = () => { + stopCleanup(); + for (const sessionId of [...state.eventStreams.keys()]) { + stopEventStream(state, sessionId); + } + }; + + return server; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/helpers.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/helpers.ts new file mode 100644 index 00000000000..fa3721ae67b --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/helpers.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Shared utility functions for serve-bridge tool handlers. + */ + +import type { BridgeState } from './types.js'; + +/** + * Resolve the session ID from explicit arg or default state. + * Returns the session ID or throws a descriptive error. + */ +export function resolveSessionId( + state: BridgeState, + explicitSessionId?: string, +): string { + const sessionId = explicitSessionId ?? state.defaultSessionId; + if (!sessionId) { + throw new Error( + 'No session active. Call session_create first, or pass an explicit session_id.', + ); + } + // Bump activity timestamp so workspace operations reset the idle TTL + const stream = state.eventStreams.get(sessionId); + if (stream) { + stream.lastActivityMs = Date.now(); + } + return sessionId; +} + +/** + * Create an MCP tool handler that catches errors and returns them as + * isError responses. Logs error details to stderr for debugging. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function handler( + fn: (args: T) => Promise, +): (args: T, extra: unknown) => Promise { + return async (args: T, _extra: unknown) => { + try { + return await fn(args); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + // Log full error with stack for debugging + if (err instanceof Error && err.stack) { + process.stderr.write(`[serve-bridge] Tool error: ${err.stack}\n`); + } else { + process.stderr.write(`[serve-bridge] Tool error: ${message}\n`); + } + return { + content: [{ type: 'text', text: message }], + isError: true, + }; + } + }; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/index.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/index.ts new file mode 100644 index 00000000000..e3f9a11235a --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/index.ts @@ -0,0 +1,8 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { createServeBridgeMcpServer } from './createServeBridgeMcpServer.js'; +export type { ServeBridgeMcpServerOptions, BridgeState } from './types.js'; diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/sse.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/sse.ts new file mode 100644 index 00000000000..76fe19216d4 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/sse.ts @@ -0,0 +1,181 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Persistent SSE connection lifecycle management. + */ + +import type { + BridgeState, + PromptCollector, + SessionEventStream, +} from './types.js'; + +/** + * Create a new PromptCollector that resolves when called. + */ +export function createPromptCollector(): PromptCollector { + let resolve!: () => void; + const promise = new Promise((r) => { + resolve = r; + }); + const collector: PromptCollector = { + texts: [], + resolve, + promise, + resolved: false, + }; + // Wrap resolve to guard against double-resolution + const originalResolve = resolve; + collector.resolve = () => { + if (!collector.resolved) { + collector.resolved = true; + originalResolve(); + } + }; + return collector; +} + +/** + * Start a persistent SSE subscription for a session. + * Collects agent_message_chunk events into the active PromptCollector. + */ +export function startEventStream(state: BridgeState, sessionId: string): void { + // Don't create duplicate streams; allow re-creation if the old stream is dead + if (state.eventStreams.has(sessionId)) { + const existing = state.eventStreams.get(sessionId)!; + if (!existing.abortCtrl.signal.aborted) return; + // Stale entry from a dead stream — clean up and re-create + state.eventStreams.delete(sessionId); + } + + const abortCtrl = new AbortController(); + const stream: SessionEventStream = { + sessionId, + abortCtrl, + activeCollector: null, + lastActivityMs: Date.now(), + }; + state.eventStreams.set(sessionId, stream); + + // Start consuming SSE in the background (fire-and-forget) + (async () => { + try { + for await (const event of state.client.subscribeEvents(sessionId, { + signal: abortCtrl.signal, + })) { + const data = event.data as Record | undefined; + if (!data) continue; + const update = data['update'] as Record | undefined; + if (!update) continue; + if (update['sessionUpdate'] === 'agent_message_chunk') { + const content = update['content'] as + | Record + | undefined; + if (!content) continue; + stream.lastActivityMs = Date.now(); + const collector = stream.activeCollector; + if (collector) { + const text = content['text']; + if (typeof text === 'string' && text) { + collector.texts.push(text); + } + // Protocol contract: daemon emits _meta only on the final + // agent_message_chunk update (sibling of sessionUpdate/content). + // If future daemon versions move _meta elsewhere, this check + // will need updating — the collector will hang until timeout. + if ('_meta' in update) { + collector.resolve(); + } + } + } else if ( + typeof update['sessionUpdate'] === 'string' && + // Best-effort error detection: daemon does not yet define a formal + // error event enum, so we match common patterns. This may produce + // false positives (e.g. "default_fallback") or miss events like + // "quota_exceeded". Update once daemon publishes an error event spec. + /error|fail/i.test(update['sessionUpdate'] as string) + ) { + process.stderr.write( + `[serve-bridge] daemon error event for ${sessionId}: ${JSON.stringify(update)}\n`, + ); + // Resolve collector so prompt returns immediately with partial text + if (stream.activeCollector) { + stream.activeCollector.interrupted = true; + stream.activeCollector.resolve(); + } + } + } + } catch (err) { + // Log unexpected SSE disconnections (skip AbortError from intentional close) + if (!(err instanceof Error && err.name === 'AbortError')) { + const detail = err instanceof Error ? err.message : String(err); + process.stderr.write( + `[serve-bridge] SSE stream ended unexpectedly for session ${sessionId}: ${detail}\n`, + ); + } + } finally { + // Resolve any pending collector so prompt doesn't hang on disconnect + if (stream.activeCollector) { + stream.activeCollector.interrupted = true; + stream.activeCollector.resolve(); + } + // Only delete if this is still our stream (not replaced by startEventStream) + if (state.eventStreams.get(sessionId) === stream) { + state.eventStreams.delete(sessionId); + // Clear defaultSessionId so callers get a clear error + if (state.defaultSessionId === sessionId) { + state.defaultSessionId = undefined; + } + } + } + })(); +} + +/** + * Stop the persistent SSE subscription for a session. + */ +export function stopEventStream(state: BridgeState, sessionId: string): void { + const stream = state.eventStreams.get(sessionId); + if (stream) { + stream.abortCtrl.abort(); + // Resolve any pending collector so prompt doesn't hang + if (stream.activeCollector) { + stream.activeCollector.interrupted = true; + stream.activeCollector.resolve(); + } + state.eventStreams.delete(sessionId); + } +} + +/** Default session idle TTL: 30 minutes. */ +const SESSION_TTL_MS = 30 * 60 * 1000; +/** Cleanup interval: every 5 minutes. */ +const CLEANUP_INTERVAL_MS = 5 * 60 * 1000; + +/** + * Start a periodic cleanup timer that removes idle SSE streams. + * Returns a cleanup function to stop the timer (call on server shutdown). + */ +export function startSessionCleanup(state: BridgeState): () => void { + const timer = setInterval(() => { + const now = Date.now(); + for (const [sessionId, stream] of state.eventStreams) { + if (now - stream.lastActivityMs > SESSION_TTL_MS) { + process.stderr.write( + `[serve-bridge] Cleaning up idle session SSE: ${sessionId}\n`, + ); + stopEventStream(state, sessionId); + if (state.defaultSessionId === sessionId) { + state.defaultSessionId = undefined; + } + } + } + }, CLEANUP_INTERVAL_MS); + // Don't keep the process alive just for cleanup + timer.unref(); + return () => clearInterval(timer); +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/agent.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/agent.ts new file mode 100644 index 00000000000..16a8198c852 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/agent.ts @@ -0,0 +1,139 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { tool } from '../../tool.js'; +import { formatJsonResult } from '../../formatters.js'; +import type { BridgeState } from '../types.js'; +import { createPromptCollector } from '../sse.js'; +import { handler, resolveSessionId } from '../helpers.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function agentTools(state: BridgeState): any[] { + return [ + tool( + 'prompt', + 'Send a prompt to the qwen-code agent and wait for the full response. This tool blocks until the agent completes processing, which may take minutes for complex tasks. After the HTTP response returns, a 30s collection timeout guards against missing completion signals — if the SSE completion event is not received within 30s, partial text is returned with an error. Do not set a short client-side timeout.', + { + prompt: z.string().describe('The prompt text to send to the agent.'), + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + + // Use the persistent SSE stream established at session_create. + const stream = state.eventStreams.get(sessionId); + if (!stream) { + throw new Error( + 'No SSE stream for session. Was the session created via session_create?', + ); + } + + // Guard against concurrent prompts on the same session + if (stream.activeCollector) { + throw new Error( + 'Another prompt is already in progress for this session. Wait for it to complete or call prompt_cancel first.', + ); + } + + // Install a new collector to capture this prompt's response chunks. + stream.lastActivityMs = Date.now(); + const collector = createPromptCollector(); + stream.activeCollector = collector; + + try { + // Send prompt — response text arrives via the persistent SSE stream. + const result = await state.client.prompt(sessionId, { + prompt: [{ type: 'text', text: args.prompt }], + }); + + // Wait for the collector to be resolved by _meta event (with timeout). + const COLLECT_TIMEOUT_MS = 30000; + let timedOut = false; + let timeoutId: ReturnType; + await Promise.race([ + collector.promise, + new Promise((r) => { + timeoutId = setTimeout(() => { + timedOut = true; + r(); + }, COLLECT_TIMEOUT_MS); + }), + ]); + clearTimeout(timeoutId!); + + // Guard against Promise.race microtask race: only treat as timeout + // if collector was NOT already resolved by _meta + if (timedOut && !collector.resolved) { + try { await state.client.cancel(sessionId); } catch { /* best-effort */ } + const partialText = collector.texts.join(''); + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + session_id: sessionId, + stop_reason: 'timeout', + response: partialText || '(no text received)', + warning: 'Agent response may be incomplete. _meta event not received within 30s.', + }, null, 2) }], + isError: true, + }; + } + + // SSE disconnect or stopEventStream resolved the collector + if (collector.interrupted) { + return { + content: [{ type: 'text' as const, text: JSON.stringify({ + session_id: sessionId, + stop_reason: 'interrupted', + response: collector.texts.join('') || '(no text received)', + warning: 'SSE stream was closed before the response completed.', + }, null, 2) }], + isError: true, + }; + } + + const responseText = + collector.texts.join('') || '(task completed, no text output)'; + return formatJsonResult({ + session_id: sessionId, + stop_reason: result.stopReason, + response: responseText, + }); + } finally { + // Clear the collector regardless of outcome. + stream.activeCollector = null; + } + }), + ), + + tool( + 'prompt_cancel', + 'Cancel the currently active prompt in a session.', + { + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + const stream = state.eventStreams.get(sessionId); + // Best-effort cancel — must not prevent collector resolution + try { + await state.client.cancel(sessionId); + } catch { /* best-effort */ } + // Resolve active collector so the prompt handler returns immediately + if (stream?.activeCollector) { + stream.activeCollector.interrupted = true; + stream.activeCollector.resolve(); + } + return formatJsonResult({ ok: true, sessionId }); + }), + ), + ]; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/index.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/index.ts new file mode 100644 index 00000000000..caa18cf0d0d --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/index.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { SdkMcpToolDefinition } from '../../tool.js'; +import type { BridgeState } from '../types.js'; +import { infrastructureTools } from './infrastructure.js'; +import { sessionTools } from './session.js'; +import { agentTools } from './agent.js'; +import { workspaceReadTools } from './workspaceRead.js'; +import { workspaceWriteTools } from './workspaceWrite.js'; + +/** + * Collect all MCP tool definitions for the serve-bridge. + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function allTools(state: BridgeState): Array> { + return [ + ...infrastructureTools(state), + ...sessionTools(state), + ...agentTools(state), + ...workspaceReadTools(state), + ...workspaceWriteTools(state), + ]; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/infrastructure.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/infrastructure.ts new file mode 100644 index 00000000000..2144efb1ef0 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/infrastructure.ts @@ -0,0 +1,28 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { tool } from '../../tool.js'; +import { formatJsonResult } from '../../formatters.js'; +import type { BridgeState } from '../types.js'; +import { handler } from '../helpers.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function infrastructureTools(state: BridgeState): any[] { + return [ + tool( + 'health', + 'Check if the qwen serve daemon is alive.', + {}, + handler(async () => formatJsonResult(await state.client.health())), + ), + tool( + 'capabilities', + 'Get qwen serve daemon capabilities including protocol versions, mode, features, model services, and workspace CWD.', + {}, + handler(async () => formatJsonResult(await state.client.capabilities())), + ), + ]; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts new file mode 100644 index 00000000000..db4ad4423fa --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/session.ts @@ -0,0 +1,191 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { tool } from '../../tool.js'; +import { formatJsonResult } from '../../formatters.js'; +import type { BridgeState } from '../types.js'; +import { startEventStream, stopEventStream } from '../sse.js'; +import { handler, resolveSessionId } from '../helpers.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function sessionTools(state: BridgeState): any[] { + return [ + tool( + 'session_create', + 'Create a new qwen-code session or attach to an existing one. The created session becomes the default for subsequent tool calls.', + { + workspace_cwd: z + .string() + .optional() + .describe('Workspace path. Defaults to daemon bound workspace.'), + model_service_id: z + .string() + .optional() + .describe('Model service to use.'), + session_scope: z + .enum(['single', 'thread']) + .optional() + .describe('Session scope.'), + }, + handler(async (args) => { + const session = await state.client.createOrAttachSession({ + workspaceCwd: args.workspace_cwd ?? state.workspaceCwd, + modelServiceId: args.model_service_id, + sessionScope: args.session_scope, + }); + // Stop old SSE only after new session is confirmed + if (state.defaultSessionId && state.defaultSessionId !== session.sessionId) { + stopEventStream(state, state.defaultSessionId); + } + state.defaultSessionId = session.sessionId; + // Start persistent SSE connection for this session + startEventStream(state, session.sessionId); + return formatJsonResult(session); + }), + ), + + tool( + 'session_load', + 'Restore a persisted session with SSE history replay. Sets the loaded session as the default.', + { + session_id: z.string().describe('Session ID to restore.'), + workspace_cwd: z.string().optional().describe('Workspace path.'), + }, + handler(async (args) => { + const result = await state.client.loadSession(args.session_id, { + workspaceCwd: args.workspace_cwd ?? state.workspaceCwd, + }); + // Stop old SSE only after load is confirmed + if (state.defaultSessionId && state.defaultSessionId !== result.sessionId) { + stopEventStream(state, state.defaultSessionId); + } + state.defaultSessionId = result.sessionId; + startEventStream(state, result.sessionId); + return formatJsonResult(result); + }), + ), + + tool( + 'session_resume', + 'Restore a session without history replay. Sets the resumed session as the default.', + { + session_id: z.string().describe('Session ID to resume.'), + workspace_cwd: z.string().optional().describe('Workspace path.'), + }, + handler(async (args) => { + const result = await state.client.resumeSession(args.session_id, { + workspaceCwd: args.workspace_cwd ?? state.workspaceCwd, + }); + // Stop old SSE only after resume is confirmed + if (state.defaultSessionId && state.defaultSessionId !== result.sessionId) { + stopEventStream(state, state.defaultSessionId); + } + state.defaultSessionId = result.sessionId; + startEventStream(state, result.sessionId); + return formatJsonResult(result); + }), + ), + + tool( + 'session_close', + 'Force-close a live session.', + { + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + try { + await state.client.closeSession(sessionId); + } finally { + // Always clean up SSE even if closeSession throws + stopEventStream(state, sessionId); + if (state.defaultSessionId === sessionId) { + state.defaultSessionId = undefined; + } + } + return formatJsonResult({ ok: true, sessionId }); + }), + ), + + tool( + 'session_update_metadata', + 'Update session metadata such as display name.', + { + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + display_name: z + .string() + .optional() + .describe('New display name for the session.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + const result = await state.client.updateSessionMetadata(sessionId, { + displayName: args.display_name, + }); + return formatJsonResult(result); + }), + ), + + tool( + 'session_list', + 'List live sessions for a workspace.', + { + workspace_cwd: z + .string() + .describe('Workspace path to list sessions for.'), + }, + handler(async (args) => { + const sessions = await state.client.listWorkspaceSessions( + args.workspace_cwd, + ); + return formatJsonResult({ sessions }); + }), + ), + + tool( + 'session_set_model', + 'Switch the active model for a session.', + { + model_id: z.string().describe('Model ID to switch to.'), + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + const result = await state.client.setSessionModel( + sessionId, + args.model_id, + ); + return formatJsonResult(result); + }), + ), + + tool( + 'session_context', + 'Get the current session model/mode/config state.', + { + session_id: z + .string() + .optional() + .describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + const sessionId = resolveSessionId(state, args.session_id); + const result = await state.client.sessionContext(sessionId); + return formatJsonResult(result); + }), + ), + ]; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts new file mode 100644 index 00000000000..939408d9f70 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceRead.ts @@ -0,0 +1,125 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { tool } from '../../tool.js'; +import { formatJsonResult } from '../../formatters.js'; +import type { BridgeState } from '../types.js'; +import { handler } from '../helpers.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function workspaceReadTools(state: BridgeState): any[] { + return [ + tool( + 'file_read', + 'Read a text file from the workspace. Returns content and SHA-256 hash.', + { + path: z.string().describe('File path (relative to workspace root).'), + max_bytes: z.number().optional().describe('Maximum bytes to read.'), + line: z.number().optional().describe('Starting line number.'), + limit: z.number().optional().describe('Number of lines to read.'), + }, + handler(async (args) => { + const result = await state.client.readWorkspaceFile(args.path, { + maxBytes: args.max_bytes, + line: args.line, + limit: args.limit, + }); + return formatJsonResult(result); + }), + ), + + tool( + 'file_read_bytes', + 'Read raw bytes from a file as base64. For binary or bounded reads.', + { + path: z.string().describe('File path (relative to workspace root).'), + offset: z.number().optional().describe('Byte offset to start reading.'), + max_bytes: z.number().optional().describe('Maximum bytes to read.'), + }, + handler(async (args) => { + const result = await state.client.readWorkspaceFileBytes(args.path, { + offset: args.offset, + maxBytes: args.max_bytes, + }); + return formatJsonResult(result); + }), + ), + + tool( + 'file_stat', + 'Get file or directory metadata (size, timestamps, type).', + { + path: z.string().describe('File path to stat.'), + }, + handler(async (args) => { + const result = await state.client.fileStat(args.path); + return formatJsonResult(result); + }), + ), + + tool( + 'dir_list', + 'List files and directories in a workspace directory (max 2000 entries).', + { + path: z.string().describe('Directory path to list.'), + }, + handler(async (args) => { + const result = await state.client.dirList(args.path); + return formatJsonResult(result); + }), + ), + + tool( + 'glob', + 'Find files matching a glob pattern in the workspace (max 5000 results).', + { + pattern: z + .string() + .describe('Glob pattern (e.g. "**/*.ts", "src/**/*.js").'), + }, + handler(async (args) => { + const result = await state.client.glob(args.pattern); + return formatJsonResult(result); + }), + ), + + tool( + 'workspace_mcp_status', + 'Get MCP server status including discovery state, server list, budgets.', + {}, + handler(async () => formatJsonResult(await state.client.workspaceMcp())), + ), + + tool( + 'workspace_skills', + 'List available skills in the workspace.', + {}, + handler(async () => formatJsonResult(await state.client.workspaceSkills())), + ), + + tool( + 'workspace_providers', + 'Get model provider status including current provider and available models.', + {}, + handler(async () => formatJsonResult(await state.client.workspaceProviders())), + ), + + tool( + 'workspace_env', + 'Get daemon runtime environment snapshot (platform, sandbox, proxy, env var presence). Never leaks secret values.', + {}, + handler(async () => formatJsonResult(await state.client.workspaceEnv())), + ), + + tool( + 'workspace_preflight', + 'Run readiness checks. Daemon-level cells always populated; ACP-level cells show not_started when idle.', + {}, + handler(async () => formatJsonResult(await state.client.workspacePreflight())), + ), + ]; +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceWrite.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceWrite.ts new file mode 100644 index 00000000000..4c00382985d --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/tools/workspaceWrite.ts @@ -0,0 +1,302 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { z } from 'zod'; +import { tool } from '../../tool.js'; +import { formatJsonResult, formatToolError } from '../../formatters.js'; +import type { BridgeState } from '../types.js'; +import { handler, resolveSessionId } from '../helpers.js'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function workspaceWriteTools(state: BridgeState): any[] { + return [ + tool( + 'file_write', + 'Create or replace a text file in the workspace. Supports hash-verified atomic writes.', + { + path: z.string().describe('File path (relative to workspace root).'), + content: z.string().describe('File content to write.'), + mode: z.enum(['create', 'replace']).describe('"create" for new files, "replace" for existing.'), + expected_hash: z.string().optional().describe('Expected SHA-256 hash for replace mode (required for replace).'), + }, + handler(async (args) => { + if (args.mode === 'replace' && !args.expected_hash) { + return formatToolError('expected_hash is required for replace mode.'); + } + const req = + args.mode === 'create' + ? { + path: args.path, + content: args.content, + mode: 'create' as const, + ...(args.expected_hash + ? { expectedHash: args.expected_hash as `sha256:${string}` } + : {}), + } + : { + path: args.path, + content: args.content, + mode: 'replace' as const, + expectedHash: args.expected_hash as `sha256:${string}`, + }; + return formatJsonResult(await state.client.writeWorkspaceFile(req)); + }), + ), + + tool( + 'file_edit', + 'Make a single text replacement in a file. Requires exact-once match of old_text.', + { + path: z.string().describe('File path.'), + old_text: z.string().describe('Text to find (must match exactly once).'), + new_text: z.string().describe('Replacement text.'), + expected_hash: z.string().describe('Expected SHA-256 hash of the current file.'), + }, + handler(async (args) => + formatJsonResult( + await state.client.editWorkspaceFile({ + path: args.path, + oldText: args.old_text, + newText: args.new_text, + expectedHash: args.expected_hash as `sha256:${string}`, + }), + ), + ), + ), + + tool( + 'session_set_approval_mode', + 'Change the approval mode of a session (plan, default, auto-edit, auto, yolo).', + { + mode: z.enum(['plan', 'default', 'auto-edit', 'auto', 'yolo']).describe('Approval mode.'), + persist: z.boolean().optional().describe('Also write to workspace settings file.'), + session_id: z.string().optional().describe('Session ID. Uses default session if omitted.'), + }, + handler(async (args) => { + // Block dangerous modes and persistent changes without explicit opt-in + if (!state.allowGlobalScope) { + const dangerousModes = ['yolo', 'auto', 'auto-edit']; + if (dangerousModes.includes(args.mode)) { + return formatToolError( + `Approval modes '${dangerousModes.join("', '")}' are restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.`, + ); + } + if (args.persist) { + return formatToolError( + 'Persisting approval mode changes is restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.', + ); + } + } + const sessionId = resolveSessionId(state, args.session_id); + return formatJsonResult( + await state.client.setSessionApprovalMode(sessionId, args.mode, { persist: args.persist }), + ); + }), + ), + + tool( + 'workspace_tool_toggle', + 'Enable or disable a tool in the workspace settings.', + { + tool_name: z.string().describe('Name of the tool to toggle.'), + enabled: z.boolean().describe('Whether to enable (true) or disable (false) the tool.'), + }, + handler(async (args) => { + if (!state.allowGlobalScope) { + return formatToolError( + 'Tool toggling is restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.', + ); + } + return formatJsonResult( + await state.client.setWorkspaceToolEnabled(args.tool_name, args.enabled), + ); + }), + ), + + tool( + 'workspace_init', + 'Scaffold an empty QWEN.md at the workspace root. No LLM invocation.', + { + force: z.boolean().optional().describe('Overwrite existing QWEN.md if present.'), + }, + handler(async (args) => + formatJsonResult( + await state.client.initWorkspace({ force: args.force }), + ), + ), + ), + + tool( + 'workspace_mcp_restart', + 'Restart a configured MCP server. Pre-checks budget before restarting.', + { + server_name: z.string().describe('Name of the MCP server to restart.'), + }, + handler(async (args) => { + if (!state.allowGlobalScope) { + return formatToolError( + 'MCP server restart is restricted for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.', + ); + } + return formatJsonResult( + await state.client.restartMcpServer(args.server_name), + ); + }), + ), + + tool( + 'workspace_memory_read', + 'Read workspace memory (QWEN.md hierarchy).', + {}, + handler(async () => + formatJsonResult(await state.client.workspaceMemory()), + ), + ), + + tool( + 'workspace_memory_write', + 'Write to workspace memory (QWEN.md). Supports append or replace mode.', + { + scope: z.enum(['workspace', 'global']).describe('Memory scope.'), + content: z.string().describe('Content to write.'), + mode: z.enum(['append', 'replace']).optional().describe('Write mode (default: append).'), + }, + handler(async (args) => { + if (args.scope === 'global' && !state.allowGlobalScope) { + return formatToolError( + 'Global scope is disabled for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.', + ); + } + return formatJsonResult( + await state.client.writeWorkspaceMemory({ + scope: args.scope, + content: args.content, + mode: args.mode, + }), + ); + }), + ), + + tool( + 'workspace_agents_manage', + 'Manage workspace agent definitions. Use action to list, get, create, update, or delete agents.', + { + action: z.enum(['list', 'get', 'create', 'update', 'delete']).describe('CRUD action to perform.'), + agent_type: z.string().optional().describe('Agent type name (required for get/update/delete).'), + name: z.string().optional().describe('Agent name (create only, required for create).'), + description: z.string().optional().describe('Agent description (required for create).'), + system_prompt: z.string().optional().describe('System prompt (required for create).'), + scope: z.enum(['workspace', 'global']).optional().describe('Agent scope (required for create).'), + tools: z.array(z.string()).optional().describe('Allowed tool names.'), + disallowed_tools: z.array(z.string()).optional().describe('Disallowed tool names.'), + model: z.string().optional().describe('Model ID for the agent.'), + }, + handler(async (args) => handleAgentsManage(state, args)), + ), + ]; +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +function validateGlobalScope(state: BridgeState, scope: string | undefined): any | null { + if (scope === 'global' && !state.allowGlobalScope) { + return formatToolError( + 'Global scope is disabled for security. Set QWEN_BRIDGE_ALLOW_GLOBAL_SCOPE=true to enable.', + ); + } + return null; +} + +async function handleAgentsManage(state: BridgeState, args: any): Promise { + switch (args.action) { + case 'list': + return formatJsonResult(await state.client.listWorkspaceAgents()); + case 'get': + return handleAgentGet(state, args); + case 'create': { + const scopeErr = validateGlobalScope(state, args.scope); + if (scopeErr) return scopeErr; + return handleAgentCreate(state, args); + } + case 'update': { + const scopeErr = validateGlobalScope(state, args.scope); + if (scopeErr) return scopeErr; + return handleAgentUpdate(state, args); + } + case 'delete': { + const scopeErr = validateGlobalScope(state, args.scope); + if (scopeErr) return scopeErr; + return handleAgentDelete(state, args); + } + default: + return formatToolError(`Unknown action: ${args.action}`); + } +} + +async function handleAgentGet(state: BridgeState, args: any): Promise { + if (!args.agent_type) { + return formatToolError('agent_type is required for get action.'); + } + return formatJsonResult(await state.client.getWorkspaceAgent(args.agent_type)); +} + +async function handleAgentCreate(state: BridgeState, args: any): Promise { + if (!args.name || !args.description || !args.system_prompt || !args.scope) { + return formatToolError( + 'name, description, system_prompt, and scope are required for create action.', + ); + } + return formatJsonResult( + await state.client.createWorkspaceAgent({ + name: args.name, + description: args.description, + systemPrompt: args.system_prompt, + scope: args.scope, + tools: args.tools, + disallowedTools: args.disallowed_tools, + model: args.model, + }), + ); +} + +async function handleAgentUpdate(state: BridgeState, args: any): Promise { + if (!args.agent_type) { + return formatToolError('agent_type is required for update action.'); + } + const hasField = + args.description !== undefined || + args.system_prompt !== undefined || + args.tools !== undefined || + args.disallowed_tools !== undefined || + args.model !== undefined; + if (!hasField) { + return formatToolError( + 'At least one field to update must be provided (description, system_prompt, tools, disallowed_tools, or model).', + ); + } + return formatJsonResult( + await state.client.updateWorkspaceAgent( + args.agent_type, + { + description: args.description, + systemPrompt: args.system_prompt, + tools: args.tools, + disallowedTools: args.disallowed_tools, + model: args.model, + }, + { scope: args.scope }, + ), + ); +} + +async function handleAgentDelete(state: BridgeState, args: any): Promise { + if (!args.agent_type) { + return formatToolError('agent_type is required for delete action.'); + } + await state.client.deleteWorkspaceAgent(args.agent_type, { + scope: args.scope, + }); + return formatJsonResult({ ok: true, deleted: args.agent_type }); +} diff --git a/packages/sdk-typescript/src/daemon-mcp/serve-bridge/types.ts b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/types.ts new file mode 100644 index 00000000000..4ee2066e9e3 --- /dev/null +++ b/packages/sdk-typescript/src/daemon-mcp/serve-bridge/types.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Type definitions for serve-bridge MCP server. + * + * Runtime implementations are in: + * - ./sse.ts — SSE stream lifecycle (startEventStream, stopEventStream, createPromptCollector) + * - ./helpers.ts — Utility functions (handler, resolveSessionId) + * + * Tool modules should import runtime functions directly from their source files, + * not from this module, to avoid circular dependency risks. + */ + +import type { DaemonClient } from '../../daemon/DaemonClient.js'; + +/** + * Options for creating a serve-bridge MCP server. + */ +export interface ServeBridgeMcpServerOptions { + /** Daemon base URL (e.g. "http://127.0.0.1:4170"). */ + daemonUrl: string; + /** Bearer token for daemon auth. */ + token?: string; + /** Workspace CWD for auto-session creation. */ + workspaceCwd?: string; + /** Allow tools to write to global scope (memory, agents). Defaults to false for security. */ + allowGlobalScope?: boolean; +} + +/** + * Tracks a per-prompt message collection cycle. + * Created before sending a prompt, resolved when _meta arrives or prompt returns. + */ +export interface PromptCollector { + texts: string[]; + resolve: () => void; + promise: Promise; + resolved: boolean; + /** Set when the collector is resolved due to SSE disconnect or stopEventStream, not _meta. */ + interrupted?: boolean; +} + +/** + * Persistent SSE connection for a session. + * Established at session_create, torn down at session_close. + */ +export interface SessionEventStream { + sessionId: string; + abortCtrl: AbortController; + /** Current active prompt collector (null when idle). */ + activeCollector: PromptCollector | null; + /** Timestamp of last activity (prompt sent or chunk received). */ + lastActivityMs: number; +} + +/** + * Mutable bridge state shared across all tool handlers. + */ +export interface BridgeState { + client: DaemonClient; + /** Daemon base URL for raw fetch calls to endpoints not in DaemonClient. */ + daemonUrl: string; + /** Bearer token for auth headers in raw fetch calls. */ + token: string | undefined; + defaultSessionId: string | undefined; + workspaceCwd: string | undefined; + /** Persistent SSE connections keyed by sessionId. */ + eventStreams: Map; + /** Whether global scope writes are allowed (default: false). */ + allowGlobalScope: boolean; +} diff --git a/packages/sdk-typescript/src/mcp/tool.ts b/packages/sdk-typescript/src/daemon-mcp/tool.ts similarity index 98% rename from packages/sdk-typescript/src/mcp/tool.ts rename to packages/sdk-typescript/src/daemon-mcp/tool.ts index ab5fa7e45c8..f3459c0375f 100644 --- a/packages/sdk-typescript/src/mcp/tool.ts +++ b/packages/sdk-typescript/src/daemon-mcp/tool.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2025 Qwen Team + * Copyright 2026 Qwen Team * SPDX-License-Identifier: Apache-2.0 */ diff --git a/packages/sdk-typescript/src/daemon/DaemonClient.ts b/packages/sdk-typescript/src/daemon/DaemonClient.ts index c438890a605..af0b05710d4 100644 --- a/packages/sdk-typescript/src/daemon/DaemonClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonClient.ts @@ -16,10 +16,12 @@ import type { DaemonDeviceFlowState, DaemonEvent, DaemonSessionContextStatus, + DaemonSessionContextUsageStatus, DaemonRestoredSession, DaemonSession, DaemonSessionSummary, DaemonSessionSupportedCommandsStatus, + DaemonSessionTasksStatus, DaemonUpdateAgentRequest, DaemonWorkspaceFile, DaemonWorkspaceFileBytes, @@ -50,6 +52,7 @@ import type { DaemonInitWorkspaceResult, DaemonMcpRestartResult, DaemonSessionRecapResult, + DaemonShellCommandResult, DaemonToolToggleResult, } from './types.js'; @@ -246,6 +249,15 @@ export interface PromptRequest { [key: string]: unknown; } +/** + * 202 Accepted envelope returned by non-blocking + * `POST /session/:id/prompt`. + */ +export interface NonBlockingPromptAccepted { + promptId: string; + lastEventId: number; +} + export interface SubscribeOptions { /** Resume from after this event id (`Last-Event-ID` header). */ lastEventId?: number; @@ -554,6 +566,45 @@ export class DaemonClient { ); } + async fileStat(filePath: string): Promise { + const url = new URL(`${this.baseUrl}/stat`); + url.searchParams.set('path', filePath); + return await this.fetchWithTimeout( + url.toString(), + { headers: this.headers() }, + async (res) => { + if (!res.ok) throw await this.failOnError(res, 'GET /stat'); + return (await res.json()) as unknown; + }, + ); + } + + async dirList(dirPath: string): Promise { + const url = new URL(`${this.baseUrl}/list`); + url.searchParams.set('path', dirPath); + return await this.fetchWithTimeout( + url.toString(), + { headers: this.headers() }, + async (res) => { + if (!res.ok) throw await this.failOnError(res, 'GET /list'); + return (await res.json()) as unknown; + }, + ); + } + + async glob(pattern: string): Promise { + const url = new URL(`${this.baseUrl}/glob`); + url.searchParams.set('pattern', pattern); + return await this.fetchWithTimeout( + url.toString(), + { headers: this.headers() }, + async (res) => { + if (!res.ok) throw await this.failOnError(res, 'GET /glob'); + return (await res.json()) as unknown; + }, + ); + } + async writeWorkspaceFile( req: DaemonWorkspaceFileWriteRequest, clientId?: string, @@ -930,6 +981,28 @@ export class DaemonClient { ); } + async sessionContextUsage( + sessionId: string, + opts: { detail?: boolean } = {}, + clientId?: string, + ): Promise { + const params = new URLSearchParams(); + if (opts.detail === true) params.set('detail', 'true'); + const query = params.toString(); + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/context-usage${ + query ? `?${query}` : '' + }`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/context-usage'); + } + return (await res.json()) as DaemonSessionContextUsageStatus; + }, + ); + } + async sessionSupportedCommands( sessionId: string, clientId?: string, @@ -949,6 +1022,22 @@ export class DaemonClient { ); } + async sessionTasks( + sessionId: string, + clientId?: string, + ): Promise { + return await this.fetchWithTimeout( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/tasks`, + { headers: this.headers({}, clientId) }, + async (res) => { + if (!res.ok) { + throw await this.failOnError(res, 'GET /session/:id/tasks'); + } + return (await res.json()) as DaemonSessionTasksStatus; + }, + ); + } + /** * Shared transport for `loadSession` / `resumeSession`. Both routes * share an identical wire shape (POST /session/:id/{load|resume} @@ -1066,6 +1155,27 @@ export class DaemonClient { return (await res.json()) as DaemonSessionRecapResult; } + async shellCommand( + sessionId: string, + command: string, + opts?: { signal?: AbortSignal; clientId?: string }, + ): Promise { + const res = await this._fetch( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/shell`, + { + method: 'POST', + headers: this.headers( + { 'Content-Type': 'application/json' }, + opts?.clientId, + ), + body: JSON.stringify({ command }), + signal: opts?.signal, + }, + ); + if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/shell'); + return (await res.json()) as DaemonShellCommandResult; + } + /** * #4175 Wave 4 PR 17. Toggle a tool name in the workspace's * `tools.disabled` settings list. Strict-gated mutation route — the @@ -1216,14 +1326,15 @@ export class DaemonClient { } /** - * Send a prompt to the agent. Long-lived: a model + tool turn can - * take minutes, so this method bypasses `fetchTimeoutMs` (which - * would force a default 30s deadline that's too short for normal - * use). Cancellation is via the optional `signal` — when it fires, - * the daemon receives the underlying TCP close and forwards an - * ACP `cancel` notification to the agent, resolving the prompt - * with `stopReason: 'cancelled'`. `cancel(sessionId)` is the - * out-of-band alternative. + * Send a prompt to the agent. Supports both blocking (legacy 200) + * and non-blocking (202 + SSE `turn_complete`) daemon responses. + * + * For 202 daemons this opens a **temporary** SSE subscription to + * await the matching `turn_complete`/`turn_error`. Callers that + * already manage a long-lived SSE subscription (e.g. + * `DaemonSessionClient`) should prefer {@link promptNonBlocking} + * and correlate via their existing event stream to avoid the extra + * connection. */ async prompt( sessionId: string, @@ -1240,10 +1351,97 @@ export class DaemonClient { signal, }, ); + + if (res.status === 202) { + const accept = (await res.json()) as NonBlockingPromptAccepted; + return this._awaitTurnComplete( + sessionId, + accept.promptId, + accept.lastEventId, + signal, + clientId, + ); + } + if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/prompt'); return (await res.json()) as PromptResult; } + /** + * Fire-and-forget prompt trigger. Returns the 202 acceptance + * envelope (`{ promptId, lastEventId }`) without waiting for the + * turn to complete. The caller is responsible for observing + * `turn_complete` / `turn_error` on the session's SSE stream, + * matching by `promptId`. + * + * This is the recommended path for callers that already maintain a + * long-lived SSE subscription (like `DaemonSessionClient`) — + * avoids the extra SSE connection that {@link prompt} opens for + * the temporary 202 fallback. + * + * Falls back to `prompt()` for legacy 200 daemons. + */ + async promptNonBlocking( + sessionId: string, + req: PromptRequest, + signal?: AbortSignal, + clientId?: string, + ): Promise { + const res = await this._fetch( + `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`, + { + method: 'POST', + headers: this.headers({ 'Content-Type': 'application/json' }, clientId), + body: JSON.stringify(req), + signal, + }, + ); + + if (res.status === 202) { + return (await res.json()) as NonBlockingPromptAccepted; + } + + if (!res.ok) throw await this.failOnError(res, 'POST /session/:id/prompt'); + return (await res.json()) as PromptResult; + } + + private async _awaitTurnComplete( + sessionId: string, + promptId: string, + lastEventId: number, + signal?: AbortSignal, + clientId?: string, + ): Promise { + const sseAbort = new AbortController(); + const composedSignal = signal + ? composeAbortSignals([signal, sseAbort.signal]) + : sseAbort.signal; + + try { + const events = this.subscribeEvents(sessionId, { + lastEventId, + signal: composedSignal, + }); + for await (const event of events) { + const result = matchTurnEvent(event, promptId); + if (result !== undefined) return result; + } + throw new Error('SSE stream ended without turn completion'); + } catch (err) { + if ( + signal?.aborted && + err instanceof DOMException && + err.name === 'AbortError' + ) { + this.cancel(sessionId, clientId).catch(() => {}); + throw err; + } + throw err; + } finally { + if (!sseAbort.signal.aborted) sseAbort.abort(); + } + } + /** * Bump the daemon's last-seen bookkeeping for this session. The * route is short-lived — drives diagnostics and future revocation @@ -1748,3 +1946,46 @@ export function composeAbortSignals(signals: AbortSignal[]): AbortSignal { ctrl.signal.addEventListener('abort', detachAll, { once: true }); return ctrl.signal; } + +/** + * Check whether a daemon SSE event is a `turn_complete` or + * `turn_error` matching `promptId`. Returns `PromptResult` on + * `turn_complete`, throws `DaemonHttpError` on `turn_error`, + * returns `undefined` for non-matching / unrelated events. + * + * Extracted so both `DaemonClient._awaitTurnComplete` (temporary SSE + * fallback) and `DaemonSessionClient.prompt` (existing subscription + * path) share the same matching logic. + */ +export function matchTurnEvent( + event: DaemonEvent, + promptId: string, +): PromptResult | undefined { + if (event.type === 'turn_complete') { + const data = event.data as { promptId?: string; stopReason?: string }; + if (data.promptId === promptId) { + return { stopReason: data.stopReason ?? 'end_turn' }; + } + } + if (event.type === 'turn_error') { + const data = event.data as { + promptId?: string; + message?: string; + code?: string; + }; + if (data.promptId === promptId) { + throw new DaemonHttpError( + 500, + data.code ?? 'turn_error', + data.message ?? 'Prompt failed', + ); + } + } + return undefined; +} + +export function isNonBlockingAccepted( + result: NonBlockingPromptAccepted | PromptResult, +): result is NonBlockingPromptAccepted { + return 'promptId' in result && 'lastEventId' in result; +} diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 4e8a2374e5c..2ff7eb40d32 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -6,6 +6,8 @@ import type { DaemonClient } from './DaemonClient.js'; import { + isNonBlockingAccepted, + matchTurnEvent, type CreateSessionRequest, type PromptRequest, type RestoreSessionRequest, @@ -14,10 +16,13 @@ import { import type { DaemonEvent, DaemonSessionContextStatus, + DaemonSessionContextUsageStatus, DaemonSessionRecapResult, + DaemonShellCommandResult, DaemonSessionState, DaemonSession, DaemonSessionSupportedCommandsStatus, + DaemonSessionTasksStatus, HeartbeatResult, PermissionResponse, PromptResult, @@ -65,6 +70,13 @@ export class DaemonSessionClient { readonly state: DaemonSessionState; private lastSeenEventId: number | undefined; private subscriptionActive = false; + private readonly _pendingPrompts = new Map< + string, + { + resolve: (r: PromptResult) => void; + reject: (e: unknown) => void; + } + >(); constructor(opts: DaemonSessionClientOptions) { this.client = opts.client; @@ -193,7 +205,45 @@ export class DaemonSessionClient { req: PromptRequest, signal?: AbortSignal, ): Promise { - return await this.client.prompt(this.sessionId, req, signal, this.clientId); + if (!this.subscriptionActive) { + return await this.client.prompt( + this.sessionId, + req, + signal, + this.clientId, + ); + } + + const accepted = await this.client.promptNonBlocking( + this.sessionId, + req, + signal, + this.clientId, + ); + if (!isNonBlockingAccepted(accepted)) { + return accepted; + } + + return new Promise((resolve, reject) => { + const onAbort = () => { + if (this._pendingPrompts.delete(accepted.promptId)) { + this.client.cancel(this.sessionId, this.clientId).catch(() => {}); + reject(signal!.reason ?? new DOMException('Aborted', 'AbortError')); + } + }; + const cleanup = () => signal?.removeEventListener('abort', onAbort); + this._pendingPrompts.set(accepted.promptId, { + resolve: (r) => { + cleanup(); + resolve(r); + }, + reject: (e) => { + cleanup(); + reject(e); + }, + }); + signal?.addEventListener('abort', onAbort, { once: true }); + }); } async cancel(): Promise { @@ -236,10 +286,30 @@ export class DaemonSessionClient { }); } + async shellCommand( + command: string, + signal?: AbortSignal, + ): Promise { + return await this.client.shellCommand(this.sessionId, command, { + ...(signal ? { signal } : {}), + ...(this.clientId ? { clientId: this.clientId } : {}), + }); + } + async context(): Promise { return await this.client.sessionContext(this.sessionId, this.clientId); } + async contextUsage( + opts: { detail?: boolean } = {}, + ): Promise { + return await this.client.sessionContextUsage( + this.sessionId, + opts, + this.clientId, + ); + } + async supportedCommands(): Promise { return await this.client.sessionSupportedCommands( this.sessionId, @@ -247,6 +317,10 @@ export class DaemonSessionClient { ); } + async tasks(): Promise { + return await this.client.sessionTasks(this.sessionId, this.clientId); + } + async respondToPermission( requestId: string, response: PermissionResponse, @@ -367,13 +441,8 @@ export class DaemonSessionClient { ...subscribeOpts, lastEventId, })) { + this._dispatchTurnEvent(event); yield event; - // Cursor updates happen after the consumer resumes iteration. That - // avoids acknowledging an event before the adapter has processed it, - // but means `lastEventId` intentionally lags while the handler for the - // just-yielded event is still running. - // The cursor is a replay watermark, so it only moves forward even if a - // replayed or synthetic frame arrives with an older id. if (event.id !== undefined) { this.lastSeenEventId = Math.max( this.lastSeenEventId ?? 0, @@ -382,9 +451,33 @@ export class DaemonSessionClient { } } } finally { + this._rejectAllPending(new Error('SSE stream ended')); release(); } } + + private _dispatchTurnEvent(event: DaemonEvent): void { + if (event.type !== 'turn_complete' && event.type !== 'turn_error') return; + const promptId = (event.data as { promptId?: string } | null | undefined) + ?.promptId; + if (!promptId) return; + const pending = this._pendingPrompts.get(promptId); + if (!pending) return; + this._pendingPrompts.delete(promptId); + try { + const result = matchTurnEvent(event, promptId); + if (result !== undefined) pending.resolve(result); + } catch (err) { + pending.reject(err); + } + } + + private _rejectAllPending(err: unknown): void { + for (const [, pending] of this._pendingPrompts) { + pending.reject(err); + } + this._pendingPrompts.clear(); + } } function validateLastEventId(lastEventId: number): number; diff --git a/packages/sdk-typescript/src/daemon/events.ts b/packages/sdk-typescript/src/daemon/events.ts index 7814d8eb564..fbca5c8fd36 100644 --- a/packages/sdk-typescript/src/daemon/events.ts +++ b/packages/sdk-typescript/src/daemon/events.ts @@ -87,6 +87,20 @@ const DAEMON_KNOWN_EVENT_TYPE_VALUES = [ // was nothing to replay (`data.replayedCount === 0`). 'prompt_cancelled', 'replay_complete', + // Daemon assist push events. `followup_suggestion`: server-side + // ghost-text "what you might want to ask next" suggestion, generated + // after each end_turn by the ACP child and forwarded through the per- + // session SSE bus so the webui (and other future daemon adapters) + // can render the suggestion in their input placeholder. The wire + // carries only post-filter suggestions (`getFilterReason()===null`); + // generator-side suppression telemetry stays on the daemon. Old SDK + // consumers silently drop this event via `asKnownDaemonEvent` + // returning undefined (no protocol bump required). + 'followup_suggestion', + 'user_shell_command', + 'user_shell_result', + 'turn_complete', + 'turn_error', ] as const; const DAEMON_KNOWN_EVENT_TYPES: ReadonlySet = new Set( @@ -277,9 +291,13 @@ export interface DaemonStreamErrorData { */ export interface DaemonStateResyncRequiredData { /** - * Machine-readable resync reason. Currently always `'ring_evicted'` - * (the only case the daemon emits this frame for); reserved for - * future causes (e.g. `'schema_version_bump'`). + * Machine-readable resync reason. One of: + * - `'ring_evicted'`: consumer's `Last-Event-ID` fell behind the ring's + * earliest surviving id (same-epoch gap). + * - `'epoch_reset'`: consumer's `Last-Event-ID` is past the bus + * high-water — its cursor is from a previous bus epoch (daemon + * restart rebuilt the EventBus). The whole fresh ring is replayed. + * Reserved for future causes (e.g. `'schema_version_bump'`). */ reason: string; /** Consumer's `Last-Event-ID` at reconnect time. */ @@ -591,6 +609,36 @@ export interface DaemonMcpServerRestartRefusedData { [key: string]: unknown; } +/** + * Daemon assist push: a follow-up suggestion generated by the ACP child + * after an end_turn completes. `suggestion` is already post-filter + * (`getFilterReason()===null`) and non-empty — the wire never carries + * rejected suggestions. `promptId` correlates with the just-completed + * turn (`########` shape) so clients can suppress + * stale events that race a fresh user prompt. + */ +export interface DaemonFollowupSuggestionData { + sessionId: string; + suggestion: string; + promptId: string; + [key: string]: unknown; +} + +export interface DaemonTurnCompleteData { + sessionId: string; + stopReason: string; + promptId?: string; + [key: string]: unknown; +} + +export interface DaemonTurnErrorData { + sessionId: string; + message: string; + code?: string; + promptId?: string; + [key: string]: unknown; +} + export type DaemonSessionUpdateEvent = DaemonEventEnvelope< 'session_update', DaemonSessionUpdateData @@ -709,6 +757,20 @@ export type DaemonAuthDeviceFlowCancelledEvent = DaemonEventEnvelope< DaemonAuthDeviceFlowCancelledData >; +export type DaemonFollowupSuggestionEvent = DaemonEventEnvelope< + 'followup_suggestion', + DaemonFollowupSuggestionData +>; + +export type DaemonTurnCompleteEvent = DaemonEventEnvelope< + 'turn_complete', + DaemonTurnCompleteData +>; +export type DaemonTurnErrorEvent = DaemonEventEnvelope< + 'turn_error', + DaemonTurnErrorData +>; + export type DaemonAuthEvent = | DaemonAuthDeviceFlowStartedEvent | DaemonAuthDeviceFlowThrottledEvent @@ -762,13 +824,28 @@ export type DaemonWorkspaceMutationEvent = | DaemonMemoryChangedEvent | DaemonAgentChangedEvent; +/** + * Daemon assist push events — non-terminal UX hints emitted by the ACP + * child on the per-session SSE bus. Today only `followup_suggestion` + * (server-side ghost-text suggestion after each end_turn); the union + * is reserved for future assist events (e.g. server-side speculation + * results, contextual help) that share the same "best-effort UX hint, + * client may ignore" semantics. Adapters that don't render assist + * hints can ignore this whole branch. + */ +export type DaemonAssistEvent = DaemonFollowupSuggestionEvent; + +export type DaemonTurnEvent = DaemonTurnCompleteEvent | DaemonTurnErrorEvent; + export type KnownDaemonEvent = | DaemonSessionEvent | DaemonControlEvent | DaemonStreamLifecycleEvent | DaemonMcpGuardrailEvent | DaemonWorkspaceMutationEvent - | DaemonAuthEvent; + | DaemonAuthEvent + | DaemonAssistEvent + | DaemonTurnEvent; export interface DaemonSessionViewState { lastEventId?: number; @@ -941,6 +1018,17 @@ export interface DaemonSessionViewState { resyncRequiredCount: number; /** Most recent resync payload (reason + gap range). */ lastResyncRequired?: DaemonStateResyncRequiredData; + /** + * Daemon assist push: most recent `followup_suggestion` observed on + * this session. Adapters render it as ghost-text in the input + * placeholder; clients self-invalidate on next sendPrompt (no + * server round-trip needed). `promptId` correlates with the turn + * that produced the suggestion. Undefined until the daemon emits + * at least one suggestion. + */ + lastFollowupSuggestion?: DaemonFollowupSuggestionData; + lastTurnComplete?: DaemonTurnCompleteData; + lastTurnError?: DaemonTurnErrorData; } /** @@ -1035,6 +1123,7 @@ export function createDaemonSessionViewState( awaitingResync: seed.awaitingResync ?? false, resyncRequiredCount: seed.resyncRequiredCount ?? 0, lastResyncRequired: seed.lastResyncRequired, + lastFollowupSuggestion: seed.lastFollowupSuggestion, }; } @@ -1199,6 +1288,18 @@ export function asKnownDaemonEvent( return isMcpServerRestartRefusedData(event.data) ? (event as DaemonMcpServerRestartRefusedEvent) : undefined; + case 'followup_suggestion': + return isFollowupSuggestionData(event.data) + ? (event as DaemonFollowupSuggestionEvent) + : undefined; + case 'turn_complete': + return isTurnCompleteData(event.data) + ? (event as DaemonTurnCompleteEvent) + : undefined; + case 'turn_error': + return isTurnErrorData(event.data) + ? (event as DaemonTurnErrorEvent) + : undefined; default: return undefined; } @@ -1538,6 +1639,26 @@ export function reduceDaemonSessionEvent( mcpRestartRefusedCount: base.mcpRestartRefusedCount + 1, lastMcpRestartRefused: mergeOriginator(event.data, event), }; + case 'followup_suggestion': + // Daemon assist push: latest suggestion replaces any prior one + // for this session. Best-effort UX hint — non-terminal, + // doesn't touch `alive` / `pendingPermissions`. Clients + // self-invalidate on next sendPrompt (no wire round-trip), so + // we don't emit "cleared" events on prompt boundaries. + return { + ...base, + lastFollowupSuggestion: event.data, + }; + case 'turn_complete': + return { + ...base, + lastTurnComplete: event.data, + }; + case 'turn_error': + return { + ...base, + lastTurnError: event.data, + }; default: { const _exhaustive: never = event; return _exhaustive; @@ -2224,6 +2345,38 @@ function isMcpServerRestartRefusedData( ); } +function isFollowupSuggestionData( + value: unknown, +): value is DaemonFollowupSuggestionData { + // `suggestion` must be a non-empty string — the daemon filters + // rejected suggestions server-side and only emits when accepted, + // so an empty suggestion on the wire is protocol garbage. Reject + // it via the unrecognized counter rather than overwriting view + // state with an empty suggestion. + return ( + isRecord(value) && + isNonEmptyString(value['sessionId']) && + isNonEmptyString(value['suggestion']) && + isNonEmptyString(value['promptId']) + ); +} + +function isTurnCompleteData(value: unknown): value is DaemonTurnCompleteData { + return ( + isRecord(value) && + isNonEmptyString(value['sessionId']) && + isNonEmptyString(value['stopReason']) + ); +} + +function isTurnErrorData(value: unknown): value is DaemonTurnErrorData { + return ( + isRecord(value) && + isNonEmptyString(value['sessionId']) && + isNonEmptyString(value['message']) + ); +} + function isPermissionOption(value: unknown): value is DaemonPermissionOption { return isRecord(value) && isNonEmptyString(value['optionId']); } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 6fcfae0b9f5..8a1871d2383 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -7,8 +7,11 @@ export { DaemonClient, DaemonHttpError, + isNonBlockingAccepted, + matchTurnEvent, type CreateSessionRequest, type DaemonClientOptions, + type NonBlockingPromptAccepted, type PromptRequest, type RestoreSessionRequest, type SubscribeOptions, @@ -69,6 +72,7 @@ export { sanitizeTerminalText as sanitizeDaemonTerminalText, selectApprovalMode, selectCurrentTool, + selectLastFollowupSuggestion, selectPendingPermissionBlocks, selectSubagentChildBlocks, selectToolProgress, @@ -131,6 +135,7 @@ export type { DaemonUiStateResyncRequiredEvent, DaemonUiReplayCompleteEvent, DaemonUiPromptCancelledEvent, + DaemonUiFollowupSuggestionEvent, DaemonUiStatusEvent, DaemonUiTextEvent, DaemonUiToolProvenance, @@ -227,6 +232,16 @@ export type { DaemonAuthDeviceFlowCancelledData, DaemonAuthDeviceFlowCancelledEvent, DaemonAuthEvent, + // Daemon assist push (server-side ghost-text suggestion) + DaemonAssistEvent, + DaemonFollowupSuggestionData, + DaemonFollowupSuggestionEvent, + // Non-blocking prompt completion events + DaemonTurnEvent, + DaemonTurnCompleteData, + DaemonTurnCompleteEvent, + DaemonTurnErrorData, + DaemonTurnErrorEvent, DaemonDeviceFlowReducerState, DaemonAuthState, KnownDaemonEvent, @@ -239,10 +254,15 @@ export type { DaemonInitWorkspaceResult, DaemonMcpRestartResult, DaemonSessionRecapResult, + DaemonShellCommandResult, DaemonToolToggleResult, DaemonAvailableCommand, DaemonCapabilities, + DaemonContextCategoryBreakdown, DaemonContextFileScope, + DaemonContextMemoryDetail, + DaemonContextSkillDetail, + DaemonContextToolDetail, DaemonCreateAgentRequest, DaemonEnvCell, DaemonEnvKind, @@ -263,9 +283,18 @@ export type { DaemonDeviceFlowStartResult, DaemonDeviceFlowState, DaemonSessionContextStatus, + DaemonSessionAgentTaskStatus, + DaemonSessionMonitorTaskStatus, + DaemonSessionProcessTaskLifecycleStatus, + DaemonSessionContextUsage, + DaemonSessionContextUsageStatus, DaemonSessionState, DaemonSessionSummary, + DaemonSessionShellTaskStatus, DaemonSessionSupportedCommandsStatus, + DaemonSessionTaskLifecycleStatus, + DaemonSessionTaskStatus, + DaemonSessionTasksStatus, DaemonSkillLevel, DaemonPreflightCell, DaemonPreflightKind, diff --git a/packages/sdk-typescript/src/daemon/types.ts b/packages/sdk-typescript/src/daemon/types.ts index 71e0ae5ad9d..3afa81b9d11 100644 --- a/packages/sdk-typescript/src/daemon/types.ts +++ b/packages/sdk-typescript/src/daemon/types.ts @@ -723,6 +723,55 @@ export interface DaemonSessionContextStatus { state: DaemonSessionState; } +export interface DaemonContextCategoryBreakdown { + systemPrompt: number; + builtinTools: number; + mcpTools: number; + memoryFiles: number; + skills: number; + messages: number; + freeSpace: number; + autocompactBuffer: number; +} + +export interface DaemonContextToolDetail { + name: string; + tokens: number; +} + +export interface DaemonContextMemoryDetail { + path: string; + tokens: number; +} + +export interface DaemonContextSkillDetail { + name: string; + tokens: number; + loaded?: boolean; + bodyTokens?: number; +} + +export interface DaemonSessionContextUsage { + modelName: string; + totalTokens: number; + contextWindowSize: number; + breakdown: DaemonContextCategoryBreakdown; + builtinTools: DaemonContextToolDetail[]; + mcpTools: DaemonContextToolDetail[]; + memoryFiles: DaemonContextMemoryDetail[]; + skills: DaemonContextSkillDetail[]; + isEstimated?: boolean; + showDetails?: boolean; +} + +export interface DaemonSessionContextUsageStatus { + v: 1; + sessionId: string; + workspaceCwd: string; + usage: DaemonSessionContextUsage; + formattedText: string; +} + export interface DaemonAvailableCommand { name: string; description?: string; @@ -737,6 +786,83 @@ export interface DaemonSessionSupportedCommandsStatus { availableSkills: string[]; } +export type DaemonSessionTaskLifecycleStatus = + | 'running' + | 'paused' + | 'completed' + | 'failed' + | 'cancelled'; + +export type DaemonSessionProcessTaskLifecycleStatus = + | 'running' + | 'completed' + | 'failed' + | 'cancelled'; + +export interface DaemonSessionAgentTaskStatus { + kind: 'agent'; + id: string; + label: string; + description: string; + status: DaemonSessionTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + subagentType?: string; + isBackgrounded: boolean; + error?: string; + resumeBlockedReason?: string; +} + +export interface DaemonSessionShellTaskStatus { + kind: 'shell'; + id: string; + label: string; + description: string; + status: DaemonSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + outputFile?: string; + command: string; + cwd: string; + pid?: number; + exitCode?: number; + error?: string; +} + +export interface DaemonSessionMonitorTaskStatus { + kind: 'monitor'; + id: string; + label: string; + description: string; + status: DaemonSessionProcessTaskLifecycleStatus; + startTime: number; + endTime?: number; + runtimeMs: number; + command: string; + pid?: number; + eventCount: number; + lastEventTime: number; + droppedLines: number; + exitCode?: number; + error?: string; + ownerAgentId?: string; +} + +export type DaemonSessionTaskStatus = + | DaemonSessionAgentTaskStatus + | DaemonSessionShellTaskStatus + | DaemonSessionMonitorTaskStatus; + +export interface DaemonSessionTasksStatus { + v: 1; + sessionId: string; + now: number; + tasks: DaemonSessionTaskStatus[]; +} + /** Returned from `POST /session/:id/model`. ACP currently allows an opaque body. */ export interface SetModelResult { [key: string]: unknown; @@ -830,6 +956,12 @@ export interface DaemonSessionRecapResult { recap: string | null; } +export interface DaemonShellCommandResult { + exitCode: number | null; + output: string; + aborted: boolean; +} + /** * #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/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 5c88fb2ef74..fd2f3c2f5ac 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -15,6 +15,7 @@ export { reduceDaemonTranscriptEvents, selectApprovalMode, selectCurrentTool, + selectLastFollowupSuggestion, selectPendingPermissionBlocks, selectSubagentChildBlocks, selectToolProgress, @@ -93,6 +94,8 @@ export type { DaemonUiStateResyncRequiredEvent, DaemonUiReplayCompleteEvent, DaemonUiPromptCancelledEvent, + // Daemon assist push (server-side ghost-text suggestion) + DaemonUiFollowupSuggestionEvent, // Workspace events DaemonUiWorkspaceMemoryChangedEvent, DaemonUiWorkspaceAgentChangedEvent, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 9c75e5564f7..62fcd3e7b29 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -155,12 +155,43 @@ export function normalizeDaemonEvent( case 'state_resync_required': return normalizeStateResyncRequired(event, base); - case 'prompt_cancelled': - return [{ ...base, type: 'prompt.cancelled' }]; + case 'prompt_cancelled': { + // Forward the optional `reason` (e.g. `'forward_failed'` from the + // bridge's C3 compensating broadcast) so consumers can distinguish a + // user cancel from a forward failure. + const reason = stringField(event.data, 'reason'); + return [ + { ...base, type: 'prompt.cancelled', ...(reason ? { reason } : {}) }, + ]; + } + + case 'followup_suggestion': + return normalizeFollowupSuggestion(event, base); + + case 'user_shell_command': { + const command = getString(event.data, 'command'); + return command + ? [{ ...base, type: 'user.text.delta', text: `! ${command}` }] + : []; + } + case 'user_shell_result': { + const exitCode = numberField(event.data, 'exitCode'); + const aborted = + isRecord(event.data) && + (event.data as Record)['aborted'] === true; + const text = aborted + ? 'Shell command was aborted' + : `Shell command exited with code ${exitCode ?? 'unknown'}`; + return [{ ...base, type: 'status', text }]; + } case 'replay_complete': { const replayedCount = numberField(event.data, 'replayedCount') ?? 0; - const lastReplayedEventId = numberField(event.data, 'lastEventId'); + // D4: prefer the canonical `lastReplayedEventId`; fall back to the + // deprecated `lastEventId` alias for daemons predating the rename. + const lastReplayedEventId = + numberField(event.data, 'lastReplayedEventId') ?? + numberField(event.data, 'lastEventId'); return [ { ...base, @@ -267,6 +298,27 @@ function normalizeStateResyncRequired( ]; } +function normalizeFollowupSuggestion( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const sessionId = getString(event.data, 'sessionId'); + const suggestion = getString(event.data, 'suggestion'); + const promptId = getString(event.data, 'promptId'); + if (!sessionId || !suggestion || !promptId) { + return fallbackDebug(event, base, 'malformed followup_suggestion payload'); + } + return [ + { + ...base, + type: 'followup.suggestion', + sessionId, + suggestion, + promptId, + }, + ]; +} + function createBase( event: DaemonEvent, opts: NormalizeDaemonEventOptions, @@ -387,6 +439,8 @@ function normalizeSessionUpdate( } case 'plan': return [normalizePlanUpdate(update, base)]; + case 'current_mode_update': + return []; default: return [ { @@ -1188,3 +1242,9 @@ function numberField(value: unknown, key: string): number | undefined { const v = value[key]; return typeof v === 'number' && Number.isFinite(v) ? v : undefined; } + +function stringField(value: unknown, key: string): string | undefined { + if (!isRecord(value)) return undefined; + const v = value[key]; + return typeof v === 'string' && v.length > 0 ? v : undefined; +} diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index a5e4e260b8c..68223be6921 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -98,6 +98,11 @@ export function createDaemonTranscriptStore( }; scheduleNotify(); }, + clearFollowupSuggestion() { + if (state.lastFollowupSuggestion === undefined) return; + state = { ...state, lastFollowupSuggestion: undefined }; + scheduleNotify(); + }, }; } @@ -141,5 +146,9 @@ function createState( seed.lastResyncRequired !== undefined ? { ...seed.lastResyncRequired } : undefined, + lastFollowupSuggestion: + seed.lastFollowupSuggestion !== undefined + ? { ...seed.lastFollowupSuggestion } + : undefined, }; } diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index 5501e7bc3f0..c77ded267a3 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -77,6 +77,12 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { ); case 'prompt.cancelled': return terminalLine('cancelled', 'prompt cancelled', '33'); + case 'followup.suggestion': + // Daemon assist push — useful in debug-style terminal tails but not + // rendered as a first-class UI affordance here (terminals don't have + // a notion of input-placeholder ghost text). Web / TUI adapters + // consume the typed event directly. + return terminalLine('suggestion', event.suggestion, '2'); case 'workspace.memory.changed': return terminalLine( 'memory', diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 6ceeee26898..363e96e0192 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -166,6 +166,9 @@ function applyDaemonTranscriptEvent( switch (event.type) { case 'user.text.delta': + if (!next.activeUserBlockId) { + next.lastFollowupSuggestion = undefined; + } appendTextDelta(next, 'user', 'activeUserBlockId', event.text, event); break; case 'assistant.text.delta': @@ -259,6 +262,17 @@ function applyDaemonTranscriptEvent( // tool_call_update frames. propagateCancellationToInFlightTools(next); break; + case 'followup.suggestion': + // Sidechannel: latest assist hint replaces any prior one for the + // session. No transcript block — adapters render the suggestion + // as ghost-text in their input placeholder via the sidechannel + // selector. Self-invalidated by the adapter on next sendPrompt + // (no wire round-trip). + next.lastFollowupSuggestion = { + suggestion: event.suggestion, + promptId: event.promptId, + }; + break; case 'session.replay_complete': // Sidechannel signal only — consumers read it off the event // stream (or `selectors`) to drop a catch-up indicator. No @@ -791,6 +805,12 @@ function cloneTranscriptState( state.lastResyncRequired !== undefined ? { ...state.lastResyncRequired } : undefined, + // Share the reference — the reducer assigns a new object when + // updating (never mutates in-place), so reference stability across + // unrelated dispatches lets `useSyncExternalStore` subscribers + // (e.g. `useDaemonFollowupSuggestion`) skip re-renders for events + // that don't touch the suggestion. + lastFollowupSuggestion: state.lastFollowupSuggestion, }; } @@ -1095,6 +1115,19 @@ export function selectApprovalMode( return state.approvalMode; } +/** + * Most recent follow-up suggestion observed for the session, mirrored + * from `followup.suggestion` events. Adapters render the `suggestion` + * as ghost-text in their input placeholder. Returns `undefined` until + * the daemon emits at least one suggestion, or after the consumer + * clears it via `clearFollowupSuggestion` (typically on sendPrompt). + */ +export function selectLastFollowupSuggestion( + state: DaemonTranscriptState, +): { suggestion: string; promptId: string } | undefined { + return state.lastFollowupSuggestion; +} + /** * Per-tool progress query. Returns `undefined` if no progress has been * recorded for the given toolCallId. The shape `{ ratio?, step? }` matches diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index ef7029dc3c3..9f9f60c9353 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -36,6 +36,8 @@ export type DaemonUiEventType = | 'session.replay_complete' // Prompt lifecycle (cross-client) | 'prompt.cancelled' + // Daemon assist push (server-side ghost-text suggestion) + | 'followup.suggestion' // Workspace events (Wave 3-4) | 'workspace.memory.changed' | 'workspace.agent.changed' @@ -257,6 +259,31 @@ export interface DaemonUiStateResyncRequiredEvent extends DaemonUiEventBase { */ export interface DaemonUiPromptCancelledEvent extends DaemonUiEventBase { type: 'prompt.cancelled'; + /** + * Why the turn was cancelled. Absent for a user-initiated cancel; + * `'forward_failed'` when the daemon synthesized the cancel because the + * prompt forward rejected after the user echo was already published (the + * bridge's C3 compensating broadcast). Lets the UI distinguish "peer + * cancelled" from "the request failed to reach the agent". + */ + reason?: 'forward_failed' | (string & {}); +} + +/** + * Daemon assist push: a follow-up suggestion the ACP child generated + * after the last end_turn. Adapters render it as ghost-text in the + * input placeholder. The suggestion is already post-filter + * (`getFilterReason()===null`) and non-empty — the wire never + * carries rejected suggestions. `promptId` correlates with the + * just-completed turn, so consumers can suppress stale events that + * race a fresh user prompt (typically by clearing local display + * state on sendPrompt). + */ +export interface DaemonUiFollowupSuggestionEvent extends DaemonUiEventBase { + type: 'followup.suggestion'; + sessionId: string; + suggestion: string; + promptId: string; } /** @@ -404,6 +431,8 @@ export type DaemonUiEvent = | DaemonUiReplayCompleteEvent // Prompt lifecycle (cross-client) | DaemonUiPromptCancelledEvent + // Daemon assist push (server-side ghost-text suggestion) + | DaemonUiFollowupSuggestionEvent // Workspace events | DaemonUiWorkspaceMemoryChangedEvent | DaemonUiWorkspaceAgentChangedEvent @@ -703,6 +732,19 @@ export interface DaemonTranscriptSidechannelState { lastDeliveredId: number; earliestAvailableId: number; }; + /** + * Daemon assist push: most recent `followup.suggestion` observed. + * Adapters render the `suggestion` as ghost-text in the input + * placeholder. `promptId` correlates with the turn that produced it + * so consumers can correlate / suppress stale suggestions after a + * fresh user prompt. Undefined until the daemon emits one for this + * session. Self-invalidated by consumers on sendPrompt (no wire + * round-trip). + */ + lastFollowupSuggestion?: { + suggestion: string; + promptId: string; + }; } export interface DaemonTranscriptState @@ -754,6 +796,14 @@ export interface DaemonTranscriptStore { * Clear FIRST, then stream events.) */ clearAwaitingResync(): void; + /** + * Clear `lastFollowupSuggestion` from sidechannel state. Adapters call + * this on sendPrompt so the prior turn's ghost-text suggestion stops + * rendering immediately (no wire round-trip — server-side + * invalidation would waste a ring slot per prompt). Idempotent: no-op + * when no suggestion is set. + */ + clearFollowupSuggestion(): void; } export interface DaemonUiSessionActions { diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 8007f99bf5d..3c97cee5b62 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -30,6 +30,7 @@ export { type DaemonInitWorkspaceResult, type DaemonMcpRestartResult, type DaemonSessionRecapResult, + type DaemonShellCommandResult, type DaemonMcpServerRestartedData, type DaemonMcpServerRestartedEvent, type DaemonMcpServerRestartRefusedData, @@ -88,13 +89,20 @@ export { type DaemonSessionClosedReason, type DaemonSessionClientOptions, type DaemonSessionContextStatus, + type DaemonSessionAgentTaskStatus, + type DaemonSessionMonitorTaskStatus, + type DaemonSessionProcessTaskLifecycleStatus, type DaemonSessionDiedData, type DaemonSessionDiedEvent, type DaemonSessionEvent, + type DaemonSessionShellTaskStatus, type DaemonSessionSubscribeOptions, type DaemonSessionState, type DaemonSessionSummary, type DaemonSessionSupportedCommandsStatus, + type DaemonSessionTaskLifecycleStatus, + type DaemonSessionTaskStatus, + type DaemonSessionTasksStatus, type DaemonSkillLevel, type DaemonPreflightCell, type DaemonPreflightKind, @@ -116,6 +124,10 @@ export { type DaemonStreamErrorData, type DaemonStreamErrorEvent, type DaemonStreamLifecycleEvent, + // Daemon assist push (server-side ghost-text suggestion) + type DaemonAssistEvent, + type DaemonFollowupSuggestionData, + type DaemonFollowupSuggestionEvent, type DaemonWorkspaceMcpServerStatus, type DaemonWorkspaceMcpStatus, type DaemonWorkspaceProviderCurrent, @@ -188,15 +200,18 @@ export { } from './daemon/index.js'; // SDK MCP Server exports -export { tool } from './mcp/tool.js'; -export { createSdkMcpServer } from './mcp/createSdkMcpServer.js'; +export { tool } from './daemon-mcp/tool.js'; +export { createSdkMcpServer } from './daemon-mcp/createSdkMcpServer.js'; +export { createServeBridgeMcpServer } from './daemon-mcp/serve-bridge/index.js'; -export type { SdkMcpToolDefinition } from './mcp/tool.js'; +export type { SdkMcpToolDefinition } from './daemon-mcp/tool.js'; export type { CreateSdkMcpServerOptions, McpSdkServerConfigWithInstance, -} from './mcp/createSdkMcpServer.js'; +} from './daemon-mcp/createSdkMcpServer.js'; + +export type { ServeBridgeMcpServerOptions } from './daemon-mcp/serve-bridge/index.js'; export type { QueryOptions } from './query/createQuery.js'; export type { LogLevel, LoggerConfig, ScopedLogger } from './utils/logger.js'; diff --git a/packages/sdk-typescript/src/mcp/formatters.ts b/packages/sdk-typescript/src/mcp/formatters.ts deleted file mode 100644 index a71e12ff1f1..00000000000 --- a/packages/sdk-typescript/src/mcp/formatters.ts +++ /dev/null @@ -1,194 +0,0 @@ -/** - * Tool result formatting utilities for MCP responses - * - * Converts various output types to MCP content blocks. - */ - -export type McpContentBlock = - | { type: 'text'; text: string } - | { type: 'image'; data: string; mimeType: string } - | { type: 'resource'; uri: string; mimeType?: string; text?: string }; - -export interface ToolResult { - content: McpContentBlock[]; - isError?: boolean; -} - -export function formatToolResult(result: unknown): ToolResult { - // Handle Error objects - if (result instanceof Error) { - return { - content: [ - { - type: 'text', - text: result.message || 'Unknown error', - }, - ], - isError: true, - }; - } - - // Handle null/undefined - if (result === null || result === undefined) { - return { - content: [ - { - type: 'text', - text: '', - }, - ], - }; - } - - // Handle string - if (typeof result === 'string') { - return { - content: [ - { - type: 'text', - text: result, - }, - ], - }; - } - - // Handle number - if (typeof result === 'number') { - return { - content: [ - { - type: 'text', - text: String(result), - }, - ], - }; - } - - // Handle boolean - if (typeof result === 'boolean') { - return { - content: [ - { - type: 'text', - text: String(result), - }, - ], - }; - } - - // Handle object (including arrays) - if (typeof result === 'object') { - try { - return { - content: [ - { - type: 'text', - text: JSON.stringify(result, null, 2), - }, - ], - }; - } catch { - // JSON.stringify failed - return { - content: [ - { - type: 'text', - text: String(result), - }, - ], - }; - } - } - - // Fallback: convert to string - return { - content: [ - { - type: 'text', - text: String(result), - }, - ], - }; -} - -export function formatToolError(error: Error | string): ToolResult { - const message = error instanceof Error ? error.message : error; - - return { - content: [ - { - type: 'text', - text: message, - }, - ], - isError: true, - }; -} - -export function formatTextResult(text: string): ToolResult { - return { - content: [ - { - type: 'text', - text, - }, - ], - }; -} - -export function formatJsonResult(data: unknown): ToolResult { - return { - content: [ - { - type: 'text', - text: JSON.stringify(data, null, 2), - }, - ], - }; -} - -export function mergeToolResults(results: ToolResult[]): ToolResult { - const mergedContent: McpContentBlock[] = []; - let hasError = false; - - for (const result of results) { - mergedContent.push(...result.content); - if (result.isError) { - hasError = true; - } - } - - return { - content: mergedContent, - isError: hasError, - }; -} - -export function isValidContentBlock(block: unknown): block is McpContentBlock { - if (!block || typeof block !== 'object') { - return false; - } - - const blockObj = block as Record; - - if (!blockObj.type || typeof blockObj.type !== 'string') { - return false; - } - - switch (blockObj.type) { - case 'text': - return typeof blockObj.text === 'string'; - - case 'image': - return ( - typeof blockObj.data === 'string' && - typeof blockObj.mimeType === 'string' - ); - - case 'resource': - return typeof blockObj.uri === 'string'; - - default: - return false; - } -} diff --git a/packages/sdk-typescript/src/query/Query.ts b/packages/sdk-typescript/src/query/Query.ts index 1cce58c8143..09c792f3912 100644 --- a/packages/sdk-typescript/src/query/Query.ts +++ b/packages/sdk-typescript/src/query/Query.ts @@ -42,7 +42,7 @@ import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js'; import { SdkControlServerTransport, type SdkControlServerTransportOptions, -} from '../mcp/SdkControlServerTransport.js'; +} from '../daemon-mcp/SdkControlServerTransport.js'; import { ControlRequestType } from '../types/protocol.js'; interface PendingControlRequest { diff --git a/packages/sdk-typescript/test/unit/DaemonClient.test.ts b/packages/sdk-typescript/test/unit/DaemonClient.test.ts index 173366eedda..c9256b211da 100644 --- a/packages/sdk-typescript/test/unit/DaemonClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonClient.test.ts @@ -20,6 +20,7 @@ import type { DaemonCapabilities, DaemonSessionContextStatus, DaemonSessionSupportedCommandsStatus, + DaemonSessionTasksStatus, DaemonWorkspaceEnvStatus, DaemonWorkspaceMcpStatus, DaemonWorkspacePreflightStatus, @@ -496,6 +497,12 @@ describe('DaemonClient', () => { ], availableSkills: ['review'], }; + const tasks: DaemonSessionTasksStatus = { + v: 1, + sessionId: 'with/slash', + now: 1_700_000_000_000, + tasks: [], + }; const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/with%2Fslash/context')) { return jsonResponse(200, context); @@ -503,6 +510,9 @@ describe('DaemonClient', () => { if (req.url.endsWith('/session/with%2Fslash/supported-commands')) { return jsonResponse(200, supportedCommands); } + if (req.url.endsWith('/session/with%2Fslash/tasks')) { + return jsonResponse(200, tasks); + } return jsonResponse(500, { error: `unexpected ${req.url}` }); }); const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); @@ -513,13 +523,18 @@ describe('DaemonClient', () => { await expect( client.sessionSupportedCommands('with/slash', 'client-1'), ).resolves.toEqual(supportedCommands); + await expect( + client.sessionTasks('with/slash', 'client-1'), + ).resolves.toEqual(tasks); expect(calls.map((c) => [c.method, c.url])).toEqual([ ['GET', 'http://daemon/session/with%2Fslash/context'], ['GET', 'http://daemon/session/with%2Fslash/supported-commands'], + ['GET', 'http://daemon/session/with%2Fslash/tasks'], ]); expect(calls.map((c) => c.headers['x-qwen-client-id'])).toEqual([ 'client-1', 'client-1', + 'client-1', ]); }); }); diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 86059cb0d1f..947ff9d6c5f 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -416,6 +416,14 @@ describe('DaemonSessionClient', () => { availableSkills: ['review'], }); } + if (req.url.endsWith('/session/s-1/tasks')) { + return jsonResponse(200, { + v: 1, + sessionId: 's-1', + now: 1_700_000_000_000, + tasks: [], + }); + } if (req.url.endsWith('/session/s-1/cancel')) { return new Response(null, { status: 204 }); } @@ -475,6 +483,12 @@ describe('DaemonSessionClient', () => { ], availableSkills: ['review'], }); + await expect(session.tasks()).resolves.toEqual({ + v: 1, + sessionId: 's-1', + now: 1_700_000_000_000, + tasks: [], + }); await expect(session.cancel()).resolves.toBeUndefined(); await expect( session.respondToPermission('req-1', { @@ -496,6 +510,7 @@ describe('DaemonSessionClient', () => { 'http://daemon/session/s-1/model', 'http://daemon/session/s-1/context', 'http://daemon/session/s-1/supported-commands', + 'http://daemon/session/s-1/tasks', 'http://daemon/session/s-1/cancel', 'http://daemon/permission/req-1', 'http://daemon/session/s-1/permission/req-2', @@ -513,6 +528,7 @@ describe('DaemonSessionClient', () => { 'client-1', 'client-1', 'client-1', + 'client-1', ]); }); diff --git a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts index 8f39ad08f59..3f0101a5bf2 100644 --- a/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts +++ b/packages/sdk-typescript/test/unit/createSdkMcpServer.test.ts @@ -12,9 +12,9 @@ import { describe, expect, it, vi } from 'vitest'; import { z } from 'zod'; -import { createSdkMcpServer } from '../../src/mcp/createSdkMcpServer.js'; -import { tool } from '../../src/mcp/tool.js'; -import type { SdkMcpToolDefinition } from '../../src/mcp/tool.js'; +import { createSdkMcpServer } from '../../src/daemon-mcp/createSdkMcpServer.js'; +import { tool } from '../../src/daemon-mcp/tool.js'; +import type { SdkMcpToolDefinition } from '../../src/daemon-mcp/tool.js'; describe('createSdkMcpServer', () => { describe('Server Creation', () => { diff --git a/packages/sdk-typescript/test/unit/daemonEvents.test.ts b/packages/sdk-typescript/test/unit/daemonEvents.test.ts index 5176089b281..a158b4dff79 100644 --- a/packages/sdk-typescript/test/unit/daemonEvents.test.ts +++ b/packages/sdk-typescript/test/unit/daemonEvents.test.ts @@ -2486,4 +2486,119 @@ describe('PR 21 — auth device-flow events', () => { expect(state.awaitingResync).toBe(false); }); }); + + describe('followup_suggestion (daemon assist push)', () => { + it('recognizes followup_suggestion frames as known events', () => { + const event = { + id: 3, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Run the build?', + promptId: 's-1########3', + }, + } satisfies DaemonEvent; + const known = asKnownDaemonEvent(event); + expect(known?.type).toBe('followup_suggestion'); + if (known?.type === 'followup_suggestion') { + expect(known.data.sessionId).toBe('s-1'); + expect(known.data.suggestion).toBe('Run the build?'); + expect(known.data.promptId).toBe('s-1########3'); + } + }); + + it('rejects malformed followup_suggestion payloads', () => { + // Missing fields → predicate rejects → asKnownDaemonEvent + // returns undefined → reducer counts via unrecognizedKnownEventCount. + expect( + asKnownDaemonEvent({ + v: 1, + type: 'followup_suggestion', + data: { suggestion: 'x', promptId: 'p' }, + }), + ).toBeUndefined(); + expect( + asKnownDaemonEvent({ + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's', promptId: 'p' }, + }), + ).toBeUndefined(); + expect( + asKnownDaemonEvent({ + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's', suggestion: 'x' }, + }), + ).toBeUndefined(); + // Empty suggestion is protocol garbage — the daemon filters + // rejected suggestions server-side and only emits when accepted. + expect( + asKnownDaemonEvent({ + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's', suggestion: '', promptId: 'p' }, + }), + ).toBeUndefined(); + // Wrong types. + expect( + asKnownDaemonEvent({ + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's', suggestion: 42, promptId: 'p' }, + }), + ).toBeUndefined(); + }); + + it('reducer stores lastFollowupSuggestion and overwrites on a fresh event', () => { + const state = reduceDaemonSessionEvents([ + { + id: 1, + v: 1, + type: 'session_update', + data: { sessionId: 's-1', phase: 'prompting' }, + }, + { + id: 2, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'First', + promptId: 's-1########1', + }, + }, + { + id: 3, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Second', + promptId: 's-1########2', + }, + }, + ]); + expect(state.lastFollowupSuggestion).toEqual({ + sessionId: 's-1', + suggestion: 'Second', + promptId: 's-1########2', + }); + // Non-terminal — does not touch alive / pendingPermissions. + expect(state.alive).toBe(true); + expect(state.terminalEvent).toBeUndefined(); + expect(state.lastEventId).toBe(3); + }); + + it('malformed payload routes to unrecognizedKnownEventCount', () => { + const state = reduceDaemonSessionEvent(createDaemonSessionViewState(), { + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's-1', suggestion: 'incomplete' }, // missing promptId + }); + expect(state.unrecognizedKnownEventCount).toBe(1); + expect(state.lastFollowupSuggestion).toBeUndefined(); + }); + }); }); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 0ce51e1f8a8..af36f2fa28f 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -5043,6 +5043,21 @@ describe('cross-client event recognition (prompt_cancelled / replay_complete)', originatorClientId: 'client-X', }), ]); + // No reason for a plain user cancel. + expect(events[0]).not.toHaveProperty('reason'); + }); + + it('forwards the prompt_cancelled reason (C3 forward_failed)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'prompt_cancelled', + data: { sessionId: 's1', reason: 'forward_failed' }, + } as never); + expect(events[0]).toMatchObject({ + type: 'prompt.cancelled', + reason: 'forward_failed', + }); }); it('normalizes replay_complete to session.replay_complete with count', () => { @@ -5059,6 +5074,20 @@ describe('cross-client event recognition (prompt_cancelled / replay_complete)', }); }); + it('prefers canonical lastReplayedEventId over the deprecated lastEventId alias (D4)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'replay_complete', + // Both present, different values — canonical must win. + data: { replayedCount: 2, lastReplayedEventId: 9, lastEventId: 7 }, + } as never); + expect(events[0]).toMatchObject({ + type: 'session.replay_complete', + lastReplayedEventId: 9, + }); + }); + it('replay_complete with zero replay (empty ring) normalizes cleanly', () => { const events = normalizeDaemonEvent({ id: 1, @@ -5220,3 +5249,184 @@ describe('permission_resolved voterClientId (A4)', () => { ); }); }); + +describe('daemon assist push: followup_suggestion', () => { + it('normalizes followup_suggestion to followup.suggestion with payload', () => { + const events = normalizeDaemonEvent({ + id: 7, + v: 1, + type: 'followup_suggestion', + originatorClientId: 'client-A', + data: { + sessionId: 's-1', + suggestion: 'Run the build?', + promptId: 's-1########3', + }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'followup.suggestion', + sessionId: 's-1', + suggestion: 'Run the build?', + promptId: 's-1########3', + originatorClientId: 'client-A', + eventId: 7, + }), + ]); + }); + + it('routes malformed followup_suggestion to debug fallback', () => { + // Missing `promptId` — the normalizer rejects via fallbackDebug + // rather than synthesizing a typed event with partial data. + const events = normalizeDaemonEvent({ + id: 8, + v: 1, + type: 'followup_suggestion', + data: { sessionId: 's-1', suggestion: 'Hi' }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'debug', + text: expect.stringContaining('malformed followup_suggestion'), + }), + ]); + }); + + it('transcript reducer stores lastFollowupSuggestion without appending a block', () => { + let state = createDaemonTranscriptState({ now: 1 }); + const before = state.blocks.length; + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'What did you find?', + promptId: 's-1########2', + }, + } as never), + { now: 2 }, + ); + // Sidechannel only — no chat-stream block. + expect(state.blocks.length).toBe(before); + expect(state.lastFollowupSuggestion).toEqual({ + suggestion: 'What did you find?', + promptId: 's-1########2', + }); + expect(state.lastEventId).toBe(1); + }); + + it('latest followup_suggestion replaces the prior one for the session', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'First suggestion', + promptId: 's-1########1', + }, + } as never), + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Second suggestion', + promptId: 's-1########2', + }, + } as never), + { now: 3 }, + ); + expect(state.lastFollowupSuggestion).toEqual({ + suggestion: 'Second suggestion', + promptId: 's-1########2', + }); + }); + + it('clears lastFollowupSuggestion when a new user prompt starts', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Try this', + promptId: 's-1########1', + }, + } as never), + { now: 2 }, + ); + expect(state.lastFollowupSuggestion).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'next question' }, + }, + }, + } as never), + { now: 3 }, + ); + expect(state.lastFollowupSuggestion).toBeUndefined(); + }); + + it('store.clearFollowupSuggestion drops the sidechannel suggestion', () => { + const store = createDaemonTranscriptStore(); + store.dispatch( + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'followup_suggestion', + data: { + sessionId: 's-1', + suggestion: 'Care to elaborate?', + promptId: 's-1########4', + }, + } as never), + ); + // queueMicrotask flush + return Promise.resolve().then(() => { + expect(store.getSnapshot().lastFollowupSuggestion).toEqual({ + suggestion: 'Care to elaborate?', + promptId: 's-1########4', + }); + store.clearFollowupSuggestion(); + return Promise.resolve().then(() => { + expect(store.getSnapshot().lastFollowupSuggestion).toBeUndefined(); + // Idempotent: calling again is a no-op (no throw). + store.clearFollowupSuggestion(); + }); + }); + }); + + it('terminal renderer surfaces followup suggestion as a debug-style line', () => { + const text = daemonUiEventToTerminalText({ + type: 'followup.suggestion', + sessionId: 's-1', + suggestion: 'Try running the tests', + promptId: 's-1########5', + } as DaemonUiEvent); + expect(text).toContain('suggestion'); + expect(text).toContain('Try running the tests'); + }); +}); diff --git a/packages/sdk-typescript/test/unit/serve-bridge.test.ts b/packages/sdk-typescript/test/unit/serve-bridge.test.ts new file mode 100644 index 00000000000..cd2991f02fc --- /dev/null +++ b/packages/sdk-typescript/test/unit/serve-bridge.test.ts @@ -0,0 +1,684 @@ +/** + * @license + * Copyright 2026 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Unit tests for the qwen-serve-bridge MCP server. + * + * Tests cover: server creation, tool registration, handler routing, + * session state management, and error handling. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createServeBridgeMcpServer } from '../../src/daemon-mcp/serve-bridge/createServeBridgeMcpServer.js'; +import { resolveSessionId, handler } from '../../src/daemon-mcp/serve-bridge/helpers.js'; +import type { + BridgeState, + SessionEventStream, +} from '../../src/daemon-mcp/serve-bridge/types.js'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; + +// --- Helpers --- + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + body: string | null; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchImpl = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? 'GET'; + const headers: Record = {}; + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } + const body = typeof init?.body === 'string' ? init.body : null; + const captured: CapturedRequest = { url, method, headers, body }; + calls.push(captured); + return reply(captured); + }, + ) as unknown as typeof globalThis.fetch; + return { fetch: fetchImpl, calls }; +} + +function makeMockState(opts?: { + token?: string; + defaultSessionId?: string; + fetchReply?: (req: CapturedRequest) => Response | Promise; +}): { state: BridgeState; calls: CapturedRequest[] } { + const token = opts?.token ?? 'test-token'; + const reply = opts?.fetchReply ?? (() => jsonResponse(200, { status: 'ok' })); + const { fetch, calls } = recordingFetch(reply); + + const state: BridgeState = { + client: new DaemonClient({ + baseUrl: 'http://127.0.0.1:4170', + token, + fetch, + }), + daemonUrl: 'http://127.0.0.1:4170', + token, + defaultSessionId: opts?.defaultSessionId, + workspaceCwd: '/tmp/test-workspace', + eventStreams: new Map(), + allowGlobalScope: false, + }; + + return { state, calls }; +} + +// --- Tests --- + +describe('serve-bridge', () => { + describe('createServeBridgeMcpServer', () => { + it('should create a server with name qwen-serve-bridge', () => { + recordingFetch(() => jsonResponse(200, {})); + const server = createServeBridgeMcpServer({ + daemonUrl: 'http://127.0.0.1:4170', + token: 'test', + }); + + expect(server).toBeDefined(); + expect(server.name).toBe('qwen-serve-bridge'); + expect(server.instance).toBeDefined(); + }); + + it('should strip trailing slashes from daemonUrl', () => { + const server = createServeBridgeMcpServer({ + daemonUrl: 'http://127.0.0.1:4170///', + token: 'test', + }); + expect(server).toBeDefined(); + }); + }); + + describe('resolveSessionId', () => { + it('should return explicit session_id when provided', () => { + const { state } = makeMockState({ defaultSessionId: 'default-123' }); + expect(resolveSessionId(state, 'explicit-456')).toBe('explicit-456'); + }); + + it('should return defaultSessionId when no explicit id', () => { + const { state } = makeMockState({ defaultSessionId: 'default-123' }); + expect(resolveSessionId(state)).toBe('default-123'); + }); + + it('should throw when no session available', () => { + const { state } = makeMockState({ defaultSessionId: undefined }); + expect(() => resolveSessionId(state)).toThrow( + 'No session active. Call session_create first', + ); + }); + }); + + describe('handler', () => { + it('should pass args through and return result', async () => { + const fn = vi + .fn() + .mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }); + const wrapped = handler(fn); + const result = await wrapped({ foo: 'bar' }, {}); + expect(fn).toHaveBeenCalledWith({ foo: 'bar' }); + expect(result).toEqual({ content: [{ type: 'text', text: 'ok' }] }); + }); + + it('should catch errors and return isError response', async () => { + const fn = vi.fn().mockRejectedValue(new Error('something broke')); + const wrapped = handler(fn); + const result = await wrapped({}, {}); + expect(result).toEqual({ + content: [{ type: 'text', text: 'something broke' }], + isError: true, + }); + }); + + it('should handle non-Error throws', async () => { + const fn = vi.fn().mockRejectedValue('string error'); + const wrapped = handler(fn); + const result = await wrapped({}, {}); + expect(result).toEqual({ + content: [{ type: 'text', text: 'string error' }], + isError: true, + }); + }); + }); + + describe('tool handlers', () => { + describe('health', () => { + it('should call GET /health and return result', async () => { + const { state } = makeMockState({ + fetchReply: () => jsonResponse(200, { status: 'ok' }), + }); + + // Import tools dynamically to test with mock state + const { infrastructureTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/infrastructure.js' + ); + const tools = infrastructureTools(state); + const healthTool = tools.find( + (t: { name: string }) => t.name === 'health', + ); + + expect(healthTool).toBeDefined(); + expect(healthTool.name).toBe('health'); + expect(healthTool.description).toContain('daemon'); + }); + }); + + describe('session_create', () => { + it('should set defaultSessionId after successful creation', async () => { + const { state } = makeMockState({ + fetchReply: (req) => { + if (req.url.endsWith('/session') && req.method === 'POST') { + return jsonResponse(200, { + sessionId: 'new-session-id', + workspaceCwd: '/tmp', + attached: false, + }); + } + return jsonResponse(404, {}); + }, + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const createTool = tools.find( + (t: { name: string }) => t.name === 'session_create', + ); + expect(createTool).toBeDefined(); + + // Call the handler + const result = await createTool.handler({ workspace_cwd: '/tmp' }, {}); + expect(result.content[0].text).toContain('new-session-id'); + expect(state.defaultSessionId).toBe('new-session-id'); + }); + }); + + describe('session_close', () => { + it('should clear defaultSessionId when closing the default session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'sess-to-close', + fetchReply: () => new Response(null, { status: 204 }), + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const closeTool = tools.find( + (t: { name: string }) => t.name === 'session_close', + ); + + await closeTool.handler({ session_id: 'sess-to-close' }, {}); + expect(state.defaultSessionId).toBeUndefined(); + }); + + it('should not clear defaultSessionId when closing a different session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'keep-this', + fetchReply: () => new Response(null, { status: 204 }), + }); + + const { sessionTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/session.js' + ); + const tools = sessionTools(state); + const closeTool = tools.find( + (t: { name: string }) => t.name === 'session_close', + ); + + await closeTool.handler({ session_id: 'other-session' }, {}); + expect(state.defaultSessionId).toBe('keep-this'); + }); + }); + + describe('workspace read tools', () => { + it('should register all 10 read tools', async () => { + const { state } = makeMockState(); + const { workspaceReadTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceRead.js' + ); + const tools = workspaceReadTools(state); + expect(tools).toHaveLength(10); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('file_read'); + expect(names).toContain('file_read_bytes'); + expect(names).toContain('file_stat'); + expect(names).toContain('dir_list'); + expect(names).toContain('glob'); + expect(names).toContain('workspace_mcp_status'); + expect(names).toContain('workspace_skills'); + expect(names).toContain('workspace_providers'); + expect(names).toContain('workspace_env'); + expect(names).toContain('workspace_preflight'); + }); + }); + + describe('workspace write tools', () => { + it('should register all 9 write tools', async () => { + const { state } = makeMockState(); + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + expect(tools).toHaveLength(9); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('file_write'); + expect(names).toContain('file_edit'); + expect(names).toContain('workspace_init'); + expect(names).toContain('workspace_memory_read'); + expect(names).toContain('workspace_memory_write'); + expect(names).toContain('workspace_agents_manage'); + }); + }); + + describe('agent tools', () => { + it('should register all 2 agent tools', async () => { + const { state } = makeMockState(); + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + expect(tools).toHaveLength(2); + + const names = tools.map((t: { name: string }) => t.name); + expect(names).toContain('prompt'); + expect(names).toContain('prompt_cancel'); + }); + }); + + describe('allTools', () => { + it('should aggregate to exactly 31 tools', async () => { + const { state } = makeMockState(); + const { allTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/index.js' + ); + const tools = allTools(state); + expect(tools).toHaveLength(31); + + // Verify no duplicate names + const names = tools.map((t: { name: string }) => t.name); + const uniqueNames = new Set(names); + expect(uniqueNames.size).toBe(31); + }); + }); + }); + + describe('prompt tool with persistent SSE', () => { + it('should collect response text via the persistent event stream', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: (req) => { + if (req.url.includes('/prompt')) { + // Simulate: prompt returns stopReason, but before that the + // persistent SSE stream will have populated the collector. + // We simulate this by filling the collector just before the + // prompt response resolves. + const stream = state.eventStreams.get('test-session')!; + const collector = stream.activeCollector!; + collector.texts.push('hello'); + collector.texts.push(' world'); + collector.resolve(); + return jsonResponse(200, { stopReason: 'end_turn' }); + } + return jsonResponse(404, {}); + }, + }); + + // Set up a fake persistent event stream (normally created by session_create) + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: null, + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.stop_reason).toBe('end_turn'); + expect(parsed.session_id).toBe('test-session'); + expect(parsed.response).toBe('hello world'); + // Collector should be cleared after prompt completes + expect(fakeStream.activeCollector).toBeNull(); + }); + + it('should throw if no SSE stream exists for the session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'no-stream-session', + }); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('No SSE stream'); + }); + + it('should reject concurrent prompts on the same session', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, { stopReason: 'end' }), + }); + + const { createPromptCollector } = await import( + '../../src/daemon-mcp/serve-bridge/sse.js' + ); + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: createPromptCollector(), // already has an active collector + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const promptTool = tools.find( + (t: { name: string }) => t.name === 'prompt', + ); + + const result = await promptTool.handler({ prompt: 'test' }, {}); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('already in progress'); + }); + + it('prompt_cancel should resolve active collector', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, {}), + }); + + const { createPromptCollector } = await import( + '../../src/daemon-mcp/serve-bridge/sse.js' + ); + const collector = createPromptCollector(); + const fakeStream: SessionEventStream = { + sessionId: 'test-session', + abortCtrl: new AbortController(), + activeCollector: collector, + lastActivityMs: Date.now(), + }; + state.eventStreams.set('test-session', fakeStream); + + const { agentTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/agent.js' + ); + const tools = agentTools(state); + const cancelTool = tools.find( + (t: { name: string }) => t.name === 'prompt_cancel', + ); + + await cancelTool.handler({}, {}); + expect(collector.resolved).toBe(true); + expect(collector.interrupted).toBe(true); + }); + }); + + describe('safety guards', () => { + it('should reject global scope in workspace_memory_write', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const memWriteTool = tools.find( + (t: { name: string }) => t.name === 'workspace_memory_write', + ); + + const result = await memWriteTool.handler( + { scope: 'global', content: 'test', mode: 'append' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Global scope is disabled'); + }); + + it('should reject global scope in workspace_agents_manage', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + const result = await agentsTool.handler( + { action: 'create', scope: 'global', name: 'x', description: 'x', system_prompt: 'x' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Global scope is disabled'); + }); + + it('should reject yolo approval mode without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'yolo', session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should reject auto-edit approval mode without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'auto-edit', session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should reject persistent approval mode change without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const approvalTool = tools.find( + (t: { name: string }) => t.name === 'session_set_approval_mode', + ); + + const result = await approvalTool.handler( + { mode: 'default', persist: true, session_id: 'test-session' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should allow read-only agents_manage actions with global scope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, []), + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + // list with scope=global should NOT be blocked (read-only) + const result = await agentsTool.handler( + { action: 'list', scope: 'global' }, + {}, + ); + expect(result.isError).toBeUndefined(); + }); + + it('should reject file_write replace mode without expected_hash', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const writeFileTool = tools.find( + (t: { name: string }) => t.name === 'file_write', + ); + + const result = await writeFileTool.handler( + { path: 'test.txt', content: 'hello', mode: 'replace' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('expected_hash is required'); + }); + + it('should reject workspace_tool_toggle without allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = false; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const toggleTool = tools.find( + (t: { name: string }) => t.name === 'workspace_tool_toggle', + ); + + const result = await toggleTool.handler( + { tool_name: 'file_read', enabled: false }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('restricted for security'); + }); + + it('should allow workspace_tool_toggle with allowGlobalScope', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + fetchReply: () => jsonResponse(200, { ok: true }), + }); + state.allowGlobalScope = true; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const toggleTool = tools.find( + (t: { name: string }) => t.name === 'workspace_tool_toggle', + ); + + const result = await toggleTool.handler( + { tool_name: 'file_read', enabled: false }, + {}, + ); + expect(result.isError).toBeUndefined(); + }); + + it('should reject agents_manage update with no fields', async () => { + const { state } = makeMockState({ + defaultSessionId: 'test-session', + }); + state.allowGlobalScope = true; + + const { workspaceWriteTools } = await import( + '../../src/daemon-mcp/serve-bridge/tools/workspaceWrite.js' + ); + const tools = workspaceWriteTools(state); + const agentsTool = tools.find( + (t: { name: string }) => t.name === 'workspace_agents_manage', + ); + + const result = await agentsTool.handler( + { action: 'update', agent_type: 'test-agent' }, + {}, + ); + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain( + 'At least one field to update must be provided', + ); + }); + }); +}); diff --git a/packages/vscode-ide-companion/NOTICES.txt b/packages/vscode-ide-companion/NOTICES.txt index 0006bd5427d..9608e55ff54 100644 --- a/packages/vscode-ide-companion/NOTICES.txt +++ b/packages/vscode-ide-companion/NOTICES.txt @@ -198,11 +198,237 @@ This file contains third-party software notices and license terms. ============================================================ -@qwen-code/webui@undefined -(No repository found) +@modelcontextprotocol/sdk@1.25.1 +(git+https://github.com/modelcontextprotocol/typescript-sdk.git) + +MIT License + +Copyright (c) 2024 Anthropic, PBC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +@hono/node-server@1.19.7 +(https://github.com/honojs/node-server.git) License text not found. +============================================================ +ajv@8.17.1 +(No repository found) + +The MIT License (MIT) + +Copyright (c) 2015-2021 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + + +============================================================ +fast-deep-equal@3.1.3 +(git+https://github.com/epoberezkin/fast-deep-equal.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +fast-uri@3.0.6 +(git+https://github.com/fastify/fast-uri.git) + +Copyright (c) 2021 The Fastify Team +Copyright (c) 2011-2021, Gary Court until https://github.com/garycourt/uri-js/commit/a1acf730b4bba3f1097c9f52e7d9d3aba8cdcaae +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * The names of any contributors may not be used to endorse or promote + products derived from this software without specific prior written + permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + * * * + +The complete list of contributors can be found at: +- https://github.com/garycourt/uri-js/graphs/contributors + +============================================================ +json-schema-traverse@1.0.0 +(git+https://github.com/epoberezkin/json-schema-traverse.git) + +MIT License + +Copyright (c) 2017 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +require-from-string@2.0.2 +(No repository found) + +The MIT License (MIT) + +Copyright (c) Vsevolod Strukchinsky (github.com/floatdrop) + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +ajv-formats@3.0.1 +(git+https://github.com/ajv-validator/ajv-formats.git) + +MIT License + +Copyright (c) 2020 Evgeny Poberezkin + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +content-type@1.0.5 +(No repository found) + +(The MIT License) + +Copyright (c) 2015 Douglas Christopher Wilson + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + ============================================================ cors@2.8.5 (No repository found) @@ -287,36 +513,176 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -dotenv@17.1.0 -(git://github.com/motdotla/dotenv.git) +cross-spawn@7.0.6 +(git@github.com:moxystudio/node-cross-spawn.git) + +The MIT License (MIT) + +Copyright (c) 2018 Made With MOXY Lda + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + +============================================================ +path-key@3.1.1 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-command@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Kevin Mårtensson (github.com/kevva) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +shebang-regex@3.0.0 +(No repository found) + +MIT License + +Copyright (c) Sindre Sorhus (sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +which@2.0.2 +(git://github.com/isaacs/node-which.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +isexe@2.0.0 +(git+https://github.com/isaacs/isexe.git) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +eventsource@3.0.7 +(git://git@github.com/EventSource/eventsource.git) + +The MIT License + +Copyright (c) EventSource GitHub organisation + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +eventsource-parser@3.0.3 +(git+ssh://git@github.com/rexxars/eventsource-parser.git) -Copyright (c) 2015, Scott Motte -All rights reserved. +MIT License -Redistribution and use in source and binary forms, with or without -modification, are permitted provided that the following conditions are met: +Copyright (c) 2025 Espen Hovlandsdal -* Redistributions of source code must retain the above copyright notice, this - list of conditions and the following disclaimer. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -* Redistributions in binary form must reproduce the above copyright notice, - this list of conditions and the following disclaimer in the documentation - and/or other materials provided with the distribution. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" -AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE -IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE -DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE -FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL -DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR -SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER -CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, -OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. ============================================================ -express@4.21.2 +express@5.2.1 (No repository found) (The MIT License) @@ -346,7 +712,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -accepts@1.3.8 +accepts@2.0.0 (No repository found) (The MIT License) @@ -375,7 +741,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -mime-types@3.0.1 +mime-types@3.0.2 (No repository found) (The MIT License) @@ -433,7 +799,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -negotiator@0.6.3 +negotiator@1.0.0 (No repository found) (The MIT License) @@ -463,34 +829,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -array-flatten@1.1.1 -(git://github.com/blakeembrey/array-flatten.git) - -The MIT License (MIT) - -Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - -============================================================ -body-parser@1.20.3 +body-parser@2.2.2 (No repository found) (The MIT License) @@ -547,34 +886,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -content-type@1.0.5 -(No repository found) - -(The MIT License) - -Copyright (c) 2015 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - ============================================================ debug@4.4.3 (git://github.com/debug-js/debug.git) @@ -629,42 +940,14 @@ SOFTWARE. ============================================================ -depd@2.0.0 -(No repository found) - -(The MIT License) - -Copyright (c) 2014-2018 Douglas Christopher Wilson - -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: - -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -============================================================ -destroy@1.2.0 +http-errors@2.0.1 (No repository found) The MIT License (MIT) Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2015-2022 Douglas Christopher Wilson doug@somethingdoug.com +Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -686,32 +969,31 @@ THE SOFTWARE. ============================================================ -http-errors@2.0.0 +depd@2.0.0 (No repository found) +(The MIT License) -The MIT License (MIT) - -Copyright (c) 2014 Jonathan Ong me@jongleberry.com -Copyright (c) 2016 Douglas Christopher Wilson doug@somethingdoug.com +Copyright (c) 2014-2018 Douglas Christopher Wilson -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ @@ -812,8 +1094,8 @@ SOFTWARE. ============================================================ -iconv-lite@0.6.3 -(git://github.com/ashtuchkin/iconv-lite.git) +iconv-lite@0.7.2 +(https://github.com/pillarjs/iconv-lite.git) Copyright (c) 2011 Alexander Shtuchkin @@ -923,7 +1205,7 @@ THE SOFTWARE. ============================================================ -qs@6.13.0 +qs@6.15.2 (https://github.com/ljharb/qs.git) BSD 3-Clause License @@ -1443,7 +1725,7 @@ SOFTWARE. ============================================================ -raw-body@3.0.0 +raw-body@3.0.2 (No repository found) The MIT License (MIT) @@ -1499,7 +1781,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -type-is@1.6.18 +type-is@2.1.0 (No repository found) (The MIT License) @@ -1528,12 +1810,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -media-typer@0.3.0 +media-typer@1.1.0 (No repository found) (The MIT License) -Copyright (c) 2014 Douglas Christopher Wilson +Copyright (c) 2014-2017 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1556,7 +1838,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -content-disposition@0.5.4 +content-disposition@1.1.0 (No repository found) (The MIT License) @@ -1583,33 +1865,6 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -============================================================ -safe-buffer@5.2.1 -(git://github.com/feross/safe-buffer.git) - -The MIT License (MIT) - -Copyright (c) Feross Aboukhadijeh - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in -all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN -THE SOFTWARE. - - ============================================================ cookie@0.7.2 (No repository found) @@ -1641,10 +1896,32 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -cookie-signature@1.0.6 +cookie-signature@1.2.2 (https://github.com/visionmedia/node-cookie-signature.git) -License text not found. +(The MIT License) + +Copyright (c) 2012–2024 LearnBoost and other contributors; + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +'Software'), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, +TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + ============================================================ encodeurl@2.0.0 @@ -1733,7 +2010,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -finalhandler@1.3.1 +finalhandler@2.1.1 (No repository found) (The MIT License) @@ -1791,7 +2068,7 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -fresh@0.5.2 +fresh@2.0.0 (No repository found) (The MIT License) @@ -1820,13 +2097,71 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -merge-descriptors@1.0.3 +merge-descriptors@2.0.0 +(No repository found) + +MIT License + +Copyright (c) Jonathan Ong +Copyright (c) Douglas Christopher Wilson +Copyright (c) Sindre Sorhus (https://sindresorhus.com) + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +============================================================ +once@1.4.0 +(git://github.com/isaacs/once) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +wrappy@1.0.2 +(https://github.com/npm/wrappy) + +The ISC License + +Copyright (c) Isaac Z. Schlueter and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR +IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + + +============================================================ +proxy-addr@2.0.7 (No repository found) (The MIT License) -Copyright (c) 2013 Jonathan Ong -Copyright (c) 2015 Douglas Christopher Wilson +Copyright (c) 2014-2016 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1849,13 +2184,12 @@ SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ============================================================ -methods@1.1.2 +forwarded@0.2.0 (No repository found) (The MIT License) -Copyright (c) 2013-2014 TJ Holowaychuk -Copyright (c) 2015-2016 Douglas Christopher Wilson +Copyright (c) 2014-2017 Douglas Christopher Wilson Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the @@ -1877,14 +2211,11 @@ TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - ============================================================ -path-to-regexp@0.1.12 -(https://github.com/pillarjs/path-to-regexp.git) - -The MIT License (MIT) +ipaddr.js@1.9.1 +(No repository found) -Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) +Copyright (C) 2011-2017 whitequark Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1906,12 +2237,13 @@ THE SOFTWARE. ============================================================ -proxy-addr@2.0.7 +range-parser@1.2.1 (No repository found) (The MIT License) -Copyright (c) 2014-2016 Douglas Christopher Wilson +Copyright (c) 2012-2014 TJ Holowaychuk +Copyright (c) 2015-2016 Douglas Christopher Wilson +Copyright (c) 2014 Forbes Lindesay + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + +============================================================ +path-to-regexp@8.2.0 +(https://github.com/pillarjs/path-to-regexp.git) + +The MIT License (MIT) + +Copyright (c) 2014 Blake Embrey (hello@blakeembrey.com) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal @@ -1987,13 +2346,13 @@ THE SOFTWARE. ============================================================ -range-parser@1.2.1 +send@1.2.1 (No repository found) (The MIT License) -Copyright (c) 2012-2014 TJ Holowaychuk -Copyright (c) 2015-2016 Douglas Christopher Wilson -Permission is hereby granted, free of charge, to any person obtaining -a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, -distribute, sublicense, and/or sell copies of the Software, and to -permit persons to whom the Software is furnished to do so, subject to -the following conditions: +All JSON Schema documentation and descriptions are copyright (c): -The above copyright notice and this permission notice shall be -included in all copies or substantial portions of the Software. +2009 [draft-0] IETF Trust , Kris Zyp , +and SitePen (USA) . -THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY -CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, -TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE -SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +2009 [draft-1] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-2] IETF Trust , Kris Zyp , +and SitePen (USA) . + +2010 [draft-3] IETF Trust , Kris Zyp , +Gary Court , and SitePen (USA) . + +2013 [draft-4] IETF Trust ), Francis Galiegue +, Kris Zyp , Gary Court +, and SitePen (USA) . + +2018 [draft-7] IETF Trust , Austin Wright , +Henry Andrews , Geraint Luff , and +Cloudflare, Inc. . + +2019 [draft-2019-09] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +2020 [draft-2020-12] IETF Trust , Austin Wright +, Henry Andrews , Ben Hutton +, and Greg Dennis . + +All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. ============================================================ -utils-merge@1.0.1 -(git://github.com/jaredhanson/utils-merge.git) +pkce-challenge@5.0.0 +(git+https://github.com/crouchcd/pkce-challenge.git) -The MIT License (MIT) +MIT License -Copyright (c) 2013-2017 Jared Hanson +Copyright (c) 2019 -Permission is hereby granted, free of charge, to any person obtaining a copy of -this software and associated documentation files (the "Software"), to deal in -the Software without restriction, including without limitation the rights to -use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of -the Software, and to permit persons to whom the Software is furnished to do so, -subject to the following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS -FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR -COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER -IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN -CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +zod@3.25.76 +(git+https://github.com/colinhacks/zod.git) + +MIT License + +Copyright (c) 2025 Colin McDonnell + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + +============================================================ +zod-to-json-schema@3.25.0 +(https://github.com/StefanTerdell/zod-to-json-schema) + +ISC License + +Copyright (c) 2020, Stefan Terdell + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ============================================================ markdown-it@14.1.0 @@ -2564,6 +3031,35 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +============================================================ +dotenv@17.1.0 +(git://github.com/motdotla/dotenv.git) + +Copyright (c) 2015, Scott Motte +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +* Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +* Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + + ============================================================ react@19.2.4 (https://github.com/facebook/react.git) @@ -2666,30 +3162,3 @@ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. -============================================================ -zod@3.25.76 -(git+https://github.com/colinhacks/zod.git) - -MIT License - -Copyright (c) 2025 Colin McDonnell - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - - diff --git a/packages/vscode-ide-companion/scripts/generate-notices.js b/packages/vscode-ide-companion/scripts/generate-notices.js index 06f735ca81b..09b43f578a2 100644 --- a/packages/vscode-ide-companion/scripts/generate-notices.js +++ b/packages/vscode-ide-companion/scripts/generate-notices.js @@ -14,27 +14,26 @@ const projectRoot = path.resolve( const packagePath = path.join(projectRoot, 'packages', 'vscode-ide-companion'); const noticeFilePath = path.join(packagePath, 'NOTICES.txt'); -async function getDependencyLicense(depName, depVersion) { - let depPackageJsonPath; +/** + * Read license information for a dependency from its on-disk location. + * + * @param {string} depName - Package name + * @param {string} depVersion - Resolved version string + * @param {string} resolvedKey - Lockfile key indicating where the package is installed + * @returns {Promise<{name: string, version: string, repository: string, license: string}>} + */ +async function getDependencyLicense(depName, depVersion, resolvedKey) { let licenseContent = 'License text not found.'; let repositoryUrl = 'No repository found'; - try { - depPackageJsonPath = path.join( - projectRoot, - 'node_modules', - depName, - 'package.json', - ); - if (!(await fs.stat(depPackageJsonPath).catch(() => false))) { - depPackageJsonPath = path.join( - packagePath, - 'node_modules', - depName, - 'package.json', - ); - } + // Derive the on-disk path directly from the lockfile key + const depPackageJsonPath = path.join( + projectRoot, + resolvedKey, + 'package.json', + ); + try { const depPackageJsonContent = await fs.readFile( depPackageJsonPath, 'utf-8', @@ -76,7 +75,7 @@ async function getDependencyLicense(depName, depVersion) { } } catch (e) { console.warn( - `Warning: Could not find package.json for ${depName}: ${e.message}`, + `Warning: Could not find package.json for ${depName} at ${depPackageJsonPath}: ${e.message}`, ); } @@ -88,24 +87,90 @@ async function getDependencyLicense(depName, depVersion) { }; } -function collectDependencies(packageName, packageLock, dependenciesMap) { +/** + * Resolve a package in the lockfile by walking up the node_modules chain, + * mirroring Node.js module resolution algorithm. + * + * @param {string} packageName - Package to find + * @param {object} packages - packageLock.packages map + * @param {string} resolveFrom - Lockfile key to start resolution from + * @returns {{info: object, key: string} | null} + */ +function resolveInLockfile(packageName, packages, resolveFrom) { + // Walk up from resolveFrom, trying each node_modules level + let current = resolveFrom; + while (current) { + const candidate = `${current}/node_modules/${packageName}`; + if (packages[candidate]) { + return { info: packages[candidate], key: candidate }; + } + // Move up: strip the last /node_modules/... segment + const lastNm = current.lastIndexOf('/node_modules/'); + if (lastNm === -1) break; + current = current.slice(0, lastNm); + } + // Finally try root hoisted level + const hoistedKey = `node_modules/${packageName}`; + if (packages[hoistedKey]) { + return { info: packages[hoistedKey], key: hoistedKey }; + } + return null; +} + +/** + * Recursively collect third-party dependencies by walking the lockfile. + * Mirrors Node.js module resolution: walks up the node_modules chain from + * the current package's location. + * + * @param {string} packageName - Package to resolve + * @param {object} packageLock - Parsed package-lock.json + * @param {Map} dependenciesMap - Accumulated results + * @param {string} resolveFrom - Lockfile key prefix to resolve from (e.g. "packages/vscode-ide-companion") + */ +function collectDependencies( + packageName, + packageLock, + dependenciesMap, + resolveFrom, +) { if (dependenciesMap.has(packageName)) { return; } - const packageInfo = packageLock.packages[`node_modules/${packageName}`]; - if (!packageInfo) { + const resolved = resolveInLockfile( + packageName, + packageLock.packages, + resolveFrom, + ); + if (!resolved) { console.warn( `Warning: Could not find package info for ${packageName} in package-lock.json.`, ); return; } - dependenciesMap.set(packageName, packageInfo.version); + const { info: packageInfo, key: resolvedKey } = resolved; + + // Workspace-linked packages: follow resolved pointer to collect their third-party deps + if (packageInfo.link) { + const realInfo = packageLock.packages[packageInfo.resolved]; + if (realInfo?.dependencies) { + for (const depName of Object.keys(realInfo.dependencies)) { + collectDependencies(depName, packageLock, dependenciesMap, resolveFrom); + } + } + return; + } + + dependenciesMap.set(packageName, { + version: packageInfo.version, + resolvedKey, + }); if (packageInfo.dependencies) { for (const depName of Object.keys(packageInfo.dependencies)) { - collectDependencies(depName, packageLock, dependenciesMap); + // Resolve transitive deps from THIS package's location + collectDependencies(depName, packageLock, dependenciesMap, resolvedKey); } } } @@ -125,15 +190,22 @@ async function main() { const allDependencies = new Map(); const directDependencies = Object.keys(packageJson.dependencies); + const workspacePrefix = path.relative(projectRoot, packagePath); for (const depName of directDependencies) { - collectDependencies(depName, packageLockJson, allDependencies); + collectDependencies( + depName, + packageLockJson, + allDependencies, + workspacePrefix, + ); } const dependencyEntries = Array.from(allDependencies.entries()); - const licensePromises = dependencyEntries.map(([depName, depVersion]) => - getDependencyLicense(depName, depVersion), + const licensePromises = dependencyEntries.map( + ([depName, { version, resolvedKey }]) => + getDependencyLicense(depName, version, resolvedKey), ); const dependencyLicenses = await Promise.all(licensePromises); @@ -151,6 +223,7 @@ async function main() { await fs.writeFile(noticeFilePath, noticeText); console.log(`NOTICES.txt generated at ${noticeFilePath}`); + console.log(`Total dependencies: ${dependencyEntries.length}`); } catch (error) { console.error('Error generating NOTICES.txt:', error); process.exit(1); diff --git a/packages/vscode-ide-companion/src/utils/imageSupport.ts b/packages/vscode-ide-companion/src/utils/imageSupport.ts index f06d8532427..d217f16091b 100644 --- a/packages/vscode-ide-companion/src/utils/imageSupport.ts +++ b/packages/vscode-ide-companion/src/utils/imageSupport.ts @@ -28,7 +28,7 @@ export const MAX_TOTAL_IMAGE_SIZE = 20 * 1024 * 1024; // ---------- Path escaping ---------- -export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~]/; +export const SHELL_SPECIAL_CHARS = /[ \t()[\]{};|*?$`'"#&<>!~,]/; export function escapePath(filePath: string): string { let result = ''; diff --git a/packages/web-shell/README.md b/packages/web-shell/README.md index 15945417db9..deb7db08301 100644 --- a/packages/web-shell/README.md +++ b/packages/web-shell/README.md @@ -1,60 +1,119 @@ -# @alife/dataworks-qwen-code-web-shell +# @qwen-code/web-shell -Qwen Code Web Shell 是面向浏览器的 daemon 会话 UI,可以打包成 React -组件给其他项目集成。 +Qwen Code Web Shell 是面向浏览器的 daemon 会话终端 UI,可以作为 React +组件嵌入到其他项目中。 -## React 组件接入 - -### 环境要求 +## 环境要求 - React:`^18.0.0 || ^19.0.0` - React DOM:`^18.0.0 || ^19.0.0` +- `@qwen-code/webui`:`>=0.0.1` +- `@qwen-code/sdk`:`>=0.1.8` - 浏览器环境需要能访问 Qwen Code daemon serve 的 HTTP 接口。 组件包会自动注入自身样式,样式已通过 CSS Modules 和组件作用域隔离; 接入方不需要额外引入全局 CSS。 -### 安装 +## 安装 ```bash -npm install @alife/dataworks-qwen-code-web-shell +npm install @qwen-code/web-shell ``` -### 基本用法 +Peer dependencies 需要同时安装: + +```bash +npm install react react-dom @qwen-code/webui @qwen-code/sdk +``` + +## 接入方式 + +WebShell 提供两种接入形态: + +### 1. 独立接入(自带 Provider) + +适合只需要嵌入一个终端视图的场景。组件内部自建 +`DaemonWorkspaceProvider` + `DaemonSessionProvider`。 ```tsx -import { WebShell } from '@alife/dataworks-qwen-code-web-shell'; +import { WebShellWithProviders } from '@qwen-code/web-shell'; export function QwenCodePanel() { return ( - { console.log('current session:', sessionId); }} theme="dark" language="zh-CN" - onLanguageChange={(language) => { - console.log('current language:', language); - }} /> ); } ``` -### Props - -| 属性 | 类型 | 说明 | -| ------------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------- | -| `baseUrl` | `string` | daemon API 地址。组件化接入时建议显式传入,例如 `http://127.0.0.1:4170`。未传时使用同源 API 路径。 | -| `token` | `string` | daemon API Bearer token。未传时会从当前 URL 的 `?token=` 中读取。 | -| `initialSessionId` | `string` | 初始要连接的 daemon session id。未传时,独立应用会尝试从 `/session/:id` 路径中读取。 | -| `onSessionIdChange` | `(sessionId: string) => void` | 当前 session id 变化时触发。组件化接入建议用它同步外层路由或状态。 | -| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark`。也可以通过 `/theme` 命令在组件内部切换。 | -| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言。未传时独立应用会读取 URL、localStorage 或浏览器语言。 | -| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发。组件化接入方可在这里持久化语言设置。 | +### 2. 共享 Provider 接入(纯消费者) + +适合同一个 React 应用中多个视图共享同一个 daemon session 的场景(如 +chat + terminal)。宿主自行提供 Provider,WebShell 只消费 hooks。 + +```tsx +import { + DaemonWorkspaceProvider, + DaemonSessionProvider, +} from '@qwen-code/webui/daemon-react-sdk'; +import { WebShell } from '@qwen-code/web-shell'; + +export function App() { + return ( + + + + + + + ); +} +``` + +> **注意**:不要在已有 `DaemonSessionProvider` 下使用 +> `WebShellWithProviders`,否则会创建嵌套的重复 Provider。 + +## Props + +### WebShellWithProviders + +包含 `WebShell` 的所有 Props,加上 Provider 配置: + +| 属性 | 类型 | 说明 | +| ------------------ | -------- | ---------------------------------------------------- | +| `baseUrl` | `string` | daemon API 地址,未传时使用 `window.location.origin` | +| `token` | `string` | daemon API Bearer token | +| `initialSessionId` | `string` | 初始要连接的 session id | + +### WebShell + +| 属性 | 类型 | 说明 | +| ------------------- | -------------------------------------- | --------------------------------- | +| `onSessionIdChange` | `(sessionId: string) => void` | 当前 session id 变化时触发 | +| `theme` | `'dark' \| 'light'` | UI 主题,默认 `dark` | +| `onThemeChange` | `(theme: WebShellTheme) => void` | `/theme` 命令切换主题后触发 | +| `language` | `'en' \| 'zh-CN' \| 'zh' \| 'zh-cn'` | UI 语言 | +| `onLanguageChange` | `(language: WebShellLanguage) => void` | `/language ui` 切换 UI 语言后触发 | + +## 架构说明 + +```text +@qwen-code/sdk/daemon ← 协议层(SSE, REST, normalizer) +@qwen-code/webui/daemon-react-sdk ← React adapter(Provider, hooks, store) +@qwen-code/web-shell ← 终端 UI 组件 +``` + +- `WebShell` 必须在 `DaemonWorkspaceProvider` 和 `DaemonSessionProvider` 之下使用。 +- `WebShellWithProviders` 是内置 Provider 的便捷 wrapper。 +- 同一个 React 树共享一个 `DaemonSessionProvider` 时只开一条 SSE。 ## 已支持的斜杠命令 diff --git a/packages/web-shell/client/App.module.css b/packages/web-shell/client/App.module.css index e42d07e02b9..c0feef88adf 100644 --- a/packages/web-shell/client/App.module.css +++ b/packages/web-shell/client/App.module.css @@ -4,6 +4,7 @@ --font-sans: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif; --radius: 6px; + position: relative; display: flex; flex-direction: column; height: 100%; @@ -59,7 +60,7 @@ } .themeLight { - --app-bg: #fbfbf8; + --app-bg: #FFF; --bg-primary: #ffffff; --bg-secondary: #f7f7f2; --bg-tertiary: #efeee8; @@ -95,6 +96,15 @@ --error-border: rgba(192, 54, 44, 0.24); } +.dialogOverlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + flex-direction: column; + background: var(--app-bg); +} + .content { flex: 0 1 auto; min-height: 0; @@ -112,6 +122,13 @@ flex-shrink: 0; } +.bottomPanels { + display: flex; + flex-direction: column; + gap: 4px; + padding-bottom: 4px; +} + .composer { flex-shrink: 0; padding: 0; diff --git a/packages/web-shell/client/App.tsx b/packages/web-shell/client/App.tsx index a6f3be29b37..fec1a9496ed 100644 --- a/packages/web-shell/client/App.tsx +++ b/packages/web-shell/client/App.tsx @@ -1,17 +1,23 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { useDaemonSession } from './hooks/useDaemonSession'; import { - transcriptBlocksToMessages, - extractPendingPermission, - extractStreamingState, -} from './adapters/transcriptAdapter'; + useActions, + useConnection, + useMessages, + useDaemonFollowupSuggestion, + useStreamingState, + useTranscriptBlocks, + useTranscriptStore, + type DaemonStreamingState, +} from '@qwen-code/webui/daemon-react-sdk'; +import { extractPendingPermission } from './adapters/transcriptAdapter'; import { MessageList } from './components/MessageList'; -import { Editor } from './components/Editor'; +import { Editor, type EditorHandle } from './components/Editor'; import type { PromptImage } from './adapters/promptTypes'; import { StatusBar } from './components/StatusBar'; import { ShortcutsPanel } from './components/ShortcutsPanel'; import { StreamingStatus } from './components/StreamingStatus'; import { TodoPanel } from './components/panels/TodoPanel'; +import { ActiveAgentsPanel } from './components/panels/ActiveAgentsPanel'; import { WelcomeHeader } from './components/WelcomeHeader'; import { ModelDialog } from './components/dialogs/ModelDialog'; import { ApprovalModeDialog } from './components/dialogs/ApprovalModeDialog'; @@ -30,7 +36,6 @@ import { } from './components/dialogs/ThemeDialog'; import { ReleaseSessionDialog } from './components/dialogs/ReleaseSessionDialog'; import { getLocalCommands } from './constants/localCommands'; -import { getDaemonBaseUrl, getDaemonToken } from './config/daemon'; import { mergeCommands } from './hooks/daemonSessionMappers'; import { useAnimationFrameValue } from './hooks/useAnimationFrameValue'; import { @@ -44,16 +49,17 @@ import { copyFromLastAssistantMessage, COPY_MESSAGES, } from './utils/copyCommand'; +import { handleTasksSlashCommand } from './utils/tasksCommand'; import { DAEMON_APPROVAL_MODES, type DaemonApprovalMode, -} from '@qwen-code/sdk/daemon'; -import type { Message, StreamingState, TodoItem } from './adapters/types'; +} from '@qwen-code/webui/daemon-react-sdk'; +import { serializeContextUsageMessage } from './components/messages/ContextUsageMessage'; +import type { ACPToolCall, Message, TodoItem } from './adapters/types'; import { extractTodosFromToolCall, hasActiveTodos } from './utils/todos'; +import { ThemeProvider } from './themeContext'; import styles from './App.module.css'; -const DAEMON_BASE_URL = getDaemonBaseUrl(); -const DAEMON_TOKEN = getDaemonToken(); const WEB_SHELL_VERSION = __WEB_SHELL_VERSION__; const MODES_CYCLE = DAEMON_APPROVAL_MODES; const MAX_DISPLAYED_QUEUED_PROMPTS = 3; @@ -64,16 +70,13 @@ interface QueuedPrompt { images?: PromptImage[]; } +interface LocalRecapMessage { + anchorAfterId?: string; + anchorIndex: number; + message: Message; +} + export interface WebShellProps { - /** - * Daemon API base URL. When omitted, the standalone app reads the `daemon` - * query parameter and otherwise uses same-origin API paths. - */ - baseUrl?: string; - /** Bearer token for daemon API calls. Standalone mode falls back to `?token=`. */ - token?: string; - /** Existing daemon session to attach to. Standalone mode falls back to `/session/:id`. */ - initialSessionId?: string; /** Called whenever the attached daemon session id changes. */ onSessionIdChange?: (sessionId: string) => void; /** Visual theme for the embedded shell. Defaults to the dark terminal skin. */ @@ -84,17 +87,16 @@ export interface WebShellProps { language?: 'en' | 'zh-CN' | 'zh' | 'zh-cn'; /** Called when `/language ui` changes the web-shell UI language. */ onLanguageChange?: (language: WebShellLanguage) => void; -} - -function getSessionIdFromUrl(): string | undefined { - if (typeof window === 'undefined') return undefined; - const match = window.location.pathname.match(/\/session\/([^/]+)/); - if (!match) return undefined; - try { - return decodeURIComponent(match[1]); - } catch { - return undefined; - } + /** Additional CSS class name appended to the root element. */ + className?: string; + /** Inline styles applied to the root element. */ + style?: React.CSSProperties; + /** Called when connection status changes (idle/connecting/connected/disconnected/error). */ + onConnectionChange?: (status: string) => void; + /** Called when prompt status changes (idle/waiting/responding). */ + onStreamingStateChange?: (state: DaemonStreamingState) => void; + /** Called when a critical error occurs (auth failure, session gone, etc). */ + onError?: (error: Error) => void; } function replaceSessionUrl(sessionId: string): void { @@ -157,6 +159,26 @@ function getFloatingTodos(messages: readonly Message[]): TodoItem[] { return []; } +function isAgentTool(tool: ACPToolCall): boolean { + const name = tool.toolName.toLowerCase(); + return ( + name === 'agent' || name === 'task' || Boolean(tool.args?.subagent_type) + ); +} + +function isActiveTool(tool: ACPToolCall): boolean { + return tool.status === 'pending' || tool.status === 'in_progress'; +} + +function getFloatingAgents(messages: readonly Message[]): ACPToolCall[] { + return messages.flatMap((message) => { + if (message.role !== 'tool_group') return []; + return message.tools.filter( + (tool) => isAgentTool(tool) && isActiveTool(tool), + ); + }); +} + function translateCopyMessage( message: string, t: ReturnType, @@ -218,19 +240,17 @@ function QueuedPromptDisplay({ } export function App({ - baseUrl, - token, - initialSessionId: providedInitialSessionId, onSessionIdChange, theme: providedTheme = 'dark', onThemeChange, language: providedLanguage, onLanguageChange, + className: externalClassName, + style: externalStyle, + onConnectionChange, + onStreamingStateChange, + onError, }: WebShellProps = {}) { - const initialSessionId = useMemo( - () => providedInitialSessionId ?? getSessionIdFromUrl(), - [providedInitialSessionId], - ); const [selectedLanguage, setSelectedLanguage] = useState( () => providedLanguage === undefined @@ -238,42 +258,70 @@ export function App({ : normalizeLanguage(providedLanguage), ); const t = useMemo(() => getTranslator(selectedLanguage), [selectedLanguage]); - const { store, state, connection, actions, promptStatus } = useDaemonSession({ - baseUrl: baseUrl ?? DAEMON_BASE_URL, - token: token ?? DAEMON_TOKEN, - initialSessionId, - loadWarnings: { - models: t('loadWarning.models'), - commands: t('loadWarning.commands'), - context: t('loadWarning.context'), - }, - }); + const store = useTranscriptStore(); + const blocks = useTranscriptBlocks(); + const connection = useConnection(); + const sessionActions = useActions(); - const messageBlocks = useAnimationFrameValue(state.blocks); - const messages = useMemo( - () => transcriptBlocksToMessages(messageBlocks), - [messageBlocks], + const messages = useMessages(); + const [recapMessage, setRecapMessage] = useState( + null, ); + const nextRecapMessageIdRef = useRef(1); + const activeSessionIdRef = useRef(connection.sessionId); + const displayMessages = useMemo(() => { + if (!recapMessage) return messages; + const anchorIndex = recapMessage.anchorAfterId + ? messages.findIndex( + (message) => message.id === recapMessage.anchorAfterId, + ) + : -1; + const index = + anchorIndex >= 0 + ? anchorIndex + 1 + : Math.min(recapMessage.anchorIndex, messages.length); + return [ + ...messages.slice(0, index), + recapMessage.message, + ...messages.slice(index), + ]; + }, [messages, recapMessage]); + const messageBlocks = useAnimationFrameValue(blocks); const pendingApproval = useMemo( () => extractPendingPermission(messageBlocks), [messageBlocks], ); const shouldHideComposer = pendingApproval !== null; const floatingTodos = useMemo(() => getFloatingTodos(messages), [messages]); - const transcriptStreamingState = useMemo( - () => extractStreamingState(messageBlocks), - [messageBlocks], + const floatingAgents = useMemo(() => getFloatingAgents(messages), [messages]); + const activeAgentsPanelRef = useRef(null); + const editorRef = useRef(null); + const { + followupState, + onAcceptFollowup, + onDismissFollowup, + clear: clearFollowup, + } = useDaemonFollowupSuggestion({ + onAccept: (suggestion) => { + editorRef.current?.insertText(suggestion); + }, + }); + const sendPrompt = useCallback( + ( + text: string, + images?: PromptImage[], + opts?: { optimisticUserMessage?: boolean }, + ) => { + clearFollowup(); + return sessionActions.sendPrompt(text, { + images, + optimisticUserMessage: opts?.optimisticUserMessage, + }); + }, + [clearFollowup, sessionActions], ); - const streamingState = useMemo(() => { - if (promptStatus === 'idle') { - return transcriptStreamingState; - } - if (transcriptStreamingState !== 'idle') { - return transcriptStreamingState; - } - return promptStatus === 'waiting' ? 'waiting' : 'responding'; - }, [promptStatus, transcriptStreamingState]); - const streamingStateRef = useRef(streamingState); + const streamingState = useStreamingState(); + const streamingStateRef = useRef(streamingState); const connected = connection.status === 'connected'; const [modelDialogMode, setModelDialogMode] = useState< @@ -320,6 +368,59 @@ export function App({ [store], ); + useEffect(() => { + activeSessionIdRef.current = connection.sessionId; + setRecapMessage(null); + lastRecapBlockCountRef.current = 0; + }, [connection.sessionId]); + + const runVisibleRecap = useCallback(() => { + const messageId = `local-recap-${nextRecapMessageIdRef.current++}`; + const anchorIndex = messages.length; + const anchorAfterId = messages.at(-1)?.id; + const sessionId = connection.sessionId; + setRecapMessage({ + anchorAfterId, + anchorIndex, + message: { + id: messageId, + role: 'system', + content: `※ recap: ${t('recap.loading')}`, + variant: 'info', + }, + }); + sessionActions.recapSession().then( + (result) => { + if (activeSessionIdRef.current !== sessionId) return; + setRecapMessage({ + anchorAfterId, + anchorIndex, + message: { + id: messageId, + role: 'system', + content: result.recap + ? `※ recap: ${result.recap}` + : t('recap.empty'), + variant: 'info', + }, + }); + }, + (error: unknown) => { + if (activeSessionIdRef.current !== sessionId) return; + setRecapMessage({ + anchorAfterId, + anchorIndex, + message: { + id: messageId, + role: 'system', + content: formatError(error, t('recap.failed')), + variant: 'error', + }, + }); + }, + ); + }, [connection.sessionId, messages, sessionActions, t]); + useEffect(() => { queuedPromptsRef.current = queuedPrompts; }, [queuedPrompts]); @@ -392,7 +493,7 @@ export function App({ ); return; } - actions + sessionActions .setApprovalMode(modeId) .then((result) => { setCurrentMode(result.mode || modeId); @@ -401,13 +502,27 @@ export function App({ reportError(error, t('local.approvalMode')); }); }, - [actions, reportError, t], + [sessionActions, reportError, t], ); useEffect(() => { streamingStateRef.current = streamingState; }, [streamingState]); + useEffect(() => { + onStreamingStateChange?.(streamingState); + }, [streamingState, onStreamingStateChange]); + + useEffect(() => { + onConnectionChange?.(connection.status); + }, [connection.status, onConnectionChange]); + + useEffect(() => { + if (connection.error) { + onError?.(new Error(connection.error)); + } + }, [connection.error, onError]); + useEffect(() => { if (connection.currentModel) { setCurrentModel(connection.currentModel); @@ -429,6 +544,48 @@ export function App({ } }, [connection.sessionId, onSessionIdChange]); + // Auto-recap: fire when the user returns after being away ≥ 3 minutes + const hiddenAtRef = useRef(null); + const lastRecapBlockCountRef = useRef(0); + useEffect(() => { + lastRecapBlockCountRef.current = 0; + }, [connection.sessionId]); + useEffect(() => { + const AWAY_THRESHOLD_MS = 3 * 60 * 1000; + const MIN_NEW_BLOCKS = 4; + function onVisibilityChange() { + if (document.hidden) { + if (hiddenAtRef.current === null) hiddenAtRef.current = Date.now(); + return; + } + const hiddenAt = hiddenAtRef.current; + hiddenAtRef.current = null; + if (hiddenAt === null) return; + if (Date.now() - hiddenAt < AWAY_THRESHOLD_MS) return; + if (streamingStateRef.current !== 'idle') return; + if (!connection.sessionId) return; + const currentCount = store.getSnapshot().blocks.length; + if (currentCount - lastRecapBlockCountRef.current < MIN_NEW_BLOCKS) + return; + lastRecapBlockCountRef.current = currentCount; + sessionActions.recapSession().then( + (result) => { + if (result.recap) { + store.dispatch([ + { type: 'status', text: `※ recap: ${result.recap}` }, + ]); + } + }, + (error: unknown) => { + console.warn('[auto-recap] failed:', error); + }, + ); + } + document.addEventListener('visibilitychange', onVisibilityChange); + return () => + document.removeEventListener('visibilitychange', onVisibilityChange); + }, [connection.sessionId, sessionActions, store]); + const handleCycleMode = useCallback(() => { const idx = isDaemonApprovalMode(currentMode) ? MODES_CYCLE.indexOf(currentMode) @@ -448,6 +605,17 @@ export function App({ setShowHelpDialog(true); return true; } + if ( + handleTasksSlashCommand({ + cmd, + promptBlocked, + getTasks: sessionActions.getTasks, + dispatch: store.dispatch, + reportError, + }) + ) { + return true; + } if (cmd === 'theme') { const themeArg = text.slice(match[0].length).trim().toLowerCase(); if (themeArg === 'dark' || themeArg === 'light') { @@ -516,11 +684,10 @@ export function App({ setSelectedLanguage(nextLanguage); onLanguageChange?.(nextLanguage); if (!promptBlocked) { - actions - .sendPrompt(`/language ui ${nextLanguage}`, undefined, { - optimisticUserMessage: false, - }) - .then(() => actions.refreshCommands()) + sendPrompt(`/language ui ${nextLanguage}`, undefined, { + optimisticUserMessage: false, + }) + .then(() => sessionActions.refreshCommands()) .catch((error: unknown) => { reportError(error, 'Failed to sync /language command'); }); @@ -557,15 +724,13 @@ export function App({ } if (modelArg.startsWith('--fast ')) { if (promptBlocked) return enqueuePrompt(text, images); - actions - .sendPrompt(text, images) - .catch((error: unknown) => - reportError(error, 'Failed to send /model --fast'), - ); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /model --fast'), + ); return true; } if (modelArg) { - actions + sessionActions .setModel(modelArg) .then(() => { setCurrentModel(modelArg); @@ -581,16 +746,14 @@ export function App({ if (cmd === 'plan') { if (promptBlocked) return enqueuePrompt(text, images); const prompt = text.slice(match[0].length).trim(); - actions + sessionActions .setApprovalMode('plan') .then(() => { setCurrentMode('plan'); if (prompt) { - actions - .sendPrompt(prompt, images) - .catch((error: unknown) => - reportError(error, 'Failed to send plan prompt'), - ); + sendPrompt(prompt, images).catch((error: unknown) => + reportError(error, 'Failed to send plan prompt'), + ); } }) .catch((error: unknown) => { @@ -615,11 +778,9 @@ export function App({ const skillArg = text.slice(match[0].length).trim(); if (skillArg) { if (promptBlocked) return enqueuePrompt(text, images); - actions - .sendPrompt(text, images) - .catch((error: unknown) => - reportError(error, 'Failed to send /skills command'), - ); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /skills command'), + ); } else { setShowSkillsDialog(true); } @@ -629,6 +790,31 @@ export function App({ setShowToolsDialog(true); return true; } + if (cmd === 'context') { + const contextArg = text.slice(match[0].length).trim().toLowerCase(); + if ( + contextArg === '' || + contextArg === 'detail' || + contextArg === '-d' + ) { + sessionActions + .getContextUsage({ + detail: contextArg === 'detail' || contextArg === '-d', + }) + .then((result) => { + store.dispatch([ + { + type: 'status', + text: serializeContextUsageMessage(result), + }, + ]); + }) + .catch((error: unknown) => { + reportError(error, 'Failed to load context usage'); + }); + return true; + } + } if (cmd === 'memory') { const memoryArg = text.slice(match[0].length).trim().toLowerCase(); if (memoryArg === 'show') { @@ -675,7 +861,7 @@ export function App({ return true; } if (cmd === 'new' || cmd === 'reset') { - actions.newSession().catch((error: unknown) => { + sessionActions.newSession().catch((error: unknown) => { reportError(error, 'Failed to create a new session'); }); return true; @@ -684,11 +870,9 @@ export function App({ const renameArg = parseRenameArgument(text.slice(match[0].length)); if (renameArg.type === 'auto' || renameArg.type === 'delegate') { if (promptBlocked) return enqueuePrompt(text, images); - actions - .sendPrompt(text, images) - .catch((error: unknown) => - reportError(error, 'Failed to send /rename command'), - ); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send /rename command'), + ); return true; } const displayName = renameArg.displayName; @@ -701,7 +885,7 @@ export function App({ ]); return true; } - actions + sessionActions .renameSession(displayName) .then(() => { store.dispatch([ @@ -727,7 +911,7 @@ export function App({ if (cmd === 'resume') { const sessionId = text.slice(match[0].length).trim(); if (sessionId) { - actions.loadSession(sessionId).catch((error: unknown) => { + sessionActions.loadSession(sessionId).catch((error: unknown) => { reportError(error, 'Failed to load session'); }); } else { @@ -735,37 +919,36 @@ export function App({ } return true; } + if (cmd === 'recap') { + runVisibleRecap(); + return true; + } } // Forward slash commands as prompts if (promptBlocked) return enqueuePrompt(text, images); - actions - .sendPrompt(text, images) - .catch((error: unknown) => - reportError(error, 'Failed to send command'), - ); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send command'), + ); return true; } else if (text.startsWith('!')) { if (promptBlocked) return enqueuePrompt(text, images); const cmd = text.slice(1).trim(); if (!cmd) return false; - actions - .sendPrompt(formatShellCommandPrompt(cmd)) - .catch((error: unknown) => { - reportError(error, 'Failed to send shell command'); - }); + sessionActions.sendShellCommand(cmd).catch((error: unknown) => { + reportError(error, 'Failed to execute shell command'); + }); return true; } else { if (promptBlocked) return enqueuePrompt(text, images); - actions - .sendPrompt(text, images) - .catch((error: unknown) => - reportError(error, 'Failed to send message'), - ); + sendPrompt(text, images).catch((error: unknown) => + reportError(error, 'Failed to send message'), + ); return true; } }, [ - actions, + sendPrompt, + sessionActions, store, enqueuePrompt, handleThemeChange, @@ -773,6 +956,7 @@ export function App({ messages, onLanguageChange, reportError, + runVisibleRecap, selectedLanguage, t, ], @@ -813,24 +997,51 @@ export function App({ const handleConfirm = useCallback( (id: string, selectedOption: string, answers?: Record) => { - actions - .respondToPermission(id, selectedOption, answers) + sessionActions + .submitPermission(id, selectedOption, answers) .catch((error: unknown) => { reportError(error, 'Failed to submit permission choice'); }); }, - [actions, reportError], + [sessionActions, reportError], ); const handleCancel = useCallback(() => { - actions.cancel().catch((error: unknown) => { + sessionActions.cancel().catch((error: unknown) => { reportError(error, 'Failed to cancel request'); }); - }, [actions, reportError]); + }, [sessionActions, reportError]); + + const handleFocusActiveAgents = useCallback((): boolean => { + if (floatingAgents.length === 0) return false; + editorRef.current?.blur(); + window.setTimeout(() => { + activeAgentsPanelRef.current?.focus({ preventScroll: true }); + }, 0); + return true; + }, [floatingAgents.length]); + + const handleReturnToEditor = useCallback((text?: string) => { + if (text) { + editorRef.current?.insertText(text); + return; + } + editorRef.current?.focus(); + }, []); useEffect(() => { const onKeyDown = (e: KeyboardEvent) => { if (e.defaultPrevented) return; + if (e.key === 'Tab' && e.shiftKey && pendingApproval && !dialogOpen) { + e.preventDefault(); + const allowAlways = pendingApproval.options.find( + (o) => o.kind === 'allow_always', + ); + if (allowAlways) { + handleConfirm(pendingApproval.id, allowAlways.id); + } + return; + } if ( e.key === 'Escape' && !pendingApproval && @@ -854,6 +1065,8 @@ export function App({ }, [ streamingState, handleCancel, + handleConfirm, + handleSetMode, pendingApproval, dialogOpen, clearQueuedPrompts, @@ -863,7 +1076,7 @@ export function App({ const handleModelSelect = useCallback( (modelId: string) => { - actions + sessionActions .setModel(modelId) .then(() => { setCurrentModel(modelId); @@ -872,17 +1085,17 @@ export function App({ reportError(error, t('model.switch')); }); }, - [actions, reportError, t], + [sessionActions, reportError, t], ); const handleFastModelSelect = useCallback( (modelId: string) => { if (streamingState !== 'idle') return; - actions.sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => { + sendPrompt(`/model --fast ${modelId}`).catch((error: unknown) => { reportError(error, 'Failed to switch fast model'); }); }, - [actions, streamingState, reportError], + [sendPrompt, streamingState, reportError], ); const commands = useMemo(() => { @@ -895,200 +1108,192 @@ export function App({ ); }, [connection.commands, connection.skills, t]); - const appClassName = `${styles.app} ${ - selectedTheme === 'light' ? styles.themeLight : styles.themeDark - }`; + const appClassName = [ + styles.app, + selectedTheme === 'light' ? styles.themeLight : styles.themeDark, + externalClassName, + ] + .filter(Boolean) + .join(' '); return ( - -
- {modelDialogMode ? ( - + +
+ {dialogOpen && ( +
+ {modelDialogMode && ( + setModelDialogMode(null)} + /> + )} + {showResumeDialog && ( + { + sessionActions + .loadSession(sessionId) + .catch((error: unknown) => { + reportError(error, 'Failed to load session'); + }); + }} + onClose={() => setShowResumeDialog(false)} + /> + )} + {showReleaseDialog && ( + { + store.dispatch([ + { + type: 'status', + text: `${t('release.released')} (${sessionId.slice(0, 8)})`, + }, + ]); + }} + onError={(error) => { + const reason = + error instanceof Error ? error.message : String(error); + store.dispatch([ + { + type: 'error', + text: t('release.failed', { reason }), + }, + ]); + }} + onClose={() => setShowReleaseDialog(false)} + /> + )} + {showModeDialog && ( + setShowModeDialog(false)} + /> + )} + {showMcpDialog && ( + setShowMcpDialog(false)} /> + )} + {showHelpDialog && ( + setShowHelpDialog(false)} + /> + )} + {showThemeDialog && ( + setShowThemeDialog(false)} + /> + )} + {showSkillsDialog && ( + setShowSkillsDialog(false)} /> + )} + {showToolsDialog && ( + setShowToolsDialog(false)} /> + )} + {memoryDialogMode && ( + { + store.dispatch([{ type, text }]); + }} + onClose={() => setMemoryDialogMode(null)} + /> + )} + {agentsDialogMode && ( + setAgentsDialogMode(null)} + /> + )} +
+ )} + +
0 || streamingState !== 'idle' + ? `${styles.content} ${styles.contentHasMessages}` + : styles.content } - onClose={() => setModelDialogMode(null)} - /> - ) : showResumeDialog ? ( - { - actions.loadSession(sessionId).catch((error: unknown) => { - reportError(error, 'Failed to load session'); - }); - }} - onClose={() => setShowResumeDialog(false)} - /> - ) : showReleaseDialog ? ( - { - store.dispatch([ - { - type: 'status', - text: `${t('release.released')} (${sessionId.slice(0, 8)})`, - }, - ]); - }} - onError={(error) => { - const reason = - error instanceof Error ? error.message : String(error); - store.dispatch([ - { - type: 'error', - text: t('release.failed', { reason }), - }, - ]); - }} - onClose={() => setShowReleaseDialog(false)} - /> - ) : showModeDialog ? ( - setShowModeDialog(false)} - /> - ) : showMcpDialog ? ( - setShowMcpDialog(false)} - /> - ) : showHelpDialog ? ( - setShowHelpDialog(false)} - /> - ) : showThemeDialog ? ( - setShowThemeDialog(false)} - /> - ) : showSkillsDialog ? ( - setShowSkillsDialog(false)} - /> - ) : showToolsDialog ? ( - setShowToolsDialog(false)} - /> - ) : memoryDialogMode ? ( - { - store.dispatch([{ type, text }]); - }} - onClose={() => setMemoryDialogMode(null)} - /> - ) : agentsDialogMode ? ( - setAgentsDialogMode(null)} - /> - ) : ( - <> -
0 || streamingState !== 'idle' - ? `${styles.content} ${styles.contentHasMessages}` - : styles.content + style={dialogOpen ? { visibility: 'hidden' } : undefined} + > + } - > - - } - /> - - -
+ /> -
- {floatingTodos.length > 0 && } - {!shouldHideComposer && ( -
- - prompt.text)} - onPopQueuedMessages={popQueuedPromptsForEdit} - onClearQueuedMessages={clearQueuedPrompts} - currentMode={currentMode} - placeholderText={ - !connected - ? t('common.loading') - : streamingState !== 'idle' - ? t('editor.processing') - : t('editor.placeholder') - } - /> -
- )} + +
- {!shouldHideComposer && - (showShortcuts ? ( - - ) : ( - - ))} -
- - )} -
-
- ); -} +
+ {floatingTodos.length > 0 && ( +
+ +
+ )} + {!shouldHideComposer && ( +
+ + prompt.text)} + onFocusActiveAgents={handleFocusActiveAgents} + onPopQueuedMessages={popQueuedPromptsForEdit} + onClearQueuedMessages={clearQueuedPrompts} + currentMode={currentMode} + dialogOpen={dialogOpen} + followupState={followupState} + onAcceptFollowup={onAcceptFollowup} + onDismissFollowup={onDismissFollowup} + placeholderText={ + !connected + ? t('common.loading') + : streamingState !== 'idle' + ? t('editor.processing') + : t('editor.placeholder') + } + /> +
+ )} + {!shouldHideComposer && + (showShortcuts ? : )} -function formatShellCommandPrompt(cmd: string): string { - const longestBacktickRun = Math.max( - 0, - ...Array.from(cmd.matchAll(/`+/g), (match) => match[0].length), + {floatingAgents.length > 0 && ( +
+ +
+ )} +
+
+
+ ); - const fence = '`'.repeat(Math.max(3, longestBacktickRun + 1)); - return `Run the following shell command exactly, do not modify it:\n${fence}sh\n${cmd}\n${fence}`; } diff --git a/packages/web-shell/client/adapters/transcriptAdapter.test.ts b/packages/web-shell/client/adapters/transcriptAdapter.test.ts index 6d2d6cf5f7b..b705ae53950 100644 --- a/packages/web-shell/client/adapters/transcriptAdapter.test.ts +++ b/packages/web-shell/client/adapters/transcriptAdapter.test.ts @@ -1,76 +1,9 @@ import { describe, expect, it } from 'vitest'; import type { - DaemonStatusTranscriptBlock, - DaemonTextTranscriptBlock, - DaemonToolTranscriptBlock, DaemonTranscriptBlock, DaemonTranscriptState, -} from '@qwen-code/sdk/daemon'; -import { - extractPendingPermission, - extractStreamingState, - transcriptBlocksToMessages, -} from './transcriptAdapter'; - -function textBlock( - id: string, - kind: 'user' | 'assistant' | 'thought', - text: string, - createdAt: number, - streaming = false, -): DaemonTextTranscriptBlock { - return { - id, - kind, - text, - streaming, - clientReceivedAt: createdAt, - createdAt, - updatedAt: createdAt, - }; -} - -function statusBlock( - id: string, - text: string, - createdAt: number, -): DaemonStatusTranscriptBlock { - return { - id, - kind: 'status', - text, - clientReceivedAt: createdAt, - createdAt, - updatedAt: createdAt, - }; -} - -function toolBlock( - id: string, - toolCallId: string, - status: string, - createdAt: number, - overrides: Partial = {}, -): DaemonToolTranscriptBlock { - return { - id, - kind: 'tool', - toolCallId, - title: overrides.title ?? 'Tool', - status, - toolName: overrides.toolName ?? 'Read', - toolKind: overrides.toolKind, - preview: overrides.preview ?? { kind: 'generic' }, - rawInput: overrides.rawInput, - rawOutput: overrides.rawOutput, - content: overrides.content, - locations: overrides.locations, - details: overrides.details, - clientReceivedAt: createdAt, - createdAt, - updatedAt: overrides.updatedAt ?? createdAt, - }; -} +} from '@qwen-code/webui/daemon-react-sdk'; +import { extractPendingPermission } from './transcriptAdapter'; function state(blocks: DaemonTranscriptBlock[]): DaemonTranscriptState { return { @@ -90,104 +23,7 @@ function state(blocks: DaemonTranscriptBlock[]): DaemonTranscriptState { }; } -describe('transcriptAdapter', () => { - it('renders daemon plan status blocks as plan messages', () => { - const plan = { - sessionUpdate: 'plan', - entries: [ - { - content: '检查项目结构', - priority: 'medium', - status: 'pending', - }, - { - content: '运行类型检查', - priority: 'high', - status: 'in_progress', - }, - ], - }; - - const messages = transcriptBlocksToMessages([ - statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 1), - ]); - - expect(messages).toEqual([ - { - id: 'plan-1', - role: 'plan', - todos: [ - { - id: 'plan-0', - content: '检查项目结构', - priority: 'medium', - status: 'pending', - }, - { - id: 'plan-1', - content: '运行类型检查', - priority: 'high', - status: 'in_progress', - }, - ], - }, - ]); - }); - - it('keeps TodoWrite blocks as tool messages and does not aggregate tools', () => { - const messages = transcriptBlocksToMessages([ - toolBlock('todo-1', 'todo-call-1', 'completed', 1, { - title: 'Update Todos', - toolName: 'TodoWrite', - rawInput: { - todos: [ - { - content: '检查项目结构', - priority: 'medium', - status: 'completed', - }, - ], - }, - }), - toolBlock('todo-2', 'todo-call-2', 'completed', 2, { - title: 'Update Todos', - toolName: 'TodoWrite', - rawInput: { - todos: [ - { - content: '运行类型检查', - priority: 'high', - status: 'in_progress', - }, - ], - }, - }), - ]); - - expect(messages).toEqual([ - { - id: 'tg-todo-1', - role: 'tool_group', - tools: [ - expect.objectContaining({ - callId: 'todo-call-1', - toolName: 'TodoWrite', - }), - ], - }, - { - id: 'tg-todo-2', - role: 'tool_group', - tools: [ - expect.objectContaining({ - callId: 'todo-call-2', - toolName: 'TodoWrite', - }), - ], - }, - ]); - }); - +describe('extractPendingPermission', () => { it('extracts pending AskUserQuestion options and raw input', () => { const permission = { id: 'perm-1', @@ -302,120 +138,4 @@ describe('transcriptAdapter', () => { const result = extractPendingPermission(state([permission]).blocks); expect(result?.toolCallId).toBeUndefined(); }); - - it('keeps assistant chunks inside an active subagent until completion', () => { - const messages = transcriptBlocksToMessages([ - toolBlock('agent-start', 'agent-1', 'in_progress', 10, { - title: 'Agent: 分析项目', - toolName: 'agent', - rawInput: { subagent_type: 'general-purpose' }, - }), - textBlock('assistant-sub', 'assistant', 'subagent output', 20, true), - toolBlock('read-sub', 'read-1', 'completed', 30, { - title: 'Read file', - toolName: 'Read', - }), - toolBlock('agent-end', 'agent-1', 'completed', 40, { - title: 'Agent: 分析项目', - toolName: 'agent', - rawOutput: { type: 'task_execution' }, - }), - textBlock('assistant-main', 'assistant', 'main output', 50, false), - ]); - - expect(messages).toHaveLength(2); - expect(messages[0]).toMatchObject({ - role: 'tool_group', - tools: [ - { - callId: 'agent-1', - status: 'completed', - subContent: 'subagent output', - subTools: [{ callId: 'read-1', status: 'completed' }], - }, - ], - }); - expect(messages[1]).toMatchObject({ - id: 'assistant-main', - role: 'assistant', - content: 'main output', - }); - }); - - it('merges streaming assistant chunks into one message', () => { - const messages = transcriptBlocksToMessages([ - textBlock('a1', 'assistant', 'hello ', 1, true), - textBlock('a2', 'assistant', 'world', 2, false), - ]); - - expect(messages).toEqual([ - { - id: 'a1', - role: 'assistant', - content: 'hello world', - isStreaming: false, - }, - ]); - }); -}); - -describe('extractStreamingState', () => { - it('returns idle for empty blocks', () => { - expect(extractStreamingState(state([]).blocks)).toBe('idle'); - }); - - it('returns thinking when last block is a streaming thought', () => { - expect( - extractStreamingState( - state([textBlock('t1', 'thought', 'thinking...', 1, true)]).blocks, - ), - ).toBe('thinking'); - }); - - it('returns responding when last block is a streaming assistant', () => { - expect( - extractStreamingState( - state([textBlock('a1', 'assistant', 'hello', 1, true)]).blocks, - ), - ).toBe('responding'); - }); - - it('returns responding when last tool is in_progress', () => { - expect( - extractStreamingState( - state([toolBlock('t1', 'call-1', 'in_progress', 1)]).blocks, - ), - ).toBe('responding'); - }); - - it('returns idle when last assistant is not streaming', () => { - expect( - extractStreamingState( - state([textBlock('a1', 'assistant', 'done', 1, false)]).blocks, - ), - ).toBe('idle'); - }); - - it('returns responding when an earlier tool is still in_progress', () => { - expect( - extractStreamingState( - state([ - toolBlock('t1', 'call-1', 'in_progress', 1), - textBlock('a1', 'assistant', 'partial', 2, false), - ]).blocks, - ), - ).toBe('responding'); - }); - - it('returns idle when all tools are completed after user block', () => { - expect( - extractStreamingState( - state([ - textBlock('u1', 'user', 'hello', 1), - toolBlock('t1', 'call-1', 'completed', 2), - textBlock('a1', 'assistant', 'done', 3, false), - ]).blocks, - ), - ).toBe('idle'); - }); }); diff --git a/packages/web-shell/client/adapters/transcriptAdapter.ts b/packages/web-shell/client/adapters/transcriptAdapter.ts index 967b23332d1..67fb4e7bdaf 100644 --- a/packages/web-shell/client/adapters/transcriptAdapter.ts +++ b/packages/web-shell/client/adapters/transcriptAdapter.ts @@ -1,268 +1,11 @@ -import type { - DaemonTranscriptBlock, - DaemonTextTranscriptBlock, - DaemonToolTranscriptBlock, - DaemonShellTranscriptBlock, - DaemonStatusTranscriptBlock, -} from '@qwen-code/sdk/daemon'; -import type { - Message, - ACPToolCall, - PermissionRequest, - PermissionOptionKind, - TodoItem, - ToolCallStatus, - ToolKind, -} from './types'; -import { parseTodoItemsFromEntries } from '../utils/todos'; -import { isSubAgentToolCall } from './toolClassification'; - -interface ActiveSubAgent { - tool: ACPToolCall; - closeAt?: number; -} +import type { DaemonTranscriptBlock } from '@qwen-code/webui/daemon-react-sdk'; +import type { PermissionRequest, PermissionOptionKind } from './types'; type PermissionTranscriptBlock = Extract< DaemonTranscriptBlock, { kind: 'permission' } >; -export function transcriptBlocksToMessages( - blocks: readonly DaemonTranscriptBlock[], -): Message[] { - const messages: Message[] = []; - const subAgentStack: ActiveSubAgent[] = []; - - for (let i = 0; i < blocks.length; i++) { - const block = blocks[i]; - closeCompletedSubAgentsBefore(subAgentStack, block.createdAt); - - switch (block.kind) { - case 'user': - closeAllSubAgents(subAgentStack); - messages.push({ - id: block.id, - role: 'user', - content: (block as DaemonTextTranscriptBlock).text, - }); - break; - - case 'assistant': { - const textBlock = block as DaemonTextTranscriptBlock; - const activeSubAgent = getActiveSubAgent(subAgentStack); - if (activeSubAgent) { - activeSubAgent.subContent = - (activeSubAgent.subContent || '') + textBlock.text; - break; - } - const lastMsg = messages[messages.length - 1]; - if (lastMsg && lastMsg.role === 'assistant' && lastMsg.isStreaming) { - messages[messages.length - 1] = { - ...lastMsg, - content: lastMsg.content + textBlock.text, - isStreaming: textBlock.streaming, - }; - } else { - messages.push({ - id: block.id, - role: 'assistant', - content: textBlock.text, - isStreaming: textBlock.streaming, - }); - } - break; - } - - case 'thought': { - const textBlock = block as DaemonTextTranscriptBlock; - const activeSubAgent = getActiveSubAgent(subAgentStack); - if (activeSubAgent) { - activeSubAgent.subContent = - (activeSubAgent.subContent || '') + textBlock.text; - break; - } - const lastMsg = messages[messages.length - 1]; - if (lastMsg && lastMsg.role === 'assistant') { - messages[messages.length - 1] = { - ...lastMsg, - thinking: (lastMsg.thinking || '') + textBlock.text, - isStreaming: textBlock.streaming, - }; - } else { - messages.push({ - id: block.id, - role: 'assistant', - content: '', - thinking: textBlock.text, - isStreaming: textBlock.streaming, - }); - } - break; - } - - case 'tool': { - const toolBlock = block as DaemonToolTranscriptBlock; - const toolCall = daemonToolBlockToACPToolCall(toolBlock); - const activeSubAgent = getActiveSubAgent(subAgentStack); - - if (activeSubAgent && isAgentCompletion(toolCall)) { - mergeToolCall(activeSubAgent, toolCall); - subAgentStack.pop(); - break; - } - - if (activeSubAgent) { - activeSubAgent.subTools ||= []; - activeSubAgent.subTools.push(toolCall); - if (isSubAgentToolCall(toolCall) && !isAgentCompletion(toolCall)) { - subAgentStack.push({ tool: toolCall }); - } - break; - } - - appendToolCallMessage(messages, block.id, toolCall); - - if (isSubAgentToolCall(toolCall)) { - const closeAt = - isAgentCompletion(toolCall) && - toolBlock.updatedAt > toolBlock.createdAt - ? toolBlock.updatedAt - : undefined; - if (!isAgentCompletion(toolCall) || closeAt) { - subAgentStack.push({ tool: toolCall, closeAt }); - } - } - break; - } - - case 'shell': { - const shellBlock = block as DaemonShellTranscriptBlock; - const activeSubAgent = getActiveSubAgent(subAgentStack); - if (activeSubAgent) { - const lastSubTool = - activeSubAgent.subTools?.[activeSubAgent.subTools.length - 1]; - if (lastSubTool) { - lastSubTool.rawOutput = - String(lastSubTool.rawOutput ?? '') + shellBlock.text; - } else { - activeSubAgent.subContent = - (activeSubAgent.subContent || '') + shellBlock.text; - } - break; - } - const lastMsg = messages[messages.length - 1]; - if (lastMsg && lastMsg.role === 'tool_group') { - const lastTool = lastMsg.tools[lastMsg.tools.length - 1]; - if (lastTool) { - const nextTool = { - ...lastTool, - rawOutput: String(lastTool.rawOutput ?? '') + shellBlock.text, - }; - messages[messages.length - 1] = { - ...lastMsg, - tools: [...lastMsg.tools.slice(0, -1), nextTool], - }; - } - } - break; - } - - case 'permission': - // Handled separately via extractPendingPermission - break; - - case 'status': - case 'debug': { - const text = (block as DaemonStatusTranscriptBlock).text; - const activeSubAgent = getActiveSubAgent(subAgentStack); - if (activeSubAgent) { - activeSubAgent.subContent = - (activeSubAgent.subContent || '') + text + '\n'; - break; - } - const todos = parsePlanTodos(text); - if (todos) { - messages.push({ - id: block.id, - role: 'plan', - todos, - }); - break; - } - messages.push({ - id: block.id, - role: 'system', - content: text, - variant: 'info', - }); - break; - } - - case 'error': - messages.push({ - id: block.id, - role: 'system', - content: (block as DaemonStatusTranscriptBlock).text, - variant: 'error', - }); - break; - } - } - closeAllSubAgents(subAgentStack); - - return messages; -} - -function getActiveSubAgent(stack: ActiveSubAgent[]): ACPToolCall | undefined { - return stack[stack.length - 1]?.tool; -} - -function closeCompletedSubAgentsBefore( - stack: ActiveSubAgent[], - timestamp: number, -): void { - while (stack.length > 0) { - const active = stack[stack.length - 1]; - if (!active?.closeAt || timestamp < active.closeAt) { - return; - } - stack.pop(); - } -} - -function closeAllSubAgents(stack: ActiveSubAgent[]): void { - stack.length = 0; -} - -function appendToolCallMessage( - messages: Message[], - blockId: string, - toolCall: ACPToolCall, -): void { - messages.push({ - id: `tg-${blockId}`, - role: 'tool_group', - tools: [toolCall], - }); -} - -function mergeToolCall(target: ACPToolCall, source: ACPToolCall): void { - target.status = source.status || target.status; - target.title = source.title || target.title; - target.toolName = source.toolName || target.toolName; - target.kind = source.kind || target.kind; - target.endTime = source.endTime || target.endTime; - target.rawOutput = source.rawOutput ?? target.rawOutput; - target.args = source.args || target.args; - target.content = source.content || target.content; - target.locations = source.locations || target.locations; -} - -function isAgentCompletion(tool: ACPToolCall): boolean { - if (!isSubAgentToolCall(tool)) return false; - return tool.status === 'completed' || tool.status === 'failed'; -} - export function extractPendingPermission( blocks: readonly DaemonTranscriptBlock[], ): PermissionRequest | null { @@ -305,29 +48,6 @@ function isPermissionBlock( return block.kind === 'permission'; } -function parsePlanTodos(text: string): TodoItem[] | undefined { - const rawJson = text.startsWith('plan: ') - ? text.slice('plan: '.length) - : undefined; - if (!rawJson) { - return undefined; - } - - try { - const parsed = JSON.parse(rawJson) as unknown; - const record = getRecord(parsed); - if ( - record?.['sessionUpdate'] !== 'plan' || - !Array.isArray(record['entries']) - ) { - return undefined; - } - return parseTodoItemsFromEntries(record['entries']); - } catch { - return undefined; - } -} - function getPermissionRawInput( toolCall: unknown, ): Record | undefined { @@ -364,89 +84,3 @@ function getPermissionOptionKind( ? kind : undefined; } - -export function extractStreamingState( - blocks: readonly DaemonTranscriptBlock[], -): 'idle' | 'waiting' | 'responding' | 'thinking' { - if (blocks.length === 0) return 'idle'; - - const last = blocks[blocks.length - 1]; - if ( - last.kind === 'thought' && - (last as DaemonTextTranscriptBlock).streaming - ) { - return 'thinking'; - } - if ( - last.kind === 'assistant' && - (last as DaemonTextTranscriptBlock).streaming - ) { - return 'responding'; - } - if ( - last.kind === 'tool' && - (last as DaemonToolTranscriptBlock).status === 'in_progress' - ) { - return 'responding'; - } - - // Check if any tool is still in progress - for (let i = blocks.length - 1; i >= 0; i--) { - const b = blocks[i]; - if (b.kind === 'user') break; - if ( - b.kind === 'tool' && - (b as DaemonToolTranscriptBlock).status === 'in_progress' - ) { - return 'responding'; - } - } - - return 'idle'; -} - -function daemonToolBlockToACPToolCall( - block: DaemonToolTranscriptBlock, -): ACPToolCall { - const statusMap: Record = { - running: 'in_progress', - pending: 'pending', - completed: 'completed', - failed: 'failed', - in_progress: 'in_progress', - }; - - return { - callId: block.toolCallId, - toolName: block.toolName || 'unknown', - title: block.title, - status: - statusMap[block.status] || - (block.status as ToolCallStatus) || - 'in_progress', - kind: inferToolKind(block.toolName, block.toolKind), - rawOutput: block.rawOutput ?? block.details, - args: block.rawInput as Record | undefined, - startTime: block.createdAt, - endTime: - block.status === 'completed' || block.status === 'failed' - ? block.updatedAt - : undefined, - }; -} - -function inferToolKind( - toolName?: string, - toolKind?: string, -): ToolKind | undefined { - if (toolKind) return toolKind as ToolKind; - if (!toolName) return undefined; - const name = toolName.toLowerCase(); - if (name === 'bash' || name === 'execute') return 'execute'; - if (name === 'read') return 'read'; - if (name === 'edit' || name === 'write') return 'edit'; - if (name.includes('search') || name === 'grep' || name === 'glob') - return 'search'; - if (name === 'agent' || name === 'task') return 'other'; - return undefined; -} diff --git a/packages/web-shell/client/adapters/types.ts b/packages/web-shell/client/adapters/types.ts index 57df08cd73d..f8c45d48223 100644 --- a/packages/web-shell/client/adapters/types.ts +++ b/packages/web-shell/client/adapters/types.ts @@ -1,58 +1,30 @@ -export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; -export type StreamingState = 'idle' | 'waiting' | 'responding' | 'thinking'; - -export type ToolKind = - | 'read' - | 'edit' - | 'delete' - | 'move' - | 'search' - | 'execute' - | 'think' - | 'fetch' - | 'switch_mode' - | 'other'; - -export interface ToolCallLocation { - file: string; - line?: number; -} - -export interface DiffContent { - type: 'diff'; - path: string; - oldText?: string; - newText: string; -} - -export interface TextContent { - type: 'content'; - content: ContentBlock; -} - -export interface TerminalContent { - type: 'terminal'; - terminalId: string; -} - -export type ToolCallContent = TextContent | DiffContent | TerminalContent; - -export interface ACPToolCall { - callId: string; - toolName: string; - args?: Record; - status: ToolCallStatus; - parentToolCallId?: string; - title?: string; - content?: ToolCallContent[]; - rawOutput?: unknown; - locations?: ToolCallLocation[]; - kind?: ToolKind; - startTime?: number; - endTime?: number; - subContent?: string; - subTools?: ACPToolCall[]; -} +import type { + DaemonMessage, + DaemonMessageToolCall, + DaemonMessageToolCallContent, + DaemonMessageToolCallStatus, + DaemonMessageToolKind, + DaemonMessageToolCallLocation, + DaemonMessageTodoItem, + DaemonStreamingState, +} from '@qwen-code/webui/daemon-react-sdk'; + +export type Message = DaemonMessage; +export type ACPToolCall = DaemonMessageToolCall; +export type ToolCallContent = DaemonMessageToolCallContent; +export type ToolCallStatus = DaemonMessageToolCallStatus; +export type ToolKind = DaemonMessageToolKind; +export type ToolCallLocation = DaemonMessageToolCallLocation; +export type TodoItem = DaemonMessageTodoItem; +export type StreamingState = DaemonStreamingState; + +export type { + DaemonUserMessage as UserMessage, + DaemonAssistantMessage as AssistantMessage, + DaemonToolGroupMessage as ToolGroupMessage, + DaemonPlanMessage as PlanMessage, + DaemonSystemMessage as SystemMessage, +} from '@qwen-code/webui/daemon-react-sdk'; export interface ContentBlock { type: 'text' | 'image'; @@ -83,13 +55,6 @@ export interface PermissionRequest { kind?: string; } -export interface TodoItem { - id: string; - content: string; - status: 'pending' | 'in_progress' | 'completed'; - priority?: 'high' | 'medium' | 'low'; -} - export interface CommandInfo { name: string; description: string; @@ -101,44 +66,3 @@ export interface ModelInfo { id: string; label?: string; } - -export interface UserMessage { - id: string; - role: 'user'; - content: string; - turnIndex?: number; -} - -export interface AssistantMessage { - id: string; - role: 'assistant'; - content: string; - thinking?: string; - isStreaming?: boolean; -} - -export interface ToolGroupMessage { - id: string; - role: 'tool_group'; - tools: ACPToolCall[]; -} - -export interface PlanMessage { - id: string; - role: 'plan'; - todos: TodoItem[]; -} - -export interface SystemMessage { - id: string; - role: 'system'; - content: string; - variant: 'info' | 'error' | 'warning'; -} - -export type Message = - | UserMessage - | AssistantMessage - | ToolGroupMessage - | PlanMessage - | SystemMessage; diff --git a/packages/web-shell/client/build-artifact.test.ts b/packages/web-shell/client/build-artifact.test.ts new file mode 100644 index 00000000000..18f190597d8 --- /dev/null +++ b/packages/web-shell/client/build-artifact.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; + +const DIST_PATH = resolve(__dirname, '../dist/index.js'); + +function readBundle(): string { + return readFileSync(DIST_PATH, 'utf8'); +} + +describe('build artifact — package boundary', () => { + it('externalizes @qwen-code/webui/daemon-react-sdk', () => { + const bundle = readBundle(); + expect(bundle).toContain('from "@qwen-code/webui/daemon-react-sdk"'); + }); + + it('does not inline DaemonSessionProvider source code', () => { + const bundle = readBundle(); + expect(bundle).not.toMatch(/DaemonStoreContext\s*=\s*createContext/); + }); + + it('does not inline createContext from React for provider contexts', () => { + const bundle = readBundle(); + const contextMatches = bundle.match(/createContext\(/g) ?? []; + // WebShell's own ThemeContext is fine; but there should be at most + // a small number of createContext calls (WebShell internal only). + // If webui Provider got bundled, we'd see many more. + expect(contextMatches.length).toBeLessThanOrEqual(3); + }); + + it('externalizes react and react-dom', () => { + const bundle = readBundle(); + expect(bundle).toContain('from "react"'); + expect(bundle).toContain('from "react/jsx-runtime"'); + }); + + it('externalizes @qwen-code/sdk subpaths', () => { + const bundle = readBundle(); + // Should not contain raw SDK implementation + expect(bundle).not.toMatch(/DaemonSessionClient\s*\{/); + }); +}); diff --git a/packages/web-shell/client/completions/atCompletion.ts b/packages/web-shell/client/completions/atCompletion.ts index 9f7fae7f9fe..d9339c637d4 100644 --- a/packages/web-shell/client/completions/atCompletion.ts +++ b/packages/web-shell/client/completions/atCompletion.ts @@ -2,24 +2,23 @@ import type { CompletionContext, CompletionResult, } from '@codemirror/autocomplete'; -import { getDaemonAuthHeaders, getDaemonBaseUrl } from '../config/daemon'; -export interface AtCompletionOptions { - baseUrl?: string; - token?: string; -} +export type GlobFn = ( + pattern: string, + opts?: { maxResults?: number }, +) => Promise<{ matches: string[] }>; export function createAtCompletionSource( - opts: AtCompletionOptions = {}, + getGlob: () => GlobFn | undefined, ): ( context: CompletionContext, ) => CompletionResult | null | Promise { - return (context) => atCompletionSource(context, opts); + return (context) => atCompletionSource(context, getGlob); } export function atCompletionSource( context: CompletionContext, - opts: AtCompletionOptions = {}, + getGlob: () => GlobFn | undefined, ): CompletionResult | null | Promise { const line = context.state.doc.lineAt(context.pos); const textBefore = line.text.slice(0, context.pos - line.from); @@ -27,10 +26,13 @@ export function atCompletionSource( const match = textBefore.match(/@([\w./-]*)$/); if (!match) return null; + const glob = getGlob(); + if (!glob) return null; + const prefix = match[1]; const atPos = context.pos - match[0].length; - return fetchFiles(prefix, opts).then((files) => { + return fetchFiles(prefix, glob).then((files) => { if (files.length === 0) return null; return { from: atPos, @@ -43,26 +45,11 @@ export function atCompletionSource( }); } -async function fetchFiles( - prefix: string, - opts: AtCompletionOptions, -): Promise { +async function fetchFiles(prefix: string, glob: GlobFn): Promise { try { const pattern = prefix ? `${prefix}*` : '**/*'; - const base = opts.baseUrl || getDaemonBaseUrl() || window.location.origin; - const headers: HeadersInit = opts.token - ? { Authorization: `Bearer ${opts.token}` } - : (getDaemonAuthHeaders() ?? {}); - const res = await fetch( - `${base}/glob?pattern=${encodeURIComponent(pattern)}&maxResults=50`, - { headers }, - ); - if (!res.ok) return []; - const data = (await res.json()) as { matches?: unknown[] }; - const matches = Array.isArray(data.matches) ? data.matches : []; - return matches - .filter((file): file is string => typeof file === 'string') - .filter((file) => file !== '.'); + const result = await glob(pattern, { maxResults: 50 }); + return result.matches.filter((file) => file !== '.'); } catch { return []; } diff --git a/packages/web-shell/client/completions/slashCompletion.ts b/packages/web-shell/client/completions/slashCompletion.ts index 0af1823bd8b..2a3106a9031 100644 --- a/packages/web-shell/client/completions/slashCompletion.ts +++ b/packages/web-shell/client/completions/slashCompletion.ts @@ -3,7 +3,6 @@ import type { CompletionContext, CompletionResult, } from '@codemirror/autocomplete'; -import type { EditorView } from '@codemirror/view'; import type { CommandInfo } from '../adapters/types'; import type { WebShellLanguage } from '../i18n'; @@ -13,8 +12,6 @@ interface SubcommandNode { children?: SubcommandNode[]; } -type SubmitCompletionCommand = (view: EditorView, command: string) => void; - const SUBCOMMAND_TREE_ZH: Record = { agents: [ { name: 'manage', description: '管理现有 subagents' }, @@ -49,10 +46,6 @@ const SUBCOMMAND_TREE_ZH: Record = { { name: 'json', description: '将会话导出为 JSON 文件' }, { name: 'jsonl', description: '将会话导出为 JSONL 文件(每行一条消息)' }, ], - stats: [ - { name: 'model', description: '显示各模型的使用统计' }, - { name: 'tools', description: '显示工具调用统计' }, - ], language: [ { name: 'ui', @@ -100,10 +93,6 @@ const SUBCOMMAND_TREE_EN: Record = { { name: 'json', description: 'Export as JSON' }, { name: 'jsonl', description: 'Export as JSONL' }, ], - stats: [ - { name: 'model', description: 'Show model usage stats' }, - { name: 'tools', description: 'Show tool call stats' }, - ], language: [ { name: 'ui', @@ -140,18 +129,6 @@ function resolveSubcommands( return nodes; } -function commandHasSubcommands( - command: CommandInfo, - language: WebShellLanguage, -): boolean { - const tree = language === 'zh-CN' ? SUBCOMMAND_TREE_ZH : SUBCOMMAND_TREE_EN; - return !!tree[command.name] || !!command.subcommands?.length; -} - -function shouldSubmitSubcommand(node: SubcommandNode): boolean { - return !node.children; -} - function comparePrefixFirst(a: string, b: string, query: string): number { const aLower = a.toLowerCase(); const bLower = b.toLowerCase(); @@ -161,28 +138,9 @@ function comparePrefixFirst(a: string, b: string, query: string): number { return a.localeCompare(b); } -function applyAndSubmitCommand( - command: string, - submitCompletionCommand: SubmitCompletionCommand, -) { - return ( - view: EditorView, - _completion: Completion, - from: number, - to: number, - ) => { - view.dispatch({ - changes: { from, to, insert: command }, - selection: { anchor: command.length }, - }); - submitCompletionCommand(view, command); - }; -} - export function slashCompletionSource( getCommands: () => CommandInfo[], getSkills: () => string[] = () => [], - submitCompletionCommand?: SubmitCompletionCommand, getLanguage: () => WebShellLanguage = () => 'en', ) { return (context: CompletionContext): CompletionResult | null => { @@ -223,14 +181,10 @@ export function slashCompletionSource( ) .map((n): Completion => { const command = `${prefix}${n.name}`; - const submitOnApply = shouldSubmitSubcommand(n); return { label: n.name, detail: n.description || undefined, - apply: - submitOnApply && submitCompletionCommand - ? applyAndSubmitCommand(command, submitCompletionCommand) - : `${command}${n.children || submitOnApply ? ' ' : ''}`, + apply: `${command} `, }; }); @@ -259,14 +213,10 @@ export function slashCompletionSource( .sort((a, b) => (prefix ? comparePrefixFirst(a.name, b.name, lp) : 0)) .map((c): Completion => { const command = `/${c.name}`; - const hasSubcommands = commandHasSubcommands(c, getLanguage()); return { label: command, detail: c.description || undefined, - apply: - !hasSubcommands && submitCompletionCommand - ? applyAndSubmitCommand(command, submitCompletionCommand) - : `${command}${hasSubcommands ? ' ' : ''}`, + apply: `${command} `, }; }); diff --git a/packages/web-shell/client/components/Editor.module.css b/packages/web-shell/client/components/Editor.module.css index a4edc187bfa..ed8e2c03dc8 100644 --- a/packages/web-shell/client/components/Editor.module.css +++ b/packages/web-shell/client/components/Editor.module.css @@ -5,7 +5,7 @@ .borderTop, .borderBottom { height: 1px; - background: var(--border-color); + background: var(--text-secondary); } .line { @@ -27,6 +27,14 @@ color: var(--warning-color); } +.prefixAutoEdit { + color: var(--warning-color); +} + +.prefixYolo { + color: var(--error-color); +} + .shellMode .borderTop, .shellMode .borderBottom { background: var(--warning-color); diff --git a/packages/web-shell/client/components/Editor.tsx b/packages/web-shell/client/components/Editor.tsx index 15de6062055..0b0bd5b146b 100644 --- a/packages/web-shell/client/components/Editor.tsx +++ b/packages/web-shell/client/components/Editor.tsx @@ -1,14 +1,16 @@ -import { useEffect, useRef, useCallback, useState } from 'react'; import { - EditorView, - keymap, - placeholder, - ViewPlugin, - ViewUpdate, -} from '@codemirror/view'; + forwardRef, + useEffect, + useImperativeHandle, + useRef, + useCallback, + useState, +} from 'react'; +import { EditorView, keymap, placeholder } from '@codemirror/view'; import { EditorState, Compartment, Prec } from '@codemirror/state'; import { defaultKeymap, history, historyKeymap } from '@codemirror/commands'; import { + acceptCompletion, autocompletion, completionStatus, startCompletion, @@ -17,6 +19,10 @@ import { import { minimalSetup } from 'codemirror'; import type { CommandInfo } from '../adapters/types'; import type { PromptImage } from '../adapters/promptTypes'; +import { + useOptionalWorkspace, + type UseDaemonFollowupSuggestionReturn, +} from '@qwen-code/webui/daemon-react-sdk'; import { slashCompletionSource } from '../completions/slashCompletion'; import { createAtCompletionSource } from '../completions/atCompletion'; import { useInputHistory } from '../hooks/useInputHistory'; @@ -26,6 +32,7 @@ import { inputHighlightTheme, } from '../extensions/inputHighlight'; import { isEditableTarget } from '../utils/dom'; +import { PromptChevron } from './PromptChevron'; import styles from './Editor.module.css'; interface EditorProps { @@ -39,12 +46,20 @@ interface EditorProps { queuedMessages?: string[]; onPopQueuedMessages?: () => string | null; onClearQueuedMessages?: () => boolean; - prefix?: string; currentMode?: string; draftText?: string; draftVersion?: number; - daemonBaseUrl?: string; - daemonToken?: string; + onFocusActiveAgents?: () => boolean; + dialogOpen?: boolean; + followupState?: UseDaemonFollowupSuggestionReturn['followupState']; + onAcceptFollowup?: UseDaemonFollowupSuggestionReturn['onAcceptFollowup']; + onDismissFollowup?: UseDaemonFollowupSuggestionReturn['onDismissFollowup']; +} + +export interface EditorHandle { + blur(): void; + focus(): void; + insertText(text: string): void; } const editableCompartment = new Compartment(); @@ -64,24 +79,30 @@ function getModeClass(mode: string, shellMode: boolean): string { } } -export function Editor({ - onSubmit, - onCycleMode, - onToggleShortcuts, - disabled = false, - placeholderText = 'Type a message...', - commands, - skills = [], - queuedMessages = [], - onPopQueuedMessages, - onClearQueuedMessages, - prefix = '>', - currentMode = 'default', - draftText, - draftVersion, - daemonBaseUrl, - daemonToken, -}: EditorProps) { +export const Editor = forwardRef(function Editor( + { + onSubmit, + onCycleMode, + onToggleShortcuts, + disabled = false, + placeholderText = 'Type a message...', + commands, + skills = [], + queuedMessages = [], + onPopQueuedMessages, + onClearQueuedMessages, + currentMode = 'default', + draftText, + draftVersion, + onFocusActiveAgents, + dialogOpen = false, + followupState, + onAcceptFollowup, + onDismissFollowup, + }, + ref, +) { + const workspace = useOptionalWorkspace(); const { language, t } = useI18n(); const containerRef = useRef(null); const viewRef = useRef(null); @@ -103,13 +124,21 @@ export function Editor({ onPopQueuedMessagesRef.current = onPopQueuedMessages; const onClearQueuedMessagesRef = useRef(onClearQueuedMessages); onClearQueuedMessagesRef.current = onClearQueuedMessages; + const followupStateRef = useRef(followupState); + followupStateRef.current = followupState; + const onAcceptFollowupRef = useRef(onAcceptFollowup); + onAcceptFollowupRef.current = onAcceptFollowup; + const onDismissFollowupRef = useRef(onDismissFollowup); + onDismissFollowupRef.current = onDismissFollowup; + const onFocusActiveAgentsRef = useRef(onFocusActiveAgents); + onFocusActiveAgentsRef.current = onFocusActiveAgents; const languageRef = useRef(language); languageRef.current = language; - const daemonBaseUrlRef = useRef(daemonBaseUrl); - daemonBaseUrlRef.current = daemonBaseUrl; - const daemonTokenRef = useRef(daemonToken); - daemonTokenRef.current = daemonToken; + const workspaceActionsRef = useRef(workspace?.actions); + workspaceActionsRef.current = workspace?.actions; const [shellMode, setShellMode] = useState(false); + const shellModeRef = useRef(shellMode); + shellModeRef.current = shellMode; const [searchMode, setSearchMode] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [searchMatches, setSearchMatches] = useState([]); @@ -119,6 +148,9 @@ export function Editor({ const [pastedImages, setPastedImages] = useState([]); const pastedImagesRef = useRef([]); + const promptHistory = useInputHistory(); + const shellHistory = useInputHistory('qwen-web-shell-command-history'); + const { push, navigateUp, @@ -126,7 +158,7 @@ export function Editor({ reset, getReverseMatches, resetSearch, - } = useInputHistory(); + } = promptHistory; const historyActionsRef = useRef({ push, navigateUp, @@ -143,6 +175,8 @@ export function Editor({ getReverseMatches, resetSearch, }; + const shellHistoryActionsRef = useRef(shellHistory); + shellHistoryActionsRef.current = shellHistory; pastedImagesRef.current = pastedImages; useEffect(() => { @@ -152,13 +186,20 @@ export function Editor({ const text = (textOverride ?? view.state.doc.toString()).trim(); if (!text) return true; const images = pastedImagesRef.current; + const isShellMode = shellModeRef.current; const accepted = onSubmitRef.current( - text, + isShellMode ? `!${text}` : text, images.length > 0 ? [...images] : undefined, ); if (accepted === false) return true; - historyActionsRef.current.push(text); - historyActionsRef.current.reset(); + onDismissFollowupRef.current?.(); + if (isShellMode) { + shellHistoryActionsRef.current.push(text); + shellHistoryActionsRef.current.reset(); + } else { + historyActionsRef.current.push(text); + historyActionsRef.current.reset(); + } setPastedImages([]); view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: '' }, @@ -170,17 +211,11 @@ export function Editor({ slashCompletionSource( () => commandsRef.current, () => skillsRef.current, - submitText, () => languageRef.current, ), - createAtCompletionSource({ - get baseUrl() { - return daemonBaseUrlRef.current; - }, - get token() { - return daemonTokenRef.current; - }, - }), + createAtCompletionSource( + () => workspaceActionsRef.current?.globWorkspace, + ), ]; const submitKeymap = keymap.of([ @@ -188,6 +223,15 @@ export function Editor({ key: 'Enter', run: (view) => { if (completionStatus(view.state) === 'active') return false; + const followup = followupStateRef.current; + if ( + view.state.doc.toString().length === 0 && + followup?.isVisible && + followup.suggestion + ) { + onAcceptFollowupRef.current?.('enter', { skipOnAccept: true }); + return submitText(view, followup.suggestion); + } return submitText(view); }, }, @@ -198,6 +242,10 @@ export function Editor({ { key: 'Escape', run: () => { + if (shellModeRef.current) { + setShellMode(false); + return true; + } if (queuedMessagesRef.current.length === 0) return false; return onClearQueuedMessagesRef.current?.() ?? false; }, @@ -211,6 +259,16 @@ export function Editor({ run: (view) => { if (completionStatus(view.state) === 'active') return false; if (view.state.doc.lines > 1) return false; + if (shellModeRef.current) { + const current = view.state.doc.toString(); + const prev = shellHistoryActionsRef.current.navigateUp(current); + if (prev === null) return true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: prev }, + selection: { anchor: prev.length }, + }); + return true; + } if (queuedMessagesRef.current.length > 0) { const queuedText = onPopQueuedMessagesRef.current?.(); if (queuedText) { @@ -240,8 +298,19 @@ export function Editor({ run: (view) => { if (completionStatus(view.state) === 'active') return false; if (view.state.doc.lines > 1) return false; + if (shellModeRef.current) { + const next = shellHistoryActionsRef.current.navigateDown(); + if (next === null) return true; + view.dispatch({ + changes: { from: 0, to: view.state.doc.length, insert: next }, + selection: { anchor: next.length }, + }); + return true; + } const next = historyActionsRef.current.navigateDown(); - if (next === null) return false; + if (next === null) { + return onFocusActiveAgentsRef.current?.() ?? false; + } view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: next }, selection: { anchor: next.length }, @@ -256,13 +325,50 @@ export function Editor({ searchDraftRef.current = query; setSearchMode(true); setSearchQuery(query); - setSearchMatches(historyActionsRef.current.getReverseMatches(query)); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + setSearchMatches(history.getReverseMatches(query)); setSearchActiveIndex(0); - historyActionsRef.current.resetSearch(); + history.resetSearch(); setTimeout(() => searchInputRef.current?.focus(), 0); return true; }, }, + { + key: 'Tab', + run: (view) => { + if (completionStatus(view.state) === 'active') { + return acceptCompletion(view); + } + const followup = followupStateRef.current; + if ( + view.state.doc.toString().length === 0 && + followup?.isVisible && + followup.suggestion + ) { + onAcceptFollowupRef.current?.('tab'); + return true; + } + return acceptCompletion(view); + }, + }, + { + key: 'ArrowRight', + run: (view) => { + const followup = followupStateRef.current; + if ( + completionStatus(view.state) !== 'active' && + view.state.doc.toString().length === 0 && + followup?.isVisible && + followup.suggestion + ) { + onAcceptFollowupRef.current?.('right'); + return true; + } + return false; + }, + }, { key: 'Shift-Tab', run: () => { @@ -272,17 +378,6 @@ export function Editor({ }, ]); - const shellModeDetector = ViewPlugin.fromClass( - class { - update(update: ViewUpdate) { - if (update.docChanged) { - const text = update.state.doc.toString(); - setShellMode(text.startsWith('!')); - } - } - }, - ); - const slashCompletionRestarter = EditorView.updateListener.of((update) => { if (!update.docChanged || completionStatus(update.state) === 'active') { return; @@ -333,9 +428,23 @@ export function Editor({ editableCompartment.of(EditorView.editable.of(true)), inputHighlight, inputHighlightTheme, - shellModeDetector, slashCompletionRestarter, EditorView.inputHandler.of((view, from, to, insert) => { + if ( + insert.length > 0 && + view.state.doc.toString() === '' && + followupStateRef.current?.isVisible + ) { + onDismissFollowupRef.current?.(); + } + if ( + insert === '!' && + view.state.doc.toString() === '' && + completionStatus(view.state) !== 'active' + ) { + setShellMode((value) => !value); + return true; + } if ( insert === '?' && view.state.doc.toString() === '' && @@ -478,10 +587,15 @@ export function Editor({ useEffect(() => { const view = viewRef.current; if (!view) return; + const followupSuggestion = + followupState?.isVisible && followupState.suggestion + ? followupState.suggestion + : null; + const nextPlaceholder = followupSuggestion ?? placeholderText; view.dispatch({ - effects: placeholderCompartment.reconfigure(placeholder(placeholderText)), + effects: placeholderCompartment.reconfigure(placeholder(nextPlaceholder)), }); - }, [placeholderText]); + }, [placeholderText, followupState?.isVisible, followupState?.suggestion]); useEffect(() => { const view = viewRef.current; @@ -493,18 +607,70 @@ export function Editor({ view.focus(); }, [draftText, draftVersion]); + useEffect(() => { + const view = viewRef.current; + if (!view) return; + if (dialogOpen) { + view.contentDOM.blur(); + } else { + view.focus(); + } + }, [dialogOpen]); + useEffect(() => { const handler = (event: KeyboardEvent) => { - if (disabledRef.current || searchMode) return; + if (disabledRef.current || searchMode || dialogOpen) return; if (event.defaultPrevented) return; + const view = viewRef.current; + const followup = followupStateRef.current; + if ( + view && + !view.hasFocus && + followup?.isVisible && + followup.suggestion && + view.state.doc.toString().length === 0 && + !isEditableTarget(event.target) + ) { + if ( + event.key === 'Tab' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + completionStatus(view.state) !== 'active' + ) { + event.preventDefault(); + onAcceptFollowupRef.current?.('tab'); + return; + } + if ( + event.key === 'ArrowRight' && + !event.shiftKey && + !event.metaKey && + !event.ctrlKey && + !event.altKey && + completionStatus(view.state) !== 'active' + ) { + event.preventDefault(); + onAcceptFollowupRef.current?.('right'); + return; + } + } if (event.metaKey || event.ctrlKey || event.altKey) return; if (event.key.length !== 1) return; if (isEditableTarget(event.target)) return; - const view = viewRef.current; if (!view || view.hasFocus) return; event.preventDefault(); + if (event.key === '!' && view.state.doc.toString() === '') { + if (followupStateRef.current?.isVisible) { + onDismissFollowupRef.current?.(); + } + setShellMode((value) => !value); + view.focus(); + return; + } const selection = view.state.selection.main; view.dispatch({ changes: { from: selection.from, to: selection.to, insert: event.key }, @@ -524,12 +690,49 @@ export function Editor({ window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); - }, [searchMode]); + }, [searchMode, dialogOpen]); const focus = useCallback(() => { viewRef.current?.focus(); }, []); + const blur = useCallback(() => { + viewRef.current?.contentDOM.blur(); + }, []); + + const insertText = useCallback((text: string) => { + const view = viewRef.current; + if (!view || !text) { + view?.focus(); + return; + } + const selection = view.state.selection.main; + view.dispatch({ + changes: { from: selection.from, to: selection.to, insert: text }, + selection: { anchor: selection.from + text.length }, + scrollIntoView: true, + }); + view.focus(); + if (text === '/' || text === '@') { + window.setTimeout(() => { + const nextView = viewRef.current; + if (nextView && nextView.hasFocus) { + startCompletion(nextView); + } + }, 0); + } + }, []); + + useImperativeHandle( + ref, + () => ({ + blur, + focus, + insertText, + }), + [blur, focus, insertText], + ); + const replaceEditorText = useCallback((text: string) => { const view = viewRef.current; if (!view) return; @@ -549,7 +752,10 @@ export function Editor({ setSearchQuery(''); setSearchMatches([]); setSearchActiveIndex(0); - historyActionsRef.current.resetSearch(); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + history.resetSearch(); viewRef.current?.focus(); }, [replaceEditorText], @@ -563,16 +769,23 @@ export function Editor({ const text = match.trim(); if (!text) return; const images = pastedImagesRef.current; + const isShellMode = shellModeRef.current; const accepted = onSubmitRef.current( - text, + isShellMode ? `!${text}` : text, images.length > 0 ? [...images] : undefined, ); if (accepted === false) { replaceEditorText(match); return; } - historyActionsRef.current.push(text); - historyActionsRef.current.reset(); + onDismissFollowupRef.current?.(); + if (isShellMode) { + shellHistoryActionsRef.current.push(text); + shellHistoryActionsRef.current.reset(); + } else { + historyActionsRef.current.push(text); + historyActionsRef.current.reset(); + } setPastedImages([]); replaceEditorText(''); }, @@ -621,9 +834,12 @@ export function Editor({ const handleSearchInput = (e: React.ChangeEvent) => { const q = e.target.value; setSearchQuery(q); - setSearchMatches(historyActionsRef.current.getReverseMatches(q)); + const history = shellModeRef.current + ? shellHistoryActionsRef.current + : historyActionsRef.current; + setSearchMatches(history.getReverseMatches(q)); setSearchActiveIndex(0); - historyActionsRef.current.resetSearch(); + history.resetSearch(); }; const modeClass = getModeClass(currentMode, shellMode); @@ -642,6 +858,25 @@ export function Editor({ visibleSearchStart, visibleSearchStart + 6, ); + const prefixClass = [ + styles.prefix, + shellMode + ? styles.prefixShell + : currentMode === 'yolo' + ? styles.prefixYolo + : currentMode === 'auto-edit' + ? styles.prefixAutoEdit + : '', + ] + .filter(Boolean) + .join(' '); + const prefixContent = shellMode ? ( + '!' + ) : currentMode === 'yolo' ? ( + '*' + ) : ( + + ); return (
@@ -712,14 +947,10 @@ export function Editor({
)}
- - {shellMode ? '!' : prefix} - + {prefixContent}
); -} +}); diff --git a/packages/web-shell/client/components/MessageList.test.ts b/packages/web-shell/client/components/MessageList.test.ts new file mode 100644 index 00000000000..2ae985b6454 --- /dev/null +++ b/packages/web-shell/client/components/MessageList.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from 'vitest'; +import type { Message } from '../adapters/types'; +import { groupParallelAgents } from './MessageList'; + +function makeAgentToolGroup(id: string, toolName = 'Agent'): Message { + return { + id, + role: 'tool_group', + tools: [ + { + callId: `call-${id}`, + toolName, + status: 'completed', + args: { description: `task ${id}` }, + }, + ], + }; +} + +function makeMultiToolGroup(id: string): Message { + return { + id, + role: 'tool_group', + tools: [ + { callId: `call-${id}-a`, toolName: 'Read', status: 'completed' }, + { callId: `call-${id}-b`, toolName: 'Write', status: 'completed' }, + ], + }; +} + +function makeUserMessage(id: string): Message { + return { id, role: 'user', content: 'hello' }; +} + +function makeAssistantMessage(id: string): Message { + return { id, role: 'assistant', content: 'response' }; +} + +describe('groupParallelAgents', () => { + it('returns empty array for empty input', () => { + expect(groupParallelAgents([])).toEqual([]); + }); + + it('does not group a single agent tool_group', () => { + const msgs = [makeAgentToolGroup('1')]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('message'); + }); + + it('groups 2+ consecutive agent-only tool_groups', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeAgentToolGroup('2'), + makeAgentToolGroup('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + if (items[0].type === 'parallel_agents') { + expect(items[0].agents).toHaveLength(3); + expect(items[0].agents[0].callId).toBe('call-1'); + expect(items[0].agents[2].callId).toBe('call-3'); + } + }); + + it('non-agent message breaks the group', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeAgentToolGroup('2'), + makeAssistantMessage('3'), + makeAgentToolGroup('4'), + makeAgentToolGroup('5'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items[0].type).toBe('parallel_agents'); + expect(items[1].type).toBe('message'); + expect(items[2].type).toBe('parallel_agents'); + }); + + it('multi-tool tool_group is not grouped as agent', () => { + const msgs = [ + makeAgentToolGroup('1'), + makeMultiToolGroup('2'), + makeAgentToolGroup('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('non-agent tool names are not grouped', () => { + const msgs: Message[] = [ + { + id: '1', + role: 'tool_group', + tools: [{ callId: 'c1', toolName: 'Read', status: 'completed' }], + }, + { + id: '2', + role: 'tool_group', + tools: [{ callId: 'c2', toolName: 'Write', status: 'completed' }], + }, + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(2); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('preserves non-tool_group messages as-is', () => { + const msgs = [ + makeUserMessage('1'), + makeAssistantMessage('2'), + makeUserMessage('3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(3); + expect(items.every((i) => i.type === 'message')).toBe(true); + }); + + it('groups Task tool calls as sub-agents', () => { + const msgs: Message[] = [ + { + id: '1', + role: 'tool_group', + tools: [{ callId: 'c1', toolName: 'Task', status: 'in_progress' }], + }, + { + id: '2', + role: 'tool_group', + tools: [{ callId: 'c2', toolName: 'Task', status: 'completed' }], + }, + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(1); + expect(items[0].type).toBe('parallel_agents'); + }); + + it('mixed agent and user messages produce correct order', () => { + const msgs = [ + makeUserMessage('u1'), + makeAgentToolGroup('a1'), + makeAgentToolGroup('a2'), + makeAssistantMessage('r1'), + makeAgentToolGroup('a3'), + ]; + const items = groupParallelAgents(msgs); + expect(items).toHaveLength(4); + expect(items[0].type).toBe('message'); + expect(items[1].type).toBe('parallel_agents'); + expect(items[2].type).toBe('message'); + expect(items[3].type).toBe('message'); + }); +}); diff --git a/packages/web-shell/client/components/MessageList.tsx b/packages/web-shell/client/components/MessageList.tsx index b3d8492a000..43d44940057 100644 --- a/packages/web-shell/client/components/MessageList.tsx +++ b/packages/web-shell/client/components/MessageList.tsx @@ -1,7 +1,9 @@ -import { useEffect, useRef, useCallback, type ReactNode } from 'react'; -import type { Message } from '../adapters/types'; +import { useEffect, useRef, useCallback, useMemo, type ReactNode } from 'react'; +import type { Message, ACPToolCall } from '../adapters/types'; import type { PermissionRequest } from '../adapters/types'; +import { isSubAgentToolCall } from '../adapters/toolClassification'; import { MessageItem } from './MessageItem'; +import { ParallelAgentsGroup } from './messages/tools/ParallelAgentsGroup'; import { ToolApproval } from './messages/ToolApproval'; import { AskUserQuestion } from './messages/AskUserQuestion'; import styles from './MessageList.module.css'; @@ -45,6 +47,51 @@ function getLastUserMessageId(messages: Message[]): string | null { return null; } +export type DisplayItem = + | { type: 'message'; key: string; message: Message } + | { type: 'parallel_agents'; key: string; agents: ACPToolCall[] }; + +function isAgentOnlyToolGroup(msg: Message): boolean { + return ( + msg.role === 'tool_group' && + msg.tools.length === 1 && + isSubAgentToolCall(msg.tools[0]) + ); +} + +export function groupParallelAgents(messages: Message[]): DisplayItem[] { + const items: DisplayItem[] = []; + let i = 0; + while (i < messages.length) { + if (isAgentOnlyToolGroup(messages[i])) { + const start = i; + while (i < messages.length && isAgentOnlyToolGroup(messages[i])) i++; + if (i - start >= 2) { + const grouped = messages.slice(start, i); + items.push({ + type: 'parallel_agents', + key: `par-${grouped[0].id}`, + agents: grouped.map((m) => (m as { tools: ACPToolCall[] }).tools[0]), + }); + } else { + items.push({ + type: 'message', + key: messages[start].id, + message: messages[start], + }); + } + } else { + items.push({ + type: 'message', + key: messages[i].id, + message: messages[i], + }); + i++; + } + } + return items; +} + export function MessageList({ messages, pendingApproval, @@ -52,6 +99,7 @@ export function MessageList({ forceScrollToBottom, welcomeHeader, }: MessageListProps) { + const displayItems = useMemo(() => groupParallelAgents(messages), [messages]); const containerRef = useRef(null); const shouldAutoScroll = useRef(true); const prevMsgCount = useRef(messages.length); @@ -104,14 +152,23 @@ export function MessageList({
{welcomeHeader} - {messages.map((msg) => ( - - ))} + {displayItems.map((item) => + item.type === 'parallel_agents' ? ( + + ) : ( + + ), + )} {pendingApproval && isAskUserQuestion(pendingApproval) && ( diff --git a/packages/web-shell/client/components/PromptChevron.tsx b/packages/web-shell/client/components/PromptChevron.tsx new file mode 100644 index 00000000000..6acdccac748 --- /dev/null +++ b/packages/web-shell/client/components/PromptChevron.tsx @@ -0,0 +1,28 @@ +import type { CSSProperties } from 'react'; + +interface PromptChevronProps { + className?: string; + style?: CSSProperties; +} + +export function PromptChevron({ className, style }: PromptChevronProps) { + return ( + + ); +} diff --git a/packages/web-shell/client/components/StatusBar.tsx b/packages/web-shell/client/components/StatusBar.tsx index 609d8da1e7c..41dc6b51888 100644 --- a/packages/web-shell/client/components/StatusBar.tsx +++ b/packages/web-shell/client/components/StatusBar.tsx @@ -1,15 +1,7 @@ +import { useConnection } from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../i18n'; import styles from './StatusBar.module.css'; -interface StatusBarProps { - connected: boolean; - streamingState: 'idle' | 'waiting' | 'responding' | 'thinking'; - currentModel: string; - currentMode: string; - tokenCount: number; - contextWindow: number; -} - function getModeIndicator( mode: string, t: ReturnType['t'], @@ -26,13 +18,13 @@ function getModeIndicator( } } -export function StatusBar({ - connected, - currentModel, - currentMode, - tokenCount, - contextWindow, -}: StatusBarProps) { +export function StatusBar() { + const connection = useConnection(); + const connected = connection.status === 'connected'; + const currentModel = connection.currentModel ?? ''; + const currentMode = connection.currentMode ?? ''; + const tokenCount = connection.tokenCount ?? 0; + const contextWindow = connection.contextWindow ?? 0; const { t } = useI18n(); const pct = contextWindow > 0 ? (tokenCount / contextWindow) * 100 : 0; const pctDisplay = pct.toFixed(1); diff --git a/packages/web-shell/client/components/StreamingStatus.tsx b/packages/web-shell/client/components/StreamingStatus.tsx index 951f107f91e..ebf52b7738b 100644 --- a/packages/web-shell/client/components/StreamingStatus.tsx +++ b/packages/web-shell/client/components/StreamingStatus.tsx @@ -3,18 +3,17 @@ import { PHRASE_CHANGE_INTERVAL_MS, getLoadingPhrases, } from '../constants/loadingPhrases'; +import { + useConnection, + useStreamingState, +} from '@qwen-code/webui/daemon-react-sdk'; import { useI18n } from '../i18n'; import styles from './StreamingStatus.module.css'; -interface StreamingStatusProps { - streamingState: 'idle' | 'waiting' | 'responding' | 'thinking'; - tokenCount: number; -} - -export function StreamingStatus({ - streamingState, - tokenCount, -}: StreamingStatusProps) { +export function StreamingStatus() { + const streamingState = useStreamingState(); + const connection = useConnection(); + const tokenCount = connection.tokenCount ?? 0; const { language, t } = useI18n(); const [elapsed, setElapsed] = useState(0); const startTime = useRef(Date.now()); diff --git a/packages/web-shell/client/components/dialogs/AgentsDialog.tsx b/packages/web-shell/client/components/dialogs/AgentsDialog.tsx index 635ab60944f..cdcf01408e6 100644 --- a/packages/web-shell/client/components/dialogs/AgentsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/AgentsDialog.tsx @@ -7,13 +7,11 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { dp } from './dialogStyles'; -import type { - DaemonAgentMutationResult, - DaemonCreateAgentRequest, - DaemonWorkspaceAgentDetail, - DaemonWorkspaceAgentSummary, - DaemonWorkspaceAgentsStatus, -} from '@qwen-code/sdk/daemon'; +import { + useAgents, + type DaemonWorkspaceAgentDetail, + type DaemonWorkspaceAgentSummary, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; @@ -26,15 +24,6 @@ export type AgentsDialogInitialMode = interface AgentsDialogProps { initialMode?: AgentsDialogInitialMode; - listAgents: () => Promise; - getAgent: (agentType: string) => Promise; - createAgent: ( - req: DaemonCreateAgentRequest, - ) => Promise; - deleteAgent: ( - agentType: string, - scope?: 'workspace' | 'global', - ) => Promise; onClose: () => void; } @@ -44,6 +33,14 @@ function scopeForLevel(level: string): 'workspace' | 'global' | undefined { return undefined; } +function canDeleteAgent(agent: DaemonWorkspaceAgentSummary): boolean { + return ( + scopeForLevel(agent.level) !== undefined && + !agent.isBuiltin && + agent.level !== 'extension' + ); +} + function initialDialogMode( mode: AgentsDialogInitialMode, ): 'menu' | 'create-scope' | 'create' | 'manage' { @@ -58,20 +55,23 @@ function initialScope(mode: AgentsDialogInitialMode): 'workspace' | 'global' { export function AgentsDialog({ initialMode = 'menu', - listAgents, - getAgent, - createAgent, - deleteAgent, onClose, }: AgentsDialogProps) { const { t } = useI18n(); + const { + agents, + loading, + error: agentsError, + reload, + getAgent, + createAgent, + deleteAgent, + } = useAgents({ autoLoad: true }); const [mode, setMode] = useState< 'menu' | 'create-scope' | 'create' | 'manage' >(() => initialDialogMode(initialMode)); - const [agents, setAgents] = useState([]); const [selectedIdx, setSelectedIdx] = useState(0); const [detail, setDetail] = useState(null); - const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [message, setMessage] = useState(null); const [name, setName] = useState(''); @@ -124,22 +124,9 @@ export function AgentsDialog({ [scope, t], ); - const reload = useCallback(() => { - setLoading(true); - listAgents() - .then((status) => { - setAgents(status.agents); - setMessage(null); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); - }, [listAgents]); - useEffect(() => { - reload(); - }, [reload]); + if (agentsError) setMessage(agentsError.message); + }, [agentsError]); useEffect(() => { if (mode !== 'manage') return; @@ -312,6 +299,13 @@ export function AgentsDialog({ {t('agent.count', { count: agents.length })} +
@@ -468,15 +462,15 @@ export function AgentsDialog({ {t('agent.tools')}: {detail.tools.join(', ')}
)} - + {canDeleteAgent(detail) && ( + + )} ) : (
diff --git a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx index 17a2b0542ab..ffa144ffc1c 100644 --- a/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ApprovalModeDialog.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { dp } from './dialogStyles'; -import { DAEMON_APPROVAL_MODES } from '@qwen-code/sdk/daemon'; +import { DAEMON_APPROVAL_MODES } from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; @@ -79,6 +79,13 @@ export function ApprovalModeDialog({ {approvalModes.find((m) => m.id === currentMode)?.label || currentMode} +
diff --git a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css index 294b2d2b29f..6bee345c211 100644 --- a/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css +++ b/packages/web-shell/client/components/dialogs/DialogPrimitives.module.css @@ -12,11 +12,32 @@ .resume-picker-header { display: flex; - align-items: baseline; + align-items: center; gap: 8px; padding: 8px 12px; } +.resume-picker-close { + margin-left: auto; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary); + color: var(--text-secondary); + cursor: pointer; + font-family: var(--font-mono); + font-size: 12px; + padding: 2px 8px; + flex-shrink: 0; +} + +.resume-picker-close:hover { + color: var(--text-primary); + border-color: var(--accent-color); +} + .resume-picker-title { font-weight: 700; color: var(--text-primary); diff --git a/packages/web-shell/client/components/dialogs/HelpDialog.tsx b/packages/web-shell/client/components/dialogs/HelpDialog.tsx index db7b95950ed..f3e52ca7d44 100644 --- a/packages/web-shell/client/components/dialogs/HelpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/HelpDialog.tsx @@ -307,6 +307,13 @@ export function HelpDialog({ commands, onClose }: HelpDialogProps) { {t('help.commandCount', { count: commands.length })} +
diff --git a/packages/web-shell/client/components/dialogs/McpDialog.tsx b/packages/web-shell/client/components/dialogs/McpDialog.tsx index b995543e8aa..60d5da093d1 100644 --- a/packages/web-shell/client/components/dialogs/McpDialog.tsx +++ b/packages/web-shell/client/components/dialogs/McpDialog.tsx @@ -1,21 +1,15 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { dp } from './dialogStyles'; -import type { - DaemonMcpRestartResult, - DaemonWorkspaceMcpServerStatus, - DaemonWorkspaceMcpStatus, -} from '@qwen-code/sdk/daemon'; +import { + useMcp, + type DaemonWorkspaceMcpServerStatus, + type DaemonWorkspaceMcpToolStatus, + type DaemonWorkspaceMcpToolsStatus, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; -import type { - WebShellMcpToolStatus, - WebShellMcpToolsStatus, -} from '../../hooks/useDaemonSession'; interface McpDialogProps { - loadStatus: () => Promise; - loadTools: (serverName: string) => Promise; - restartServer: (serverName: string) => Promise; onClose: () => void; } @@ -85,28 +79,24 @@ function schemaSummary( return lines; } -export function McpDialog({ - loadStatus, - loadTools, - restartServer, - onClose, -}: McpDialogProps) { +export function McpDialog({ onClose }: McpDialogProps) { const { t } = useI18n(); - const [status, setStatus] = useState(null); + const { status, loading, error, reload, loadTools, restartServer } = useMcp({ + autoLoad: true, + }); const [toolsByServer, setToolsByServer] = useState< - Record + Record >({}); const [view, setView] = useState('servers'); const [selectedIdx, setSelectedIdx] = useState(0); const [actionIdx, setActionIdx] = useState(0); const [toolIdx, setToolIdx] = useState(0); - const [loading, setLoading] = useState(true); const [loadingTools, setLoadingTools] = useState(null); const [busyServer, setBusyServer] = useState(null); const [message, setMessage] = useState(null); const listRef = useRef(null); - const servers = status?.servers ?? []; + const servers: DaemonWorkspaceMcpServerStatus[] = status?.servers ?? []; const selected = servers[selectedIdx]; const selectedTools = selected ? (toolsByServer[selected.name]?.tools ?? []) @@ -121,30 +111,19 @@ export function McpDialog({ setToolsByServer((cur) => ({ ...cur, [serverName]: next })); setMessage(next.errors?.[0]?.error ?? null); }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); + .catch((err: unknown) => { + setMessage(err instanceof Error ? err.message : String(err)); }) .finally(() => setLoadingTools(null)); }, [loadTools], ); - const reload = useCallback(() => { - setLoading(true); - loadStatus() - .then((next) => { - setStatus(next); - setMessage(null); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); - }, [loadStatus]); - useEffect(() => { - reload(); - }, [reload]); + if (error) setMessage(error.message); + else if (status?.errors?.[0]?.error) setMessage(status.errors[0].error); + else if (status) setMessage(null); + }, [status, error]); useEffect(() => { if (selectedIdx >= servers.length && servers.length > 0) { @@ -357,6 +336,13 @@ export function McpDialog({ : selected?.name} {budgetText} +
@@ -510,7 +496,7 @@ export function McpDialog({ ); } -function ToolDetail({ tool }: { tool: WebShellMcpToolStatus }) { +function ToolDetail({ tool }: { tool: DaemonWorkspaceMcpToolStatus }) { const { t } = useI18n(); return (
diff --git a/packages/web-shell/client/components/dialogs/MemoryDialog.tsx b/packages/web-shell/client/components/dialogs/MemoryDialog.tsx index 575d6dfb859..bd742f3ad23 100644 --- a/packages/web-shell/client/components/dialogs/MemoryDialog.tsx +++ b/packages/web-shell/client/components/dialogs/MemoryDialog.tsx @@ -7,14 +7,11 @@ import { type KeyboardEvent as ReactKeyboardEvent, } from 'react'; import { dp } from './dialogStyles'; -import type { - DaemonContextFileScope, - DaemonWorkspaceFile, - DaemonWorkspaceMemoryFile, - DaemonWorkspaceMemoryStatus, - DaemonWriteMemoryRequest, - DaemonWriteMemoryResult, -} from '@qwen-code/sdk/daemon'; +import { + useMemory, + type DaemonContextFileScope, + type DaemonWorkspaceMemoryFile, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; import styles from './MemoryDialog.module.css'; @@ -29,11 +26,6 @@ export type MemoryDialogInitialMode = interface MemoryDialogProps { initialMode?: MemoryDialogInitialMode; - loadStatus: () => Promise; - readFile: (filePath: string) => Promise; - writeMemory: ( - req: DaemonWriteMemoryRequest, - ) => Promise; onMessage?: (message: string, type?: 'status' | 'error') => void; onClose: () => void; } @@ -73,13 +65,18 @@ function scopeLabel( export function MemoryDialog({ initialMode = 'menu', - loadStatus, - readFile, - writeMemory, onMessage, onClose, }: MemoryDialogProps) { const { t } = useI18n(); + const { + status: memoryStatus, + loading: memoryLoading, + error: memoryError, + reload: reloadMemory, + readFile, + writeMemory, + } = useMemory({ autoLoad: true }); const scopes: ScopeItem[] = useMemo( () => [ { @@ -96,9 +93,6 @@ export function MemoryDialog({ [t], ); const [view, setView] = useState(() => initialView(initialMode)); - const [status, setStatus] = useState( - null, - ); const [selectedIdx, setSelectedIdx] = useState(0); const [fileIdx, setFileIdx] = useState(0); const [selectedFile, setSelectedFile] = @@ -112,7 +106,6 @@ export function MemoryDialog({ initialScope(initialMode), ); const [content, setContent] = useState(''); - const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [message, setMessage] = useState(null); const listRef = useRef(null); @@ -121,28 +114,23 @@ export function MemoryDialog({ initialMode === 'add-user' || initialMode === 'add-project'; const directMode = initialMode !== 'menu'; + const status = memoryStatus; + const loading = memoryLoading; + const files: DaemonWorkspaceMemoryFile[] = status?.files ?? []; + const reload = useCallback( - (successMessage?: string) => { - setLoading(true); - loadStatus() - .then((next) => { - setStatus(next); - setFileIdx((idx) => - Math.min(idx, Math.max(next.files.length - 1, 0)), - ); - setMessage(successMessage ?? null); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); + async (successMessage?: string) => { + await reloadMemory(); + if (successMessage) setMessage(successMessage); }, - [loadStatus], + [reloadMemory], ); useEffect(() => { - reload(initialMode === 'refresh' ? t('memory.refreshed') : undefined); - }, [initialMode, reload, t]); + if (memoryError) setMessage(memoryError.message); + else if (initialMode === 'refresh' && memoryStatus) + setMessage(t('memory.refreshed')); + }, [memoryError, memoryStatus, initialMode, t]); useEffect(() => { if (view === 'edit') { @@ -261,9 +249,7 @@ export function MemoryDialog({ Math.min(idx + 1, Math.max(scopes.length - 1, 0)), ); } else if (view === 'show') { - setFileIdx((idx) => - Math.min(idx + 1, Math.max((status?.files.length ?? 0) - 1, 0)), - ); + setFileIdx((idx) => Math.min(idx + 1, Math.max(files.length - 1, 0))); } return; } @@ -284,7 +270,7 @@ export function MemoryDialog({ setView('edit'); setMessage(null); } else if (view === 'show') { - const file = status?.files[fileIdx]; + const file = files[fileIdx]; if (file) openFile(file); } } @@ -295,11 +281,11 @@ export function MemoryDialog({ fileIdx, menuItems, onClose, + files, openFile, scopeIdx, scopes, selectedIdx, - status?.files, view, ], ); @@ -381,6 +367,13 @@ export function MemoryDialog({ ? `${status.fileCount} files · ${status.totalBytes} bytes` : ''} +
@@ -465,12 +458,12 @@ export function MemoryDialog({ {view === 'show' && (
- {!loading && status?.files.length === 0 && ( + {!loading && files.length === 0 && (
{t('memory.noFiles')}
)} - {status?.files.map((file, index) => ( + {files.map((file, index) => (
void; onClose: () => void; } export function ModelDialog({ mode = 'main', - currentModel, - availableModels, onSelect, onClose, }: ModelDialogProps) { + const connection = useConnection(); + const currentModel = connection.currentModel ?? ''; + const availableModels = connection.models ?? []; const { t } = useI18n(); const isFastMode = mode === 'fast'; const [selectedIdx, setSelectedIdx] = useState(() => { @@ -27,10 +26,7 @@ export function ModelDialog({ }); const [searchMode, setSearchMode] = useState(false); const [searchQuery, setSearchQuery] = useState(''); - const [customMode, setCustomMode] = useState(false); - const [customInput, setCustomInput] = useState(''); const listRef = useRef(null); - const customInputRef = useRef(null); const filtered = searchQuery ? availableModels.filter((m) => { @@ -55,12 +51,6 @@ export function ModelDialog({ el?.scrollIntoView({ block: 'nearest' }); }, [selectedIdx]); - useEffect(() => { - if (customMode) { - customInputRef.current?.focus(); - } - }, [customMode]); - const handleSelect = useCallback(() => { const model = filtered[selectedIdx]; if (model) { @@ -71,25 +61,6 @@ export function ModelDialog({ useDelayedGlobalKeyDown( (e: KeyboardEvent) => { - if (customMode) { - if (e.key === 'Escape') { - e.preventDefault(); - setCustomMode(false); - setCustomInput(''); - return; - } - if (e.key === 'Enter') { - e.preventDefault(); - const val = customInput.trim(); - if (val) { - onSelect(val); - onClose(); - } - return; - } - return; - } - if (searchMode) { if (e.key === 'Escape') { e.preventDefault(); @@ -152,23 +123,8 @@ export function ModelDialog({ setSearchMode(true); return; } - if (e.key === 'c' && !e.ctrlKey && !e.metaKey) { - e.preventDefault(); - setCustomMode(true); - return; - } }, - [ - searchMode, - searchQuery, - filtered, - selectedIdx, - onClose, - handleSelect, - customMode, - customInput, - onSelect, - ], + [searchMode, searchQuery, filtered, selectedIdx, onClose, handleSelect], ); return ( @@ -184,24 +140,17 @@ export function ModelDialog({ model: currentModel || t('model.unknown'), })} +
- {customMode ? ( - <> - - {t('model.custom')}:{' '} - - setCustomInput(e.target.value)} - autoFocus - placeholder={t('model.placeholder')} - /> - - ) : searchMode ? ( + {searchMode ? ( <> {t('common.search')}:{' '} @@ -228,7 +177,7 @@ export function ModelDialog({ ) : ( - {t('model.customHint')} + {t('model.searchHint')} )}
@@ -248,9 +197,7 @@ export function ModelDialog({ key={m.id} className={dp( 'resume-picker-item', - i === selectedIdx && !searchMode && !customMode - ? 'selected' - : undefined, + i === selectedIdx && !searchMode ? 'selected' : undefined, )} onClick={() => { onSelect(m.id); @@ -260,7 +207,7 @@ export function ModelDialog({ >
- {i === selectedIdx && !searchMode && !customMode ? '›' : ' '} + {i === selectedIdx && !searchMode ? '›' : ' '} {m.label || m.id} @@ -277,13 +224,11 @@ export function ModelDialog({
- {customMode - ? t('dialog.footer.confirmCancel') - : searchMode - ? t('dialog.footer.search') - : isFastMode - ? t('dialog.footer.modelFast') - : t('dialog.footer.navSelectCancel')} + {searchMode + ? t('dialog.footer.search') + : isFastMode + ? t('dialog.footer.modelFast') + : t('dialog.footer.navSelectCancel')}
); diff --git a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx index 89f865c6dec..c34fabcaf92 100644 --- a/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ReleaseSessionDialog.tsx @@ -1,5 +1,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { dp } from './dialogStyles'; +import { + useConnection, + useSessions, + type DaemonSessionSummary, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; @@ -18,36 +23,26 @@ function formatRelativeTime(iso: string, language: string): string { return new Date(iso).toLocaleDateString(); } -interface SessionInfo { - sessionId: string; - title?: string; - displayName?: string; - createdAt?: string; - updatedAt?: string; - clientCount?: number; - hasActivePrompt?: boolean; -} - interface ReleaseSessionDialogProps { - currentSessionId?: string | null; - loadSessions: () => Promise; - releaseSession: (sessionId: string) => Promise; onReleased: (sessionId: string) => void; onError: (error: unknown) => void; onClose: () => void; } export function ReleaseSessionDialog({ - currentSessionId, - loadSessions, - releaseSession, onReleased, onError, onClose, }: ReleaseSessionDialogProps) { const { language, t } = useI18n(); - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); + const connection = useConnection(); + const { + sessions, + loading, + error: sessionsError, + releaseSession, + } = useSessions({ autoLoad: true }); + const currentSessionId = connection.sessionId; const [deleting, setDeleting] = useState(false); const [selectedIdx, setSelectedIdx] = useState(0); const [searchMode, setSearchMode] = useState(false); @@ -56,15 +51,8 @@ export function ReleaseSessionDialog({ const listRef = useRef(null); useEffect(() => { - loadSessions() - .then((loadedSessions) => { - setSessions(loadedSessions); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); - }, [loadSessions]); + if (sessionsError) setMessage(sessionsError.message); + }, [sessionsError]); const filtered = searchQuery ? sessions.filter((s) => { @@ -90,7 +78,7 @@ export function ReleaseSessionDialog({ }, [selectedIdx]); const handleRelease = useCallback( - (targetSession?: SessionInfo) => { + (targetSession?: DaemonSessionSummary) => { const session = targetSession ?? filtered[selectedIdx]; if (!session || deleting) return; const releasable = @@ -103,6 +91,7 @@ export function ReleaseSessionDialog({ setMessage(t('release.cannotCurrent')); return; } + if (!releaseSession) return; setDeleting(true); releaseSession(session.sessionId) .then(() => { @@ -211,6 +200,13 @@ export function ReleaseSessionDialog({ ({filtered.length} matches)
)} +
diff --git a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx index eef73f86a6d..62bb0c21f81 100644 --- a/packages/web-shell/client/components/dialogs/ResumeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ResumeDialog.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef, useCallback } from 'react'; import { dp } from './dialogStyles'; +import { useConnection, useSessions } from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; @@ -18,46 +19,21 @@ function formatRelativeTime(iso: string, language: string): string { return new Date(iso).toLocaleDateString(); } -interface SessionInfo { - sessionId: string; - title?: string; - displayName?: string; - createdAt?: string; - updatedAt?: string; - clientCount?: number; - hasActivePrompt?: boolean; -} - interface ResumeDialogProps { - currentSessionId?: string | null; - loadSessions: () => Promise; onSelect: (sessionId: string) => void; onClose: () => void; } -export function ResumeDialog({ - currentSessionId, - loadSessions, - onSelect, - onClose, -}: ResumeDialogProps) { +export function ResumeDialog({ onSelect, onClose }: ResumeDialogProps) { const { language, t } = useI18n(); - const [sessions, setSessions] = useState([]); - const [loading, setLoading] = useState(true); + const connection = useConnection(); + const { sessions, loading, error } = useSessions({ autoLoad: true }); + const currentSessionId = connection.sessionId; const [selectedIdx, setSelectedIdx] = useState(0); const [searchMode, setSearchMode] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const listRef = useRef(null); - useEffect(() => { - loadSessions() - .then((loadedSessions) => { - setSessions(loadedSessions); - setLoading(false); - }) - .catch(() => setLoading(false)); - }, [loadSessions]); - const filtered = searchQuery ? sessions.filter((s) => { const q = searchQuery.toLowerCase(); @@ -171,6 +147,13 @@ export function ResumeDialog({ ({filtered.length} matches) )} +
{/* Search row */} @@ -215,7 +198,12 @@ export function ResumeDialog({ {loading && (
{t('common.loading')}
)} - {!loading && filtered.length === 0 && ( + {!loading && error && ( +
+ {error.message || 'Failed to load sessions'} +
+ )} + {!loading && !error && filtered.length === 0 && (
{searchQuery ? t('resume.noMatch', { query: searchQuery }) diff --git a/packages/web-shell/client/components/dialogs/SkillsDialog.tsx b/packages/web-shell/client/components/dialogs/SkillsDialog.tsx index 7c1afdd02de..0b95f3000c2 100644 --- a/packages/web-shell/client/components/dialogs/SkillsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/SkillsDialog.tsx @@ -1,14 +1,13 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; import { dp } from './dialogStyles'; -import type { - DaemonWorkspaceSkillStatus, - DaemonWorkspaceSkillsStatus, -} from '@qwen-code/sdk/daemon'; +import { + useSkills, + type DaemonWorkspaceSkillStatus, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; interface SkillsDialogProps { - loadStatus: () => Promise; onClose: () => void; } @@ -31,35 +30,18 @@ function metaText(skill: DaemonWorkspaceSkillStatus): string { .join(' · '); } -export function SkillsDialog({ loadStatus, onClose }: SkillsDialogProps) { +export function SkillsDialog({ onClose }: SkillsDialogProps) { const { t } = useI18n(); - const [status, setStatus] = useState( - null, - ); + const { status, loading, error, reload } = useSkills({ autoLoad: true }); const [selectedIdx, setSelectedIdx] = useState(0); - const [loading, setLoading] = useState(true); - const [message, setMessage] = useState(null); const listRef = useRef(null); - const skills = useMemo(() => status?.skills ?? [], [status?.skills]); + const skills: DaemonWorkspaceSkillStatus[] = useMemo( + () => status?.skills ?? [], + [status?.skills], + ); const selected = skills[selectedIdx]; - - const reload = useCallback(() => { - setLoading(true); - loadStatus() - .then((next) => { - setStatus(next); - setMessage(next.errors?.[0]?.error ?? null); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); - }, [loadStatus]); - - useEffect(() => { - reload(); - }, [reload]); + const message = error?.message ?? status?.errors?.[0]?.error ?? null; useEffect(() => { if (selectedIdx >= skills.length && skills.length > 0) { @@ -110,6 +92,13 @@ export function SkillsDialog({ loadStatus, onClose }: SkillsDialogProps) {
{t('skills.title')} {summary} +
diff --git a/packages/web-shell/client/components/dialogs/ThemeDialog.tsx b/packages/web-shell/client/components/dialogs/ThemeDialog.tsx index b619022dac1..46e121db1ea 100644 --- a/packages/web-shell/client/components/dialogs/ThemeDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ThemeDialog.tsx @@ -77,6 +77,13 @@ export function ThemeDialog({ {t('theme.current', { theme: currentTheme })} +
diff --git a/packages/web-shell/client/components/dialogs/ToolsDialog.tsx b/packages/web-shell/client/components/dialogs/ToolsDialog.tsx index 3b002074abb..6a8a60579a0 100644 --- a/packages/web-shell/client/components/dialogs/ToolsDialog.tsx +++ b/packages/web-shell/client/components/dialogs/ToolsDialog.tsx @@ -1,44 +1,26 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { dp } from './dialogStyles'; +import { + useTools, + type DaemonWorkspaceToolStatus, +} from '@qwen-code/webui/daemon-react-sdk'; import { useDelayedGlobalKeyDown } from '../../hooks/useDelayedGlobalKeyDown'; import { useI18n } from '../../i18n'; -export interface WebShellWorkspaceToolStatus { - name: string; - displayName?: string; - description?: string; - enabled: boolean; -} - -export interface WebShellWorkspaceToolsStatus { - v: 1; - workspaceCwd: string; - initialized: boolean; - tools: WebShellWorkspaceToolStatus[]; - errors?: Array<{ error?: string }>; -} - interface ToolsDialogProps { - loadStatus: () => Promise; - setToolEnabled: (toolName: string, enabled: boolean) => Promise; onClose: () => void; } -function toolLabel(tool: WebShellWorkspaceToolStatus): string { +function toolLabel(tool: DaemonWorkspaceToolStatus): string { return tool.displayName || tool.name; } -export function ToolsDialog({ - loadStatus, - setToolEnabled, - onClose, -}: ToolsDialogProps) { +export function ToolsDialog({ onClose }: ToolsDialogProps) { const { t } = useI18n(); - const [status, setStatus] = useState( - null, - ); + const { status, tools, loading, error, reload, setEnabled } = useTools({ + autoLoad: true, + }); const [selectedIdx, setSelectedIdx] = useState(0); - const [loading, setLoading] = useState(true); const [busyTool, setBusyTool] = useState(null); const [message, setMessage] = useState(null); const [expandedTools, setExpandedTools] = useState>( @@ -46,38 +28,30 @@ export function ToolsDialog({ ); const listRef = useRef(null); - const tools = useMemo(() => status?.tools ?? [], [status?.tools]); const selected = tools[selectedIdx]; const selectedExpanded = selected ? expandedTools.has(selected.name) : false; - const reload = useCallback(() => { - setLoading(true); - loadStatus() - .then((next) => { - setStatus(next); - setMessage(next.errors?.[0]?.error ?? null); - }) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); - }) - .finally(() => setLoading(false)); - }, [loadStatus]); + useEffect(() => { + if (error) setMessage(error.message); + else if (status?.errors?.[0]?.error) setMessage(status.errors[0].error); + else if (status) setMessage(null); + }, [status, error]); const handleToggle = useCallback( - (tool: WebShellWorkspaceToolStatus) => { + (tool: DaemonWorkspaceToolStatus) => { setBusyTool(tool.name); setMessage(null); - setToolEnabled(tool.name, !tool.enabled) + setEnabled(tool.name, !tool.enabled) .then(() => reload()) - .catch((error: unknown) => { - setMessage(error instanceof Error ? error.message : String(error)); + .catch((err: unknown) => { + setMessage(err instanceof Error ? err.message : String(err)); }) .finally(() => setBusyTool(null)); }, - [reload, setToolEnabled], + [reload, setEnabled], ); - const toggleDetails = useCallback((tool: WebShellWorkspaceToolStatus) => { + const toggleDetails = useCallback((tool: DaemonWorkspaceToolStatus) => { setExpandedTools((current) => { const next = new Set(current); if (next.has(tool.name)) { @@ -89,10 +63,6 @@ export function ToolsDialog({ }); }, []); - useEffect(() => { - reload(); - }, [reload]); - useEffect(() => { if (selectedIdx >= tools.length && tools.length > 0) { setSelectedIdx(tools.length - 1); @@ -152,6 +122,13 @@ export function ToolsDialog({
{t('tools.title')} {summary} +
diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.module.css b/packages/web-shell/client/components/messages/AskUserQuestion.module.css index 2e61b223dc5..7e6925aee36 100644 --- a/packages/web-shell/client/components/messages/AskUserQuestion.module.css +++ b/packages/web-shell/client/components/messages/AskUserQuestion.module.css @@ -1,7 +1,7 @@ .question { - margin: 8px 0 12px 22px; + margin: 8px 0 12px 12px; padding: 12px 16px; - border-left: 3px solid var(--border-color); + border: 1px solid var(--border-color); font-family: var(--font-mono); font-size: 13px; } @@ -34,6 +34,7 @@ margin-bottom: 10px; border-bottom: 1px solid var(--border-color); padding-bottom: 6px; + flex-wrap: wrap; } .tab { @@ -53,6 +54,14 @@ border-color: var(--accent-color); } +.tabCheck { + color: var(--success-color, #4caf50); +} + +.tabActive .tabCheck { + color: #fff; +} + .header { font-size: 13px; font-weight: bold; @@ -66,6 +75,11 @@ margin-bottom: 12px; } +.multiHint { + color: var(--text-secondary); + font-size: 12px; +} + .options { display: flex; flex-direction: column; @@ -81,9 +95,13 @@ cursor: pointer; } -.optionActive .optionLabel { +.optionActive .optionLabel, +.optionSelected .optionLabel { font-weight: bold; + color: var(--accent-color); text-decoration: underline; + text-decoration-color: var(--accent-color); + text-underline-offset: 3px; } .pointer { @@ -93,6 +111,18 @@ font-weight: bold; } +.checkbox { + font-size: 14px; + font-weight: bold; + color: var(--text-secondary); + flex-shrink: 0; + min-width: 24px; +} + +.optionSelected .checkbox { + color: var(--accent-color); +} + .optionNum { color: var(--text-secondary); flex-shrink: 0; @@ -120,21 +150,12 @@ padding-left: 16px; } -.optionSelected .optionLabel { - color: var(--accent-color); -} - -.check { - color: var(--accent-color); - margin-left: 8px; -} - .customInput { flex: 1; padding: 2px 6px; background: transparent; border: none; - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid var(--accent-color); color: var(--text-primary); font-family: var(--font-mono); font-size: 13px; @@ -145,6 +166,33 @@ border-bottom-color: var(--accent-color); } +.submitTab { + margin-top: 4px; +} + +.summary { + margin-bottom: 12px; + padding: 8px 12px; + border-radius: 4px; + background: var(--bg-secondary, rgba(255, 255, 255, 0.03)); +} + +.summaryRow { + display: flex; + gap: 8px; + padding: 3px 0; +} + +.summaryLabel { + color: var(--text-secondary); + flex-shrink: 0; +} + +.summaryValue { + color: var(--accent-color); + font-weight: bold; +} + .actions { display: flex; gap: 8px; diff --git a/packages/web-shell/client/components/messages/AskUserQuestion.tsx b/packages/web-shell/client/components/messages/AskUserQuestion.tsx index 7f172b55023..8524449a167 100644 --- a/packages/web-shell/client/components/messages/AskUserQuestion.tsx +++ b/packages/web-shell/client/components/messages/AskUserQuestion.tsx @@ -38,6 +38,10 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { const [customFocused, setCustomFocused] = useState(false); const submittedRef = useRef(false); + // Total tabs = questions + submit tab + const totalTabs = questions.length + 1; + const isOnSubmitTab = currentIdx === questions.length; + useEffect(() => { submittedRef.current = false; setCurrentIdx(0); @@ -48,16 +52,41 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { setCustomFocused(false); }, [request.id]); - const current = questions[currentIdx]; + const current = isOnSubmitTab ? undefined : questions[currentIdx]; const isMulti = current?.multiSelect ?? false; - const totalOptions = (current?.options.length ?? 0) + 1; // +1 for "Other" + const totalOptions = isOnSubmitTab ? 2 : (current?.options.length ?? 0) + 1; const otherOptionIdx = current?.options.length ?? 0; - const handleSubmit = useCallback(() => { - if (submittedRef.current) return; - const submitOption = request.options.find((o) => o.kind === 'allow_once'); - if (!submitOption) return; - submittedRef.current = true; + const getSelectedIdxForTab = useCallback( + ( + tabIdx: number, + nextAnswers = answers, + nextCustomInputs = customInputs, + nextSelectedMulti = selectedMulti, + ): number => { + if (tabIdx === questions.length) return 0; + const question = questions[tabIdx]; + if (!question) return 0; + const otherIdx = question.options.length; + if (question.multiSelect) { + const selected = nextSelectedMulti[tabIdx] || []; + const selectedOptionIdx = question.options.findIndex((option) => + selected.includes(option.label), + ); + if (selectedOptionIdx >= 0) return selectedOptionIdx; + return nextCustomInputs[tabIdx] ? otherIdx : 0; + } + const answer = nextAnswers[tabIdx]; + const answerOptionIdx = question.options.findIndex( + (option) => option.label === answer, + ); + if (answerOptionIdx >= 0) return answerOptionIdx; + return nextCustomInputs[tabIdx] || answer ? otherIdx : 0; + }, + [answers, customInputs, questions, selectedMulti], + ); + + const buildResult = useCallback((): Record => { const result: Record = {}; for (let i = 0; i < questions.length; i++) { const q = questions[i]; @@ -66,39 +95,21 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { const multi = selectedMulti[i] || []; const custom = customInputs[i]; const all = custom ? [...multi, custom] : multi; - result[q.question] = all.join(', '); + result[String(i)] = all.join(', '); } else { - result[q.question] = answers[i] || customInputs[i] || ''; + result[String(i)] = answers[i] || customInputs[i] || ''; } } - onConfirm(request.id, submitOption.id, result); - }, [questions, selectedMulti, customInputs, answers, request, onConfirm]); + return result; + }, [questions, selectedMulti, customInputs, answers]); - const submitWithAnswer = useCallback( - (questionIdx: number, answer: string) => { - if (submittedRef.current) return; - const submitOption = request.options.find((o) => o.kind === 'allow_once'); - if (!submitOption) return; - submittedRef.current = true; - const result: Record = {}; - for (let i = 0; i < questions.length; i++) { - const q = questions[i]; - if (!q) continue; - if (i === questionIdx) { - result[q.question] = answer; - } else if (q.multiSelect) { - const multi = selectedMulti[i] || []; - const custom = customInputs[i]; - const all = custom ? [...multi, custom] : multi; - result[q.question] = all.join(', '); - } else { - result[q.question] = answers[i] || customInputs[i] || ''; - } - } - onConfirm(request.id, submitOption.id, result); - }, - [questions, selectedMulti, customInputs, answers, request, onConfirm], - ); + const handleSubmit = useCallback(() => { + if (submittedRef.current) return; + const submitOption = request.options.find((o) => o.kind === 'allow_once'); + if (!submitOption) return; + submittedRef.current = true; + onConfirm(request.id, submitOption.id, buildResult()); + }, [buildResult, request, onConfirm]); const handleCancel = useCallback(() => { if (submittedRef.current) return; @@ -112,16 +123,15 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { const switchQuestion = useCallback( (direction: 1 | -1) => { - if (questions.length <= 1) return; + if (totalTabs <= 1) return; setCurrentIdx((idx) => { - const next = (idx + direction + questions.length) % questions.length; - const nextQuestion = questions[next]; - setSelectedIdx(0); + const next = (idx + direction + totalTabs) % totalTabs; + setSelectedIdx(getSelectedIdxForTab(next)); setCustomFocused(false); - return nextQuestion ? next : idx; + return next; }); }, - [questions], + [getSelectedIdxForTab, totalTabs], ); const focusCustomInput = useCallback( @@ -136,6 +146,14 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { const handleSelectOption = useCallback( (idx: number) => { + if (isOnSubmitTab) { + if (idx === 0) { + handleSubmit(); + } else { + handleCancel(); + } + return; + } if (!current) return; const isOther = idx === current.options.length; if (isOther) { @@ -150,24 +168,54 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { : [...prev, label]; setSelectedMulti({ ...selectedMulti, [currentIdx]: next }); } else { - setAnswers({ ...answers, [currentIdx]: label }); - // Auto-advance or submit - if (questions.length > 1 && currentIdx < questions.length - 1) { - setCurrentIdx(currentIdx + 1); - setSelectedIdx(0); + const nextAnswers = { ...answers, [currentIdx]: label }; + setAnswers(nextAnswers); + if (currentIdx < questions.length - 1) { + const nextIdx = currentIdx + 1; + setCurrentIdx(nextIdx); + setSelectedIdx(getSelectedIdxForTab(nextIdx, nextAnswers)); } else { - submitWithAnswer(currentIdx, label); + // Last question answered — go to submit tab + setCurrentIdx(questions.length); + setSelectedIdx(getSelectedIdxForTab(questions.length, nextAnswers)); } } }, [ + isOnSubmitTab, current, currentIdx, isMulti, selectedMulti, answers, questions, - submitWithAnswer, + handleSubmit, + handleCancel, + focusCustomInput, + getSelectedIdxForTab, + ], + ); + + const handleToggle = useCallback( + (idx: number) => { + if (isOnSubmitTab || !current || !isMulti) return; + if (idx === current.options.length) { + focusCustomInput(); + return; + } + const label = current.options[idx].label; + const prev = selectedMulti[currentIdx] || []; + const next = prev.includes(label) + ? prev.filter((l) => l !== label) + : [...prev, label]; + setSelectedMulti({ ...selectedMulti, [currentIdx]: next }); + }, + [ + isOnSubmitTab, + current, + isMulti, + selectedMulti, + currentIdx, focusCustomInput, ], ); @@ -192,9 +240,28 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { } else if (e.key === 'ArrowLeft') { claimKey(e); switchQuestion(-1); + } else if (e.key === ' ') { + claimKey(e); + if (isMulti) { + handleToggle(selectedIdx); + } else { + handleSelectOption(selectedIdx); + } } else if (e.key === 'Enter') { claimKey(e); - handleSelectOption(selectedIdx); + if (isMulti) { + // In multiSelect, Enter advances to next tab or submits + if (currentIdx < questions.length - 1) { + const nextIdx = currentIdx + 1; + setCurrentIdx(nextIdx); + setSelectedIdx(getSelectedIdxForTab(nextIdx)); + } else { + setCurrentIdx(questions.length); + setSelectedIdx(getSelectedIdxForTab(questions.length)); + } + } else { + handleSelectOption(selectedIdx); + } } else if (e.key === 'Escape') { claimKey(e); handleCancel(); @@ -203,9 +270,14 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { if (idx < totalOptions) { claimKey(e); setSelectedIdx(idx); - handleSelectOption(idx); + if (!isMulti) { + handleSelectOption(idx); + } else { + handleToggle(idx); + } } } else if ( + !isOnSubmitTab && selectedIdx === otherOptionIdx && e.key.length === 1 && !e.metaKey && @@ -223,10 +295,16 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { totalOptions, selectedIdx, otherOptionIdx, + isMulti, + isOnSubmitTab, + currentIdx, + questions.length, handleSelectOption, + handleToggle, handleCancel, switchQuestion, focusCustomInput, + getSelectedIdxForTab, ]); const handleCustomKeyDown = (e: React.KeyboardEvent) => { @@ -236,27 +314,77 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) { const val = customInputs[currentIdx]; if (val) { if (!isMulti) { - setAnswers({ ...answers, [currentIdx]: val }); - if (questions.length <= 1 || currentIdx === questions.length - 1) { - submitWithAnswer(currentIdx, val); + const nextAnswers = { ...answers, [currentIdx]: val }; + setAnswers(nextAnswers); + if (currentIdx < questions.length - 1) { + const nextIdx = currentIdx + 1; + setCurrentIdx(nextIdx); + setSelectedIdx(getSelectedIdxForTab(nextIdx, nextAnswers)); + setCustomFocused(false); return; } - setCurrentIdx(currentIdx + 1); - setSelectedIdx(0); + // Go to submit tab + setCurrentIdx(questions.length); + setSelectedIdx(getSelectedIdxForTab(questions.length, nextAnswers)); setCustomFocused(false); return; } setCustomFocused(false); - handleSubmit(); + // Multi — advance to next or submit tab + if (currentIdx < questions.length - 1) { + const nextIdx = currentIdx + 1; + setCurrentIdx(nextIdx); + setSelectedIdx(getSelectedIdxForTab(nextIdx)); + } else { + setCurrentIdx(questions.length); + setSelectedIdx(getSelectedIdxForTab(questions.length)); + } } } else if (e.key === 'Escape') { e.preventDefault(); e.stopPropagation(); setCustomFocused(false); + } else if (e.key === 'ArrowDown') { + e.preventDefault(); + e.stopPropagation(); + setCustomFocused(false); + setSelectedIdx((i) => Math.min(i + 1, totalOptions - 1)); + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + e.stopPropagation(); + setCustomFocused(false); + setSelectedIdx((i) => Math.max(i - 1, 0)); + } else if (e.key === 'Tab') { + e.preventDefault(); + e.stopPropagation(); + setCustomFocused(false); + switchQuestion(e.shiftKey ? -1 : 1); + } + }; + + if (questions.length === 0) return null; + + // Check which questions have answers + const hasAnswer = (i: number): boolean => { + const q = questions[i]; + if (!q) return false; + if (q.multiSelect) { + return (selectedMulti[i] || []).length > 0 || !!customInputs[i]; } + return !!answers[i] || !!customInputs[i]; }; - if (questions.length === 0 || !current) return null; + const getAnswerText = (i: number): string => { + const q = questions[i]; + if (!q) return ''; + if (q.multiSelect) { + const multi = selectedMulti[i] || []; + const custom = customInputs[i]; + const all = custom ? [...multi, custom] : multi; + return all.join(', '); + } + return answers[i] || customInputs[i] || ''; + }; return (
@@ -269,125 +397,201 @@ export function AskUserQuestion({ request, onConfirm }: AskUserQuestionProps) {
- {/* Tabs for multi-question */} - {questions.length > 1 && ( -
- {questions.map((q, i) => ( - - ))} -
- )} - - {/* Header label */} -
{current.header}
- - {/* Question text */} -

{current.question}

- - {/* Options list */} -
- {current.options.map((opt, i) => { - const isActive = i === selectedIdx; - const isSelected = isMulti - ? (selectedMulti[currentIdx] || []).includes(opt.label) - : answers[currentIdx] === opt.label; + {/* Tabs for navigation */} +
+ {questions.map((q, i) => ( + + ))} + +
- return ( + {isOnSubmitTab ? ( + /* Submit confirmation tab */ +
+
{t('askUser.confirmTitle')}
+
+ {questions.map((q, i) => ( +
+ {q.header}: + + {getAnswerText(i) || '—'} + +
+ ))} +
+
{t('askUser.confirmPrompt')}
+
{ - setSelectedIdx(i); - handleSelectOption(i); - }} - onMouseEnter={() => setSelectedIdx(i)} + selectedIdx === 0 ? styles.optionActive : '' + }`} + onClick={handleSubmit} + onMouseEnter={() => setSelectedIdx(0)} > - {isActive ? '›' : ' '} - {i + 1}. - - {opt.label} - {opt.description && ( - {opt.description} - )} + + {selectedIdx === 0 ? '›' : ' '} + + 1. + + {t('askUser.submitAnswers')} - {isMulti && ( - {isSelected ? '☑' : '☐'} - )}
- ); - })} - - {/* Other / custom input option */} -
{ - setSelectedIdx(current.options.length); - focusCustomInput(); - }} - onMouseEnter={() => setSelectedIdx(current.options.length)} - > - - {selectedIdx === current.options.length ? '›' : ' '} - - - {current.options.length + 1}. - - {customFocused ? ( - - setCustomInputs({ - ...customInputs, - [currentIdx]: e.target.value, - }) - } - onKeyDown={handleCustomKeyDown} - onBlur={() => setCustomFocused(false)} - autoFocus - /> - ) : ( - setSelectedIdx(1)} > - Type something... - - )} + + {selectedIdx === 1 ? '›' : ' '} + + 2. + {t('askUser.cancel')} +
+
-
+ ) : current ? ( + /* Question content */ + <> + {/* Question text */} +

+ {current.question} + {isMulti && ( + + {' '} + ({t('askUser.multiHint')}) + + )} +

- {/* Multi-select actions */} - {isMulti && ( -
- -
- )} + {/* Options list */} +
+ {current.options.map((opt, i) => { + const isActive = i === selectedIdx; + const isSelected = isMulti + ? (selectedMulti[currentIdx] || []).includes(opt.label) + : answers[currentIdx] === opt.label; + + return ( +
{ + setSelectedIdx(i); + if (isMulti) { + handleToggle(i); + } else { + handleSelectOption(i); + } + }} + onMouseEnter={() => setSelectedIdx(i)} + > + {isActive ? '›' : ' '} + {isMulti && ( + + {isSelected ? '[✓]' : '[ ]'} + + )} + {i + 1}. + + {opt.label} + {opt.description && ( + + {opt.description} + + )} + +
+ ); + })} + + {/* Other / custom input option */} + {(() => { + const isCustomActive = selectedIdx === current.options.length; + const hasCustomValue = !!customInputs[currentIdx]; + return ( +
{ + setSelectedIdx(current.options.length); + focusCustomInput(); + }} + onMouseEnter={() => setSelectedIdx(current.options.length)} + > + + {isCustomActive ? '›' : ' '} + + {isMulti && ( + + {hasCustomValue ? '[✓]' : '[ ]'} + + )} + + {current.options.length + 1}. + + {customFocused ? ( + + setCustomInputs({ + ...customInputs, + [currentIdx]: e.target.value, + }) + } + onKeyDown={handleCustomKeyDown} + onBlur={() => setCustomFocused(false)} + autoFocus + /> + ) : ( + + {customInputs[currentIdx] || t('askUser.typePlaceholder')} + + )} +
+ ); + })()} +
+ + ) : null} {/* Footer hint */} -
{t('askUser.footer')}
+
+ {isMulti ? t('askUser.footerMulti') : t('askUser.footer')} +
); } diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.module.css b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css new file mode 100644 index 00000000000..a395e034f79 --- /dev/null +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.module.css @@ -0,0 +1,148 @@ +.panel { + display: flex; + width: min(100%, 660px); + flex-direction: column; + gap: 2px; + padding: 12px 16px; + border: 1px solid var(--border-color); + border-radius: 8px; + color: var(--text-primary); + background: var(--bg-surface); + font-family: var(--font-mono); + font-size: 13px; + line-height: 1.45; +} + +.title { + margin-bottom: 8px; + color: var(--accent-color); + font-weight: 700; +} + +.metaLine { + display: flex; + width: 100%; + max-width: 56ch; + justify-content: space-between; + gap: 16px; + color: var(--text-secondary); + white-space: nowrap; +} + +.progress { + max-width: 56ch; + overflow: hidden; + white-space: nowrap; +} + +.spacer { + height: 8px; +} + +.row, +.detailRow, +.subDetailRow { + display: grid; + width: 100%; + max-width: 56ch; + align-items: baseline; + column-gap: 0; +} + +.row { + grid-template-columns: 2ch 24ch minmax(0, 1fr); +} + +.detailRow { + grid-template-columns: 4ch 32ch minmax(0, 1fr); + padding-left: 2ch; +} + +.subDetailRow { + grid-template-columns: 6ch 30ch minmax(0, 1fr); + padding-left: 4ch; +} + +.symbol { + width: 2ch; +} + +.label { + color: var(--text-primary); +} + +.value { + justify-self: end; + color: var(--text-secondary); + text-align: right; + white-space: nowrap; +} + +.detailName { + min-width: 0; + overflow: hidden; + color: var(--accent-color); + text-overflow: ellipsis; + white-space: nowrap; +} + +.sectionTitle { + margin-top: 8px; + color: var(--text-primary); + font-weight: 700; +} + +.detailSection { + display: flex; + flex-direction: column; + gap: 2px; +} + +.skillBlock { + display: flex; + flex-direction: column; +} + +.hint { + margin-top: 8px; + color: var(--text-secondary); + font-style: italic; +} + +.estimateHint { + margin-bottom: 8px; + color: var(--warning-color); + font-style: italic; +} + +.bodyLoaded { + color: var(--text-secondary); + font-style: italic; +} + +.accent { + color: var(--accent-color); +} + +.secondary { + color: var(--text-secondary); +} + +.warning { + color: var(--warning-color); +} + +.error { + color: var(--error-color); +} + +.success { + color: var(--success-color); +} + +@media (max-width: 720px) { + .panel { + font-size: 12px; + overflow-x: auto; + } +} diff --git a/packages/web-shell/client/components/messages/ContextUsageMessage.tsx b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx new file mode 100644 index 00000000000..595850f59cd --- /dev/null +++ b/packages/web-shell/client/components/messages/ContextUsageMessage.tsx @@ -0,0 +1,394 @@ +import type { + DaemonContextMemoryDetail, + DaemonContextSkillDetail, + DaemonContextToolDetail, + DaemonSessionContextUsageStatus, +} from '@qwen-code/webui/daemon-react-sdk'; +import { useI18n } from '../../i18n'; +import styles from './ContextUsageMessage.module.css'; + +const SENTINEL = 'web-shell:context-usage:v1:'; +const FILLED = '\u2588'; +const BUFFER = '\u2592'; +const EMPTY = '\u2591'; +const DETAIL_NAME_MAX_LEN = 30; + +export function serializeContextUsageMessage( + status: DaemonSessionContextUsageStatus, +): string { + return `${SENTINEL}${JSON.stringify(status)}`; +} + +export function parseContextUsageMessage( + content: string, +): DaemonSessionContextUsageStatus | null { + if (!content.startsWith(SENTINEL)) return null; + try { + const parsed = JSON.parse(content.slice(SENTINEL.length)); + if (!parsed?.usage || typeof parsed.usage.totalTokens !== 'number') { + return null; + } + return parsed as DaemonSessionContextUsageStatus; + } catch { + return null; + } +} + +function truncateName(name: string, maxLen: number): string { + if (name.length <= maxLen) return name; + return `${name.slice(0, maxLen - 1)}\u2026`; +} + +function formatTokens(tokens: number): string { + if (tokens >= 1000) return `${(tokens / 1000).toFixed(1)}k`; + return `${tokens}`; +} + +function formatPercentage(tokens: number, contextWindowSize: number): string { + if (contextWindowSize <= 0) return '0.0'; + const percentage = (tokens / contextWindowSize) * 100; + if (percentage > 100) return '>100'; + return percentage.toFixed(1); +} + +function sortByTokens(items: readonly T[]): T[] { + return [...items].sort((a, b) => b.tokens - a.tokens); +} + +function ProgressBar({ + usedPercentage, + bufferPercentage, +}: { + usedPercentage: number; + bufferPercentage: number; +}) { + const width = 56; + const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width); + const bufferCount = Math.round( + (Math.min(bufferPercentage, Math.max(0, 100 - usedPercentage)) / 100) * + width, + ); + const freeCount = Math.max(0, width - usedCount - bufferCount); + const usedClass = + usedPercentage > 80 + ? styles.error + : usedPercentage > 60 + ? styles.warning + : styles.accent; + + return ( + + ); +} + +function CategoryRow({ + symbol, + label, + tokens, + tokenLabel, + contextWindowSize, + symbolClassName = styles.secondary, + isOverLimit, +}: { + symbol: string; + label: string; + tokens: number; + tokenLabel: string; + contextWindowSize: number; + symbolClassName?: string; + isOverLimit?: boolean; +}) { + return ( +
+ {symbol} + {label} + + {formatTokens(tokens)} {tokenLabel} ( + {formatPercentage(tokens, contextWindowSize)}%) + +
+ ); +} + +function DetailRow({ + name, + tokens, + tokenLabel, +}: { + name: string; + tokens: number; + tokenLabel: string; +}) { + return ( +
+ {'\u2514'} + + {truncateName(name, DETAIL_NAME_MAX_LEN)} + + + {formatTokens(tokens)} {tokenLabel} + +
+ ); +} + +function DetailSection({ + title, + items, + getName, + tokenLabel, +}: { + title: string; + items: readonly (DaemonContextToolDetail | DaemonContextMemoryDetail)[]; + getName: ( + item: DaemonContextToolDetail | DaemonContextMemoryDetail, + ) => string; + tokenLabel: string; +}) { + const sorted = sortByTokens(items); + if (sorted.length === 0) return null; + return ( +
+
{title}
+ {sorted.map((item) => ( + + ))} +
+ ); +} + +function SkillsSection({ + skills, + labels, +}: { + skills: readonly DaemonContextSkillDetail[]; + labels: { + active: string; + bodyLoaded: string; + skills: string; + tokens: string; + }; +}) { + const sorted = [...skills].sort((a, b) => { + if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; + return b.tokens + (b.bodyTokens ?? 0) - (a.tokens + (a.bodyTokens ?? 0)); + }); + if (sorted.length === 0) return null; + + return ( +
+
{labels.skills}
+ {sorted.map((skill) => ( +
+
+ {'\u2514'} + + {truncateName(skill.name, DETAIL_NAME_MAX_LEN)} + {skill.loaded && ( + {labels.active} + )} + + + {formatTokens(skill.tokens)} {labels.tokens} + +
+ {skill.loaded && skill.bodyTokens != null && skill.bodyTokens > 0 && ( +
+ {' \u2514'} + {labels.bodyLoaded} + + +{formatTokens(skill.bodyTokens)} {labels.tokens} + +
+ )} +
+ ))} +
+ ); +} + +export function ContextUsageMessage({ + status, +}: { + status: DaemonSessionContextUsageStatus; +}) { + const { t } = useI18n(); + const { usage } = status; + const { breakdown, contextWindowSize } = usage; + const percentage = + contextWindowSize > 0 ? (usage.totalTokens / contextWindowSize) * 100 : 0; + const isOverLimit = percentage > 100; + const bufferPercentage = + contextWindowSize > 0 + ? (breakdown.autocompactBuffer / contextWindowSize) * 100 + : 0; + + return ( +
+
{t('contextUsage.title')}
+ + {usage.isEstimated ? ( + <> +
+ + {t('contextUsage.model')}: {usage.modelName} + + + {t('contextUsage.contextWindow')}:{' '} + {formatTokens(contextWindowSize)} {t('contextUsage.tokens')} + +
+ + ) : ( + <> +
+ + {t('contextUsage.model')}: {usage.modelName} + + + {t('contextUsage.contextWindow')}:{' '} + {formatTokens(contextWindowSize)} {t('contextUsage.tokens')} + +
+ {isOverLimit && ( +
{t('contextUsage.overLimit')}
+ )} + + )} + + +
+ + + +
+
+ {t('contextUsage.usageByCategory')} +
+ + + + {breakdown.mcpTools > 0 && ( + + )} + + + {!usage.isEstimated && ( + + )} + + {usage.showDetails ? ( + <> + ('name' in item ? item.name : item.path)} + tokenLabel={t('contextUsage.tokens')} + /> + ('name' in item ? item.name : item.path)} + tokenLabel={t('contextUsage.tokens')} + /> + ('path' in item ? item.path : item.name)} + tokenLabel={t('contextUsage.tokens')} + /> + + + ) : ( +
{t('contextUsage.detailHint')}
+ )} +
+ ); +} diff --git a/packages/web-shell/client/components/messages/Markdown.module.css b/packages/web-shell/client/components/messages/Markdown.module.css index 4488aa0076f..0df8a112402 100644 --- a/packages/web-shell/client/components/messages/Markdown.module.css +++ b/packages/web-shell/client/components/messages/Markdown.module.css @@ -188,6 +188,12 @@ text-align: center; } +.mermaidBlock.mermaidInline { + margin: 0; + border: none; + border-radius: 0; +} + .mermaidBlock :global(svg) { max-width: 100%; height: auto; @@ -200,6 +206,11 @@ padding: 20px; } +.mermaidActions { + display: flex; + gap: 4px; +} + .content :global(.katex-display) { margin: 12px 0; overflow-x: auto; diff --git a/packages/web-shell/client/components/messages/Markdown.test.ts b/packages/web-shell/client/components/messages/Markdown.test.ts index 558dcdbbc84..440a2ea5878 100644 --- a/packages/web-shell/client/components/messages/Markdown.test.ts +++ b/packages/web-shell/client/components/messages/Markdown.test.ts @@ -104,18 +104,45 @@ describe('sanitizeSvg', () => { expect(result).toContain(' { + it('keeps foreignObject elements (mermaid uses them for text labels)', () => { const svg = - '
XSS
'; + '
Label
'; const result = sanitizeSvg(svg); - expect(result).not.toContain('foreignObject'); + expect(result).toContain('foreignObject'); + expect(result).toContain('Label'); }); - it('strips style elements', () => { + it('strips on* handlers inside foreignObject', () => { const svg = - ''; + '
Label
'; const result = sanitizeSvg(svg); - expect(result).not.toContain(' { + const svg = + ''; + const result = sanitizeSvg(svg); + expect(result).toContain(' { + const svg = + ''; + const result = sanitizeSvg(svg); + expect(result).toContain(' { + const svg = + ''; + const result = sanitizeSvg(svg); + expect(result).toContain('url(#grad)'); + expect(result).not.toContain('url(https://'); }); it('strips image elements (external resource loading)', () => { diff --git a/packages/web-shell/client/components/messages/Markdown.tsx b/packages/web-shell/client/components/messages/Markdown.tsx index 22bf6b951e1..0236198d24b 100644 --- a/packages/web-shell/client/components/messages/Markdown.tsx +++ b/packages/web-shell/client/components/messages/Markdown.tsx @@ -1,4 +1,5 @@ -import { memo, useEffect, useState, useRef, type ReactNode } from 'react'; +import { memo, useEffect, useState, type ReactNode } from 'react'; +import { useTheme } from '../../themeContext'; import ReactMarkdown from 'react-markdown'; import type { Components } from 'react-markdown'; import remarkGfm from 'remark-gfm'; @@ -60,6 +61,19 @@ const SUPPORTED_LANGUAGES = new Set([ 'diff', ]); +// Sanitize mermaid SVG output to prevent XSS while preserving rendering. +// +// Why