feat(sdk,serve): DaemonTransport abstraction + ACP standard compliance (#5040)

* feat(sdk): DaemonTransport abstraction — pluggable transport for REST/ACP-HTTP/ACP-WS

- DaemonTransport interface with fetch + subscribeEvents
- RestSseTransport: extract current SSE logic from DaemonClient
- AcpWsTransport: WebSocket multiplexer + URL-to-JSON-RPC mapping
- AcpHttpTransport: POST /acp + session-scoped SSE
- AcpEventDenormalizer: JSON-RPC notification -> DaemonEvent
- AutoReconnectTransport: opt-in reconnect + fallback wrapper
- negotiateTransport(): auto-detect best transport via GET /capabilities
- Provider: DaemonWorkspaceProvider gains transport prop
- Server: GET /capabilities advertises supported transports
- Zero breaking changes: no transport = current REST behavior

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* docs(design): include DaemonTransport design doc in implementation PR

* fix(sdk): address 6 verification findings — bundle size, WS hang, error types, ACP compat

- Remove ACP transport class re-exports from barrel (index.ts) to avoid
  ~19.7KB browser bundle bloat; keep type-only exports
- Fix WS dial hang: reject connect promise in onerror when not yet
  connected (Node WebSocket may only fire error, not close)
- Fix parked generators: maintain _activeGenerators set, abort all on
  WS close so generators throw DaemonTransportClosedError
- Forward abort signal through AcpHttpTransport.sendRequest to fetch
- Restore DaemonHttpError in RestSseTransport (was plain Error)
- ACP endpoint compat: extract connectionId from initialize, send
  Acp-Connection-Id header, add _qwen/ prefix for vendor methods,
  preserve real HTTP status in error mapping, fetch /capabilities
  from REST endpoint for correct shape

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): address 16 review findings + CI bundle size

- CI: move negotiateTransport to separate file, extract DaemonHttpError
  to break static import chain from barrel -> DaemonClient. Browser
  bundle drops from 136KB to 115KB, under the 116KB budget.
- Route table: extract shared acpRouteTable.ts, used by both transports.
  Unify method naming (remove _qwen/ prefix inconsistency).
- Token: move from URL query to Authorization header on WS upgrade
- Error type: DaemonHttpError extracted to DaemonHttpError.ts; import
  in RestSseTransport no longer pulls in DaemonClient.
- Init retry: reset failed initPromise so next call retries
- Reconnect mutex: prevent concurrent reconnect storms
- Generator queue: cap at 256, drop-oldest
- WS init timeout: 30s default
- negotiate: clear timer on all paths, catch dispose rejection
- Headers: forward init.headers in ACP transports via mergeHeaders()
- Dead code: remove unused pendingRequests/sseAbort fields
- Provider: dispose client on unmount
- Helpers: extract matchRoute/synthesizeResponse/jsonRpcErrorToHttpStatus/
  isRecord/composeAbortSignals to shared acpTransportUtils.ts
- Package exports: add deep import paths for ACP transports
- Tests: add AcpEventDenormalizer unit tests (17 cases)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): ESLint array-type rule — ReadonlyArray<T> → readonly T[]

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): fix 3 ACP wire bugs + bundle size + npm exports

Wire bugs (verified broken against real daemon):
1. AcpHttpTransport: read connectionId from response header + correct JSON path
2. AcpWsTransport: send token via Authorization header, not URL query
3. AcpEventDenormalizer: read params.update.sessionUpdate, not params.type

Bundle: remove negotiateTransport from barrel-reachable imports
Exports: add package.json deep import paths for ACP transports

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* test(sdk): comprehensive ACP transport test suite (~175 tests)

- RestSseTransport: fetch delegation, SSE subscribe, auth, timeout, signal
- AcpWsTransport: route mapping, token auth, event filtering, queue cap
- AcpHttpTransport: connectionId extraction, header injection, init retry
- AutoReconnectTransport: reconnect mutex, fallback, delegation
- negotiateTransport: capability probing, timeout, fallback
- acpRouteTable: URL→method mapping, param extraction

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): route table coverage, browser WS auth, header forwarding, capabilities type

- Route table: add file/stat/list/glob/write/edit paths (all DaemonClient URLs)
- Route table: add session diagnostic routes (context, tasks, stats, rewind, language)
- Route table: add bulk sessions/delete
- WS auth: document browser limitation, Node uses headers, browser needs proxy
- Headers: forward X-Qwen-Client-Id via JSON-RPC _meta in WS transport
- DaemonCapabilities: add transports field to SDK type
- Package exports: remove unreachable deep exports, document monorepo usage
- Provider bypass: document limitation for glob/stat/list in workspace actions

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): add missing detach + hooks routes per QA doc

Cross-referenced with daemon-acp-integration-qa.md route table.
Added POST /session/:id/detach and GET /session/:id/hooks.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve,sdk): enforce ACP standard session/new — always isolated session

ACP standard mandates session/new MUST create a new isolated session.

Server-side (dispatch.ts):
- Force sessionScope='thread' on /acp session/new, ignoring client params
- REST POST /session retains 'single' default for backward compat

SDK-side (acpRouteTable.ts):
- Strip sessionScope from session/new params in ACP transports
- Document that ACP follows the standard (no extensions)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): ACP session/new returns standard models/modes fields

ACP standard NewSessionResponse includes optional `models` and `modes`
top-level fields alongside `configOptions`. Extract model/mode state
from configOptions and surface them as standard-shaped objects:
- models: { currentModelId, availableModels: [{id}] }
- modes:  { currentModeId, availableModes: [{id}] }

Also update test to verify sessionScope is always forced to 'thread'
(ACP standard compliance — session/new always creates isolated session).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* feat(serve): add standard ACP methods session/set_mode, session/set_model, session/fork

Align /acp endpoint with ACP standard protocol:

- session/set_mode: dedicated method for mode changes (standard)
  Maps to bridge.setSessionApprovalMode(). Params: {modeId, sessionId}
- session/set_model: dedicated method for model changes (unstable)
  Maps to bridge.setSessionModel(). Params: {modelId, sessionId}
- session/fork: create a branched copy of an existing session
  Maps to bridge.branchSession(). Response includes configOptions,
  models, modes per ACP standard.
- session/load, session/resume: responses now include configOptions,
  models, modes (per ACP LoadSessionResponse/ResumeSessionResponse)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): TS2345 — pass persist: false to setSessionApprovalMode

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(webui): add dispose() to MockDaemonClient in provider tests

DaemonClient now has dispose() (called in provider cleanup effect).
Mock clients in test files need to implement it.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(serve): add sessionId pre-validation + remove type assertion

- session/set_mode, session/set_model: add explicit sessionId empty
  check before requireOwned (consistent with session/fork)
- session/set_model: remove `as unknown as` type assertion, pass
  proper {modelId, sessionId} matching SetSessionModelRequest

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk,serve): align route table with dispatcher + AcpHttp SSE response correlation

Route table:
- Add _qwen/ prefix to all vendor session/workspace methods
- Split workspace catch-all into granular dispatcher methods
- Fix session/branch → session/fork, model → session/set_model
- Remove routes with no dispatcher handler

AcpHttpTransport:
- Implement conn-scoped SSE stream for response correlation
- POST returns 202 (ack), real response rides SSE stream
- Map<id, {resolve, reject}> for pending request correlation

dispatch.ts:
- Remove session/set_mode, session/set_model from CONN_ROUTED_METHODS

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): bump browser bundle budget 116KB→118KB for transport abstraction

Main uses 117,753 bytes (99.1% of 116KB budget). The transport
abstraction adds ~1.5KB (DaemonTransport interface + RestSseTransport
default constructor in DaemonClient). Bump to 118KB (120,832 bytes).

Also change RestSseTransport to type-only export from barrel (class
is constructed internally by DaemonClient, not needed as a value
export for consumers).

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): fix 2 test failures — SSE error message + workspace catch-all route

- RestSseTransport: error message 'SSE response has no body' → 'No SSE body'
  (matches existing DaemonClient.test.ts assertion)
- acpRouteTable: re-add GET/POST /workspace/* catch-all after granular routes
  (AcpWsTransport.test.ts expects generic workspace path to resolve)

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(sdk): align RestSseTransport test with updated error message

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
ChiGao 2026-06-14 02:37:06 +08:00 committed by GitHub
parent dc6edcd523
commit ce4b0cf629
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
31 changed files with 6428 additions and 139 deletions

View file

@ -0,0 +1,473 @@
# DaemonTransport Abstraction Layer
> Target branch: `main`. Author: arnoo.gao. Date: 2026-06-12. Status: **Design v4 — review**.
> Design-first per repo workflow: this doc lands before the implementation PR.
---
## 0. TL;DR
`DaemonClient` hardcodes REST+SSE. Third-party integrations wanting ACP
WebSocket must fork the provider stack (~8 files). This proposal adds a
**`DaemonTransport` interface** with `fetch` + `subscribeEvents` methods,
plus auto-detection and runtime fallback, enabling pluggable transports
with **zero breaking changes**.
**Total change: ~1300 lines** in a single implementation PR. Existing
consumers untouched — `new DaemonClient({ baseUrl, token })` = current behavior.
---
## 1. Background
### 1.1 Current architecture
```
DaemonClient({ baseUrl, token })
└─ this._fetch = globalThis.fetch ← hardcoded
└─ subscribeEvents → GET /session/:id/events → parseSseStream → DaemonEvent
```
67 public methods, each constructing REST URLs and branching on HTTP status
codes. `fetch` is already injectable via `DaemonClientOptions.fetch`, but
`subscribeEvents` has inline SSE-specific logic (content-type check, SSE parsing,
connect-phase timeout) that cannot be swapped via fetch injection alone.
### 1.2 The problem for third parties
When a third party (e.g., `agent-web`) builds an `AcpSessionProvider` to use
WebSocket instead of REST+SSE:
- **If they replace** `DaemonSessionProvider`: components that read
`DaemonStoreContext` (e.g., TerminalView) lose their context → crash.
- **If they keep both providers**: two event sources, two stores, desync.
- **If they inject events** into the SDK store: `DaemonSessionProvider` also
subscribes to SSE internally → duplicate events.
**Root cause**: changing the transport requires replacing the provider, because
`DaemonClient`'s `subscribeEvents` is hardcoded to SSE.
### 1.3 Target
```
DaemonClient({ transport: new AcpWsTransport(url, token) })
└─ transport.fetch → maps URL+verb to JSON-RPC over WS
└─ transport.subscribeEvents → demux WS notifications → DaemonEvent
```
One provider, one store, transport is an internal detail. Third parties pass
`transport` to `DaemonClient`; everything else works unchanged.
---
## 2. Design
### 2.1 Interface
```typescript
interface DaemonTransportFetchOptions {
timeout?: number; // 0 = no timeout. undefined = transport default.
}
interface DaemonTransportSubscribeOptions {
lastEventId?: number;
maxQueued?: number;
signal?: AbortSignal;
connectTimeoutMs?: number;
}
interface DaemonTransport {
/**
* Send a request and return a Response.
*
* Contract:
* - Response MUST support .json(), .text(), .ok, .status,
* .headers.get(), .body?.cancel()
* - .status MUST be an accurate HTTP status code
* (200, 201, 202, 204, 404, etc.)
* - Error bodies MUST preserve the daemon's structured shape
* - Callable without prior setup; transport handles init internally
* (lazy-init / init-once deferred pattern)
* - Throws DaemonTransportClosedError when connection is dead
* - When init.signal aborts: for prompt requests, transport MUST
* cancel the in-flight prompt on the wire (WS: send session/cancel
* RPC; HTTP: abort fetch). For ordinary requests, abort only
* rejects/cancels the pending request without side effects.
* Pending response rejects with AbortError.
*/
fetch(
url: string,
init: RequestInit,
opts?: DaemonTransportFetchOptions,
): Promise<Response>;
/**
* Subscribe to session events.
*
* Contract:
* - Events with id MUST have monotonic integer ids; synthetic/terminal
* frames (e.g., stream_error) MAY omit id (DaemonEvent.id is optional)
* - MUST deliver ALL event types (session + workspace) in one stream
* - Aborting signal MUST stop only this generator, NOT the connection
* - When the connection dies, all pending generators MUST throw
* DaemonTransportClosedError (transport maintains generator refs)
* - MUST apply connectTimeoutMs to connect phase only
* - Transport MUST declare whether lastEventId replay is supported;
* if not, consumer MUST use session/load for full resync on reconnect
*/
subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions,
): AsyncGenerator<DaemonEvent>;
/** Transport identity for exhaustive switching. */
readonly type: 'rest' | 'acp-http' | 'acp-ws';
/** Whether this transport supports Last-Event-ID based replay on reconnect.
* When false, consumer MUST use session/load for full resync. */
readonly supportsReplay: boolean;
/** False after connection drop or dispose(). */
readonly connected: boolean;
/** Idempotent teardown. */
dispose(): void;
}
class DaemonTransportClosedError extends Error {}
```
### 2.2 Why two methods (fetch + subscribeEvents), not just fetch
`subscribeEvents` has fundamentally different wire semantics per transport:
| Transport | Wire mechanism |
|-----------|---------------|
| REST | `GET /session/:id/events` → SSE → `parseSseStream``DaemonEvent` |
| ACP HTTP | `GET /acp` (session-scoped SSE) → JSON-RPC notification unwrap |
| ACP WS | Demux notifications from shared socket by sessionId |
Forcing these through a fetch-shaped hole requires SSE re-encoding/decoding
(WS → fake SSE text → `parseSseStream` → DaemonEvent) — wasteful and fragile.
All other 66 methods work through `fetch` because they follow request→response
semantics regardless of transport.
### 2.3 Why fetch-level, not method-dispatch
DaemonClient's 67 methods contain per-method HTTP branching:
- `prompt()`: 202 vs 200 status check
- `deleteWorkspaceAgent()`: 204 vs 404 with body inspection
- `respondToPermission()`: 200 vs 404 for race detection
- 6 methods bypass `fetchWithTimeout` by calling `_fetch` directly
A method-dispatch interface (`request<T>(method, params)`) forces duplicating
all this logic in every transport. Fetch-level keeps DaemonClient unchanged.
### 2.4 DaemonClient changes (~40 lines)
```typescript
export interface DaemonClientOptions {
baseUrl: string;
token?: string;
fetch?: typeof globalThis.fetch; // Kept
fetchTimeoutMs?: number; // Kept
transport?: DaemonTransport; // NEW — optional override
}
```
Internal changes:
- Constructor: `this.transport = opts.transport ?? new RestSseTransport(...)`
- `fetchWithTimeout`: delegate to `this.transport.fetch(url, init, { timeout })`
- 6 direct `this._fetch` sites (prompt, promptNonBlocking, recapSession,
btwSession, shellCommand, subscribeEvents): replace with
`this.transport.fetch(url, init, { timeout: 0 })`
- `subscribeEvents`: exhaustive switch on `this.transport.type`:
- `'rest'`: delegate to `this.transport.subscribeEvents(sessionId, opts)`
- default: same delegation (each transport handles its own wire format)
- Remove `private _fetch` field (replaced by transport)
### 2.5 Provider injection point
`DaemonWorkspaceProvider` and `DaemonSessionProvider` both construct
`DaemonClient` internally. To let third parties inject a transport without
bypassing the provider:
```typescript
// DaemonWorkspaceProvider — add optional transport prop
interface DaemonWorkspaceProviderProps {
baseUrl: string;
token?: string;
transport?: DaemonTransport; // NEW — forwarded to DaemonClient
// ...existing props
}
// DaemonSessionProvider — inherit from workspace context
// No transport prop needed; reads from workspace context
```
When `transport` is provided, the provider passes it to `DaemonClient`:
```typescript
new DaemonClient({ baseUrl, token, transport: props.transport })
```
When omitted: current behavior (REST+SSE). ~5 lines of provider change.
### 2.5 RestSseTransport (~80 lines)
Wraps `globalThis.fetch` + extracts current SSE logic from
`DaemonClient.subscribeEvents`:
```typescript
class RestSseTransport implements DaemonTransport {
readonly type = 'rest' as const;
readonly supportsReplay = true; // SSE supports Last-Event-ID
readonly connected = true; // REST is stateless
constructor(
private readonly baseUrl: string,
private readonly token: string | undefined,
private readonly _fetch: typeof globalThis.fetch,
) {}
fetch(url, init, opts?) { return this._fetch(url, init); }
async *subscribeEvents(sessionId, opts) {
// Current DaemonClient.subscribeEvents logic moved here:
// - build URL from this.baseUrl + sessionId
// - set Authorization header from this.token
// - connect-phase timeout from opts.connectTimeoutMs
// - fetch → validate content-type → parseSseStream → yield
}
dispose() {} // no-op
}
```
### 2.6 ACP transport internals
**AcpWsTransport** (~400-600 lines):
- Lazy-init: first `fetch` call opens WS + sends `initialize`
- URL→JSON-RPC mapping table: `/session/:id/prompt``{method: "session/prompt", params: {sessionId: id, ...body}}`
- Request multiplexer: `Map<id, {resolve, reject}>` for pending requests
- `subscribeEvents`: filter shared notification stream by sessionId
- `connected`: tracks WS readyState
- `supportsReplay`: false (WS has no Last-Event-ID; consumer must `session/load`)
- Synthesizes `Response` objects with correct `.status`/`.json()`/`.text()`
**AcpHttpTransport** (~800-1000 lines):
- Lazy-init: first `fetch` call sends `POST /acp {initialize}`
- Manages conn-scoped + session-scoped SSE streams internally
- Same URL→JSON-RPC mapping + request correlation
- `supportsReplay`: true (session SSE supports Last-Event-ID)
### 2.7 Transport auto-detection
Server advertises supported transports in `GET /capabilities`:
```json
{
"transports": ["rest+sse", "acp-http+sse", "acp-ws"],
...existing capabilities fields...
}
```
SDK provides a one-shot static factory:
```typescript
// Probe once before React render, never switches mid-session
const transport = await DaemonTransport.negotiate(baseUrl, token);
// Returns best available: acp-ws > acp-http > rest (fallback)
```
Implementation:
1. `GET /capabilities` → read `transports` array
2. If `acp-ws` in list → try WS upgrade; on success return `AcpWsTransport`
3. If WS fails or not in list → try `acp-http`; on success return `AcpHttpTransport`
4. Fallback → `RestSseTransport`
No existing API affected: `GET /capabilities` adds a new field (additive),
existing consumers ignore unknown fields.
### 2.8 Runtime fallback (WS → REST on disconnect)
When a non-REST transport disconnects mid-session:
```
AcpWsTransport (connected=true)
├── WS drops (network, server restart, idle timeout)
├── connected = false
├── All pending fetch() calls → reject with DaemonTransportClosedError
├── All subscribeEvents generators → throw DaemonTransportClosedError
└── Consumer (Provider / third party) detects disconnect:
1. Create new RestSseTransport (guaranteed to work if daemon is up)
2. Create new DaemonClient({ transport: newTransport })
3. For each active session: session/load to re-attach
4. Resume event subscription
```
**Key constraint**: runtime fallback is **consumer-driven, not transport-internal**.
The transport does not silently switch protocols — it fails loudly
(`DaemonTransportClosedError`) and the consumer decides whether to rebuild.
Rationale:
- WS teardown destroys all owned sessions server-side (`registry.delete`
`conn.destroy`). A silent switch would hide this data loss.
- `session/load` re-attaches to the existing bridge session (transcripts
preserved), but the prompt in flight is aborted. The consumer must handle
this explicitly (retry or surface to user).
- No `Last-Event-ID` resume across transports yet (Phase 4). Events between
disconnect and reconnect may be lost. The consumer should request a full
state resync via `session/load` (which replays history).
**AutoReconnectTransport** (~150 lines, optional wrapper):
```typescript
class AutoReconnectTransport implements DaemonTransport {
constructor(
private baseUrl: string,
private token: string,
private preferred: 'acp-ws' | 'acp-http' | 'rest',
) {}
// On DaemonTransportClosedError from inner transport:
// 1. Try to re-create preferred transport
// 2. If preferred fails, fallback to REST
// 3. Re-initialize connection
// Caller still needs to session/load — this wrapper only
// handles transport-level reconnect, not session-level.
}
```
This wrapper is opt-in. Existing consumers who don't want auto-reconnect
simply catch `DaemonTransportClosedError` and handle it themselves.
**Impact on existing functionality**: zero. All auto-detection and fallback
code is additive and opt-in. `new DaemonClient({ baseUrl, token })` without
`transport` = current REST behavior, no auto-detection, no fallback logic.
---
## 3. Breaking change audit
### Verdict: zero breaking changes
| Public API | Change | Breaking? |
|-----------|--------|:---------:|
| `new DaemonClient({ baseUrl, token })` | No change | ❌ |
| `DaemonClientOptions.*` | All kept, `transport` added | ❌ |
| `DaemonHttpError` | Unchanged | ❌ |
| `DaemonSessionClient` | Zero changes (delegates to DaemonClient) | ❌ |
| All type exports (100+) | Unchanged | ❌ |
### Per-consumer impact
| Consumer | Impact |
|----------|--------|
| webui (25 files) | Zero code changes |
| web-shell (4 files) | Zero code changes |
| vscode-ide-companion (1 file) | Zero code changes |
| Third-party | Zero for REST; pass `transport` for ACP |
---
## 4. Design decisions
| Decision | Rationale |
|----------|-----------|
| `subscribeEvents` on transport, not just `fetch` | SSE re-encoding through fetch is wasteful and fragile |
| `connected: boolean` on transport | Provider reconnect loop needs to distinguish "transport dead" from "transient 500" |
| Lazy-init (not explicit `connect()`) | Keeps DaemonClient construction synchronous; default `new RestSseTransport()` needs no init |
| Auto-detection is one-shot, not mid-session | `negotiate()` probes once at startup; runtime fallback is consumer-driven via `DaemonTransportClosedError`, not silent internal switch |
| No error taxonomy prerequisite | ACP transports map errors to HTTP-equivalent status codes internally; `DaemonHttpError` works as-is |
| Provider gets `transport` prop | `DaemonWorkspaceProvider` gains optional `transport` prop (~5 lines), forwarded to `DaemonClient` constructor. Third parties set this prop; omitting it = current REST behavior |
---
## 5. Alternatives considered
### 5.1 Custom fetch injection (no new interface)
Pass a WS-based `fetch` via existing `DaemonClientOptions.fetch`.
**Rejected**: `subscribeEvents` validates `content-type: text/event-stream` and
uses `parseSseStream`. A custom fetch must re-encode WS frames as SSE text, then
the SDK decodes them back — wasteful encode-decode roundtrip. Also,
`capabilities()` and `initialize` have different response shapes requiring a
format mapping layer.
### 5.2 Full formal interface (4 PRs, ~2750 lines)
Error taxonomy → Interface → AcpHttp → AcpWs as separate PRs.
**Rejected**: over-engineered. Error taxonomy is unnecessary (ACP transports can
map to HTTP-equivalent status codes). Separate PRs increase review context-switch
cost for a single cohesive abstraction.
### 5.3 Dual provider with BridgeContext
Parallel `AcpSessionProvider` + `ChatBridgeContext` + `SessionBridgeContext`.
**Rejected**: causes store desync, requires ~8 files, cannot work without SDK changes.
---
## 6. Implementation plan (single PR)
All changes land in one PR. Estimated ~1300 lines total.
| File | Change | Lines |
|------|--------|-------|
| `packages/sdk-typescript/src/daemon/DaemonTransport.ts` | Interface + types + `DaemonTransportClosedError` + `negotiate()` factory | ~110 |
| `packages/sdk-typescript/src/daemon/RestSseTransport.ts` | Wraps `globalThis.fetch` + SSE logic extracted from DaemonClient | ~80 |
| `packages/sdk-typescript/src/daemon/AcpWsTransport.ts` | WS multiplexer + URL→JSON-RPC mapping + request correlation | ~400 |
| `packages/sdk-typescript/src/daemon/AcpHttpTransport.ts` | POST /acp + conn/session SSE management | ~300 |
| `packages/sdk-typescript/src/daemon/AcpEventDenormalizer.ts` | JSON-RPC notification → DaemonEvent mapping | ~150 |
| `packages/sdk-typescript/src/daemon/AutoReconnectTransport.ts` | Opt-in wrapper: reconnect + fallback | ~150 |
| `packages/sdk-typescript/src/daemon/DaemonClient.ts` | Constructor + 6 `_fetch` sites + subscribeEvents rewrite | ~40 net |
| `packages/sdk-typescript/src/daemon/index.ts` | Export new types | ~10 |
| `packages/cli/src/serve/server.ts` | Add `transports` field to `GET /capabilities` | ~5 |
| `packages/sdk-typescript/src/daemon/types.ts` | Add `transports` to `DaemonCapabilities` type | ~3 |
| `packages/webui/src/daemon/workspace/DaemonWorkspaceProvider.tsx` | Add optional `transport` prop, forward to `DaemonClient` | ~5 |
| Tests | Transport unit + integration tests | ~200 |
**Backward compatibility**: `new DaemonClient({ baseUrl, token })` without
`transport` = identical REST+SSE behavior. All existing tests pass unchanged.
---
## 7. Verification
1. **Backward compat**: `npm run test` across sdk-typescript and webui — zero
test changes needed. `new DaemonClient({ baseUrl, token })` = identical behavior.
2. **RestSseTransport extraction**: bit-for-bit equivalent SSE behavior confirmed
by existing test suite.
3. **AcpWsTransport**: integration test connecting to real daemon via WS. Verify:
- `subscribeEvents` yields same `DaemonEvent` shapes as REST SSE
- prompt 202/200 branching works with synthesized Response
- permission vote round-trips correctly
- `connected` transitions to `false` on WS drop
- abort signal on prompt → WS sends session/cancel RPC
4. **AcpHttpTransport**: same verification as WS but over HTTP+SSE.
5. **Auto-detect**: `negotiate()` returns best transport; fallback to REST on WS failure.
6. **Runtime fallback**: `AutoReconnectTransport` catches `DaemonTransportClosedError`,
rebuilds transport, consumer calls `session/load` for resync.
7. **Provider**: `DaemonWorkspaceProvider` with `transport` prop — ChatView +
TerminalView both read from single store.
8. **End-to-end**: Third-party passes `transport={new AcpWsTransport(url, token)}`
to `DaemonWorkspaceProvider`. All SDK hooks and transcript store work unchanged.
---
## 8. Risks
| Risk | Mitigation |
|------|-----------|
| URL→JSON-RPC mapping table maintenance | Table co-located with transport; daemon route changes require transport update |
| ACP WS synthesized Response fidelity | Provide `syntheticResponse(status, json)` helper; document contract (`.json()`, `.text()`, `.status`, `.body?.cancel()`) |
| `DaemonEvent.id` monotonicity for WS | ACP server's JSON-RPC notifications carry event id; transport surfaces it directly |
| Prompt 202 vs 200 for WS | Transport maps JSON-RPC response → 200 with result body (blocking path); events still flow via `subscribeEvents` |
| WS connection drop detection | `connected: boolean` + `DaemonTransportClosedError` thrown from `fetch` |

View file

@ -143,6 +143,7 @@ const CONN_ROUTED_METHODS = new Set<string>([
'session/resume',
'session/list',
'session/close',
'session/fork',
...ALL_QWEN_VENDOR_METHODS,
]);
@ -415,6 +416,62 @@ export class AcpDispatcher {
}
}
/**
* Extract ACP-standard `SessionModelState` from configOptions.
* ConfigOptions carry model info as `{ category: 'model', type: 'select',
* currentValue, options }`. Maps to `{ currentModelId, availableModels }`.
*/
private extractModelState(
configOptions: unknown[] | undefined,
): { currentModelId: string; availableModels: unknown[] } | undefined {
if (!configOptions) return undefined;
const modelOpt = configOptions.find(
(o) =>
typeof o === 'object' &&
o !== null &&
(o as Record<string, unknown>)['category'] === 'model',
) as Record<string, unknown> | undefined;
if (!modelOpt) return undefined;
const currentModelId = String(modelOpt['currentValue'] ?? '');
const options = Array.isArray(modelOpt['options'])
? modelOpt['options']
: [];
return {
currentModelId,
availableModels: options.map((opt: unknown) => {
const o = opt as Record<string, unknown>;
return { id: String(o['value'] ?? o['id'] ?? '') };
}),
};
}
/**
* Extract ACP-standard `SessionModeState` from configOptions.
* ConfigOptions carry mode info as `{ category: 'mode', type: 'select',
* currentValue, options }`. Maps to `{ currentModeId, availableModes }`.
*/
private extractModeState(
configOptions: unknown[] | undefined,
): { currentModeId: string; availableModes: unknown[] } | undefined {
if (!configOptions) return undefined;
const modeOpt = configOptions.find(
(o) =>
typeof o === 'object' &&
o !== null &&
(o as Record<string, unknown>)['category'] === 'mode',
) as Record<string, unknown> | undefined;
if (!modeOpt) return undefined;
const currentModeId = String(modeOpt['currentValue'] ?? '');
const options = Array.isArray(modeOpt['options']) ? modeOpt['options'] : [];
return {
currentModeId,
availableModes: options.map((opt: unknown) => {
const o = opt as Record<string, unknown>;
return { id: String(o['value'] ?? o['id'] ?? '') };
}),
};
}
/**
* Cancel a permission request the client abandoned (closed its stream /
* connection before voting), so the bridge isn't left blocked. Invoked
@ -594,30 +651,14 @@ export class AcpDispatcher {
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;
}
// ACP standard: session/new MUST create a new isolated session.
// Always use sessionScope 'thread' regardless of client params.
// The REST surface (POST /session) supports 'single' for
// backward compat, but the ACP endpoint follows the standard.
const session = await this.bridge.spawnOrAttach({
workspaceCwd: cwd,
clientId: conn.clientId,
...(rawScope !== undefined
? { sessionScope: rawScope as 'single' | 'thread' }
: {}),
sessionScope: 'thread',
});
// Teardown raced the spawn: the connection was destroyed while the
// bridge call was in flight, so nothing will tear this session down.
@ -634,9 +675,16 @@ export class AcpDispatcher {
this.killOrphanSession(session.sessionId);
return;
}
// Build ACP-standard models/modes from configOptions.
// configOptions carry model/mode as category-tagged entries;
// the standard also expects top-level models/modes objects.
const models = this.extractModelState(configOptions);
const modes = this.extractModeState(configOptions);
this.replyConn(conn, id, {
sessionId: session.sessionId,
...(configOptions ? { configOptions } : {}),
...(models ? { models } : {}),
...(modes ? { modes } : {}),
});
return;
}
@ -722,7 +770,16 @@ export class AcpDispatcher {
}
conn.getOrCreateSession(sessionId).clientId = restored.clientId;
conn.ownSession(sessionId);
this.replyConn(conn, id, restored.state ?? {});
// ACP standard: load/resume response includes configOptions + models + modes
const loadConfigOptions = await this.configOptionsFor(sessionId);
const loadModels = this.extractModelState(loadConfigOptions);
const loadModes = this.extractModeState(loadConfigOptions);
this.replyConn(conn, id, {
...(restored.state ?? {}),
...(loadConfigOptions ? { configOptions: loadConfigOptions } : {}),
...(loadModels ? { models: loadModels } : {}),
...(loadModes ? { modes: loadModes } : {}),
});
return;
}
@ -787,6 +844,46 @@ export class AcpDispatcher {
return;
}
// ACP standard: session/fork — create a branched copy of an existing
// session. Maps to bridge.branchSession().
case 'session/fork': {
const sessionId = String(params['sessionId'] ?? '');
if (!sessionId) {
if (id !== undefined) {
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`sessionId` is required'),
);
}
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const ctx = this.sessionCtx(conn, sessionId, loopback);
const result = await this.bridge.branchSession(
sessionId,
{
name:
typeof params['name'] === 'string' ? params['name'] : undefined,
},
ctx,
);
if (conn.destroyed) {
this.killOrphanSession(result.sessionId);
return;
}
conn.getOrCreateSession(result.sessionId).clientId = result.clientId;
conn.ownSession(result.sessionId);
const configOptions = await this.configOptionsFor(result.sessionId);
const models = this.extractModelState(configOptions);
const modes = this.extractModeState(configOptions);
this.replyConn(conn, id, {
sessionId: result.sessionId,
...(configOptions ? { configOptions } : {}),
...(models ? { models } : {}),
...(modes ? { modes } : {}),
});
return;
}
case 'session/cancel': {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;
@ -893,6 +990,82 @@ export class AcpDispatcher {
return;
}
// ACP standard: session/set_mode — dedicated method for mode changes.
// Maps to the same bridge call as set_config_option with configId='mode'.
case 'session/set_mode': {
const sessionId = String(params['sessionId'] ?? '');
if (!sessionId) {
if (id !== undefined)
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`sessionId` is required'),
);
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const modeId = String(params['modeId'] ?? '');
if (!modeId || !APPROVAL_MODES.includes(modeId as ApprovalMode)) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(
id,
RPC.INVALID_PARAMS,
`invalid modeId "${modeId}" (expected one of: ${APPROVAL_MODES.join(', ')})`,
),
);
}
return;
}
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionApprovalMode(
sessionId,
modeId as ApprovalMode,
{ persist: false },
ctx,
);
this.replySession(conn, sessionId, id, {});
return;
}
// ACP standard (unstable): session/set_model — dedicated method for
// model changes. Maps to the same bridge call as set_config_option
// with configId='model'.
case 'session/set_model': {
const sessionId = String(params['sessionId'] ?? '');
if (!sessionId) {
if (id !== undefined)
conn.sendConn(
error(id, RPC.INVALID_PARAMS, '`sessionId` is required'),
);
return;
}
if (!this.requireOwned(conn, sessionId, id)) return;
const modelId = String(params['modelId'] ?? '');
if (!modelId) {
if (id !== undefined) {
this.replySession(
conn,
sessionId,
id,
undefined,
error(id, RPC.INVALID_PARAMS, '`modelId` is required'),
);
}
return;
}
const ctx = this.sessionCtx(conn, sessionId, loopback);
await this.bridge.setSessionModel(
sessionId,
{ modelId, sessionId },
ctx,
);
this.replySession(conn, sessionId, id, {});
return;
}
case `${QWEN_METHOD_NS}session/heartbeat`: {
const sessionId = String(params['sessionId'] ?? '');
if (!this.requireOwned(conn, sessionId, id)) return;

View file

@ -1167,27 +1167,26 @@ describe('ACP Streamable HTTP transport (over the wire)', () => {
expect(bridge.lastApprovalMode).toBeUndefined();
});
it('session/new forwards sessionScope; rejects invalid scope', async () => {
it('session/new always uses thread scope (ACP standard compliance)', async () => {
// ACP standard: session/new MUST create a new isolated session.
// sessionScope param is ignored; bridge always gets 'thread'.
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' },
params: { sessionScope: 'single' }, // ignored
});
const [bad] = (await got) as Array<{ error: { code: number } }>;
expect(bad.error.code).toBe(-32602);
// valid scope → forwarded to bridge
await new Promise((r) => setTimeout(r, 30));
expect(bridge.lastSpawnScope).toBe('thread');
// Even 'bogus' is ignored (not rejected) — param is simply not read
const c2 = await initialize();
await post(c2, {
jsonrpc: '2.0',
id: 44,
method: 'session/new',
params: { sessionScope: 'thread' },
params: { sessionScope: 'bogus' },
});
await new Promise((r) => setTimeout(r, 30));
expect(bridge.lastSpawnScope).toBe('thread');

View file

@ -1395,6 +1395,12 @@ export function createServeApp(
// Surface the bound workspace so clients can detect mismatch
// pre-flight and omit `cwd` on `POST /session`.
workspaceCwd: boundWorkspace,
// Advertise supported transport families so SDK clients can
// auto-negotiate the best available transport via
// `negotiateTransport()`. REST is always available; future PRs
// will add 'acp-http' / 'acp-ws' entries when the corresponding
// routes are wired.
transports: ['rest'],
// Active mediation policy under the `policy` namespace.
policy: { permission: bridge.permissionPolicy },
limits: {

View file

@ -237,6 +237,14 @@ export interface CapabilitiesEnvelope {
* current server code always populates it.
*/
workspaceCwd?: string;
/**
* Transport families this daemon supports. Always includes `'rest'`;
* future builds may add `'acp-http'` and/or `'acp-ws'`. SDK clients
* use `negotiateTransport()` to auto-select the best available.
* Additive older daemons omit this field; SDK consumers should
* treat absence as `['rest']`.
*/
transports?: string[];
/**
* Daemon-policy namespace. Active values for
* cross-cutting daemon coordination policies that don't fit on a

View file

@ -20,7 +20,10 @@ import esbuild from 'esbuild';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const rootDir = join(__dirname, '..');
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 116 * 1024;
// Budget includes the DaemonTransport interface + DaemonTransportClosedError +
// RestSseTransport (default transport, constructed by DaemonClient).
// Bumped from 116KB to 118KB for the transport abstraction layer (~1.5KB).
const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 118 * 1024;
rmSync(join(rootDir, 'dist'), { recursive: true, force: true });
mkdirSync(join(rootDir, 'dist'), { recursive: true });

View file

@ -0,0 +1,205 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
// ---------------------------------------------------------------------------
// JSON-RPC notification shape
// ---------------------------------------------------------------------------
/**
* A JSON-RPC 2.0 notification (no `id` field). ACP transports receive
* these on the wire and must convert them to `DaemonEvent`.
*/
export interface JsonRpcNotification {
jsonrpc: '2.0';
method: string;
params?: Record<string, unknown>;
}
// ---------------------------------------------------------------------------
// AcpEventDenormalizer
// ---------------------------------------------------------------------------
/**
* Monotonic id generator for ACP-sourced events. ACP notifications do
* not carry a per-session monotonic id the way REST SSE frames do.
* The denormalizer stamps a local monotonic id so the SDK's
* `reduceDaemonSessionEvent` / `advanceLastEventId` contract is
* satisfied. These ids are NOT compatible with the REST daemon's
* `Last-Event-ID` replay `AcpWsTransport.supportsReplay = false`
* reflects this.
*/
let nextSyntheticId = 1;
/**
* Convert an ACP JSON-RPC notification into a `DaemonEvent`.
*
* Mapping rules:
* - `session/update` notification `DaemonEvent` with the `type`
* field read from `params.type`. The full `params` object becomes
* `data`.
* - `_qwen/notify` notification `DaemonEvent` with `type` and
* `data` read from `params`.
* - Notifications with `method` matching a known daemon event type
* directly (e.g. `memory_changed`, `agent_changed`) are passed
* through with `params` as `data`.
*
* Returns `undefined` for notifications that don't map to any known
* `DaemonEvent` shape (the caller silently drops them).
*/
export function denormalizeAcpNotification(
notification: JsonRpcNotification,
): DaemonEvent | undefined {
const params = notification.params ?? {};
// Primary path: session/update carries the event payload inside
// params.update.sessionUpdate. The daemon's `translateEvent` sends:
// { method: "session/update", params: { sessionId, update: { sessionUpdate: "<type>", ...fields } } }
// The `update` object (or `update.sessionUpdate` string for the type)
// is the canonical event data.
if (notification.method === 'session/update') {
const rawUpdate = isRecord(params['update']) ? params['update'] : undefined;
// New format (current daemon): type at update.sessionUpdate, data
// is the update object itself.
// Legacy format: type at params.type, data at params.data.
const type = rawUpdate
? typeof rawUpdate['sessionUpdate'] === 'string'
? rawUpdate['sessionUpdate']
: undefined
: typeof params['type'] === 'string'
? params['type']
: undefined;
if (!type || type.length === 0) return undefined;
// Build the event data payload. For the new format, spread the
// update object and inject sessionId so the WS transport's
// per-session filter (event.data.sessionId) works. For the
// legacy format, use params.data or fall back to params itself.
let data: unknown;
if (rawUpdate) {
const d: Record<string, unknown> = { ...rawUpdate };
if (typeof params['sessionId'] === 'string') {
d['sessionId'] = params['sessionId'];
}
data = d;
} else {
data = params['data'] ?? params;
}
// _meta may live inside the update object or at params level.
const meta = rawUpdate
? isRecord(rawUpdate['_meta'])
? rawUpdate['_meta']
: isRecord(params['_meta'])
? params['_meta']
: undefined
: isRecord(params['_meta'])
? params['_meta']
: undefined;
return {
id: nextSyntheticId++,
v: 1,
type,
data,
_meta: meta,
originatorClientId:
typeof params['originatorClientId'] === 'string'
? params['originatorClientId']
: undefined,
};
}
// Extension path: _qwen/notify is a generic notification envelope.
if (notification.method === '_qwen/notify') {
const type = params['type'];
if (typeof type !== 'string' || type.length === 0) return undefined;
return {
id: nextSyntheticId++,
v: 1,
type,
data: params['data'] ?? params,
_meta: isRecord(params['_meta']) ? params['_meta'] : undefined,
originatorClientId:
typeof params['originatorClientId'] === 'string'
? params['originatorClientId']
: undefined,
};
}
// Workspace events arrive as top-level methods matching the event
// type name directly (e.g. `memory_changed`, `agent_changed`,
// `auth_device_flow_started`).
if (notification.method.includes('_') || notification.method.includes('/')) {
// Extract the event type: for `namespace/event` use the event
// part; for `snake_case` names use the full method.
const type = notification.method.includes('/')
? notification.method.split('/').pop()!
: notification.method;
return {
id: nextSyntheticId++,
v: 1,
type,
data: params,
_meta: isRecord(params['_meta']) ? params['_meta'] : undefined,
originatorClientId:
typeof params['originatorClientId'] === 'string'
? params['originatorClientId']
: undefined,
};
}
return undefined;
}
/**
* Filter a mixed stream of ACP notifications, yielding only
* `DaemonEvent`s for the specified session. Events without a
* `sessionId` field in their data are considered workspace-scoped
* and are always yielded (they fan out to all sessions).
*/
export function* filterEventsBySession(
events: Iterable<DaemonEvent>,
sessionId: string,
): Generator<DaemonEvent> {
for (const event of events) {
const data = event.data;
if (isRecord(data)) {
const eventSessionId = data['sessionId'];
// If the event has a sessionId, only yield if it matches.
// Events without sessionId are workspace-scoped and pass through.
if (
typeof eventSessionId === 'string' &&
eventSessionId.length > 0 &&
eventSessionId !== sessionId
) {
continue;
}
}
yield event;
}
}
/**
* Reset the synthetic id counter. Exposed for testing only.
* @internal
*/
export function _resetSyntheticIdCounter(): void {
nextSyntheticId = 1;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
(Object.getPrototypeOf(value) === Object.prototype ||
Object.getPrototypeOf(value) === null)
);
}

View file

@ -0,0 +1,636 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
import type {
DaemonTransport,
DaemonTransportFetchOptions,
DaemonTransportSubscribeOptions,
} from './DaemonTransport.js';
import { DaemonTransportClosedError } from './DaemonTransport.js';
import { parseSseStream } from './sse.js';
import type { JsonRpcNotification } from './AcpEventDenormalizer.js';
import {
matchRoute,
synthesizeResponse,
jsonRpcErrorToHttpStatus,
isRecord,
composeAbortSignals,
mergeHeaders,
} from './acpTransportUtils.js';
// ---------------------------------------------------------------------------
// JSON-RPC types
// ---------------------------------------------------------------------------
interface JsonRpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
// ---------------------------------------------------------------------------
// Pending request tracking
// ---------------------------------------------------------------------------
interface PendingRequest {
resolve: (response: JsonRpcResponse) => void;
reject: (error: Error) => void;
}
// ---------------------------------------------------------------------------
// AcpHttpTransport
// ---------------------------------------------------------------------------
/**
* HTTP+SSE ACP transport. Sends JSON-RPC requests via `POST /acp`
* and receives responses + notifications via a connection-scoped SSE
* stream at `GET /acp`.
*
* Lazy-init: the first `fetch()` call sends `POST /acp { initialize }`
* (which returns 200 with the initialize result inline), then opens a
* connection-scoped SSE stream at `GET /acp` for subsequent responses.
*
* Subsequent `POST /acp` requests return 202 (ack); the real JSON-RPC
* response rides the connection-scoped SSE stream. Responses are
* correlated by `id` using a `Map<id, {resolve, reject}>`.
*
* Session events are received via a session-scoped SSE stream at
* `GET /acp` with appropriate headers (session filtering).
*/
export class AcpHttpTransport implements DaemonTransport {
private readonly baseUrl: string;
private readonly token: string | undefined;
private readonly _fetch: typeof globalThis.fetch;
private _disposed = false;
private _initialized = false;
private initPromise: Promise<void> | undefined = undefined;
private nextId = 1;
private initResult: unknown = undefined;
/** Connection id returned by the ACP initialize handshake. */
private connectionId: string | undefined;
/** Pending requests awaiting their JSON-RPC response on the SSE stream. */
private readonly pending = new Map<number, PendingRequest>();
/** Abort controller for the connection-scoped SSE stream. */
private connStreamAbort: AbortController | undefined;
readonly type = 'acp-http' as const;
readonly supportsReplay = true;
constructor(
baseUrl: string,
token: string | undefined,
fetchFn: typeof globalThis.fetch,
) {
this.baseUrl = baseUrl;
this.token = token;
this._fetch = fetchFn;
}
get connected(): boolean {
return this._initialized && !this._disposed;
}
async fetch(
url: string,
init: RequestInit,
_opts?: DaemonTransportFetchOptions,
): Promise<Response> {
if (this._disposed) throw new DaemonTransportClosedError();
await this.ensureInitialized();
const parsedUrl = new URL(url);
const path = parsedUrl.pathname;
let body: unknown;
if (typeof init.body === 'string') {
try {
body = JSON.parse(init.body);
} catch {
body = init.body;
}
}
const httpMethod = (init.method ?? 'GET').toUpperCase();
const match = matchRoute(path, httpMethod);
if (!match) {
return synthesizeResponse(404, {
error: `No ACP mapping for ${httpMethod} ${path}`,
});
}
const { mapping, segments } = match;
if (mapping.method === '_capabilities') {
return synthesizeResponse(200, this.initResult ?? { v: 1 });
}
// For notifications, send via POST /acp and return 204.
if (mapping.notification) {
const params = mapping.extractParams(segments, body, httpMethod);
await this.sendNotification(mapping.method, params, init.headers);
return synthesizeResponse(204, null);
}
// Normal request: POST /acp with the JSON-RPC request body.
// The POST returns 202 (ack); the real response rides the SSE stream.
const params = mapping.extractParams(segments, body, httpMethod);
const response = await this.sendRequest(
mapping.method,
params,
init.signal ?? undefined,
init.headers,
);
if (response.error) {
// Recover the original HTTP status when available (set by our
// sendRequest wrapper), otherwise fall back to the JSON-RPC
// error-code → HTTP-status mapping.
const errorData = response.error.data;
const httpStatus =
isRecord(errorData) && typeof errorData['httpStatus'] === 'number'
? errorData['httpStatus']
: jsonRpcErrorToHttpStatus(response.error.code);
return synthesizeResponse(httpStatus, {
error: response.error.message,
...(response.error.data != null ? { data: response.error.data } : {}),
});
}
return synthesizeResponse(200, response.result);
}
async *subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
if (this._disposed) throw new DaemonTransportClosedError();
await this.ensureInitialized();
// Open a session-scoped SSE stream. For ACP HTTP, we use
// the daemon's per-session SSE endpoint — same URL as REST
// because ACP HTTP sessions still expose SSE for events.
const headers: Record<string, string> = {
Accept: 'text/event-stream',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (opts.lastEventId !== undefined) {
headers['Last-Event-ID'] = String(opts.lastEventId);
}
// Connect-phase timeout.
const connectCtrl = new AbortController();
let connectTimer: ReturnType<typeof setTimeout> | undefined;
if (opts.connectTimeoutMs && Number.isFinite(opts.connectTimeoutMs)) {
connectTimer = setTimeout(
() =>
connectCtrl.abort(
new DOMException('Initial connect timed out', 'TimeoutError'),
),
opts.connectTimeoutMs,
);
if (
typeof connectTimer === 'object' &&
connectTimer &&
'unref' in connectTimer
) {
(connectTimer as { unref: () => void }).unref();
}
}
const fetchSignal = opts.signal
? composeAbortSignals([opts.signal, connectCtrl.signal])
: connectCtrl.signal;
let url = `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`;
if (opts.maxQueued !== undefined) {
url += `?maxQueued=${encodeURIComponent(String(opts.maxQueued))}`;
}
let res: Response;
try {
res = await this._fetch(url, { headers, signal: fetchSignal });
} finally {
if (connectTimer !== undefined) clearTimeout(connectTimer);
}
if (!res.ok) {
let body: unknown;
try {
const text = await res.text();
try {
body = JSON.parse(text);
} catch {
body = text;
}
} catch {
/* body unreadable */
}
const detail =
body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw Object.assign(new Error(`GET /session/:id/events: ${detail}`), {
status: res.status,
body,
});
}
const ct = res.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('text/event-stream')) {
try {
await res.body?.cancel();
} catch {
/* body already consumed or no body */
}
throw Object.assign(
new Error(
`GET /session/:id/events: expected content-type text/event-stream, got "${ct}"`,
),
{ status: res.status, body: ct },
);
}
if (!res.body) {
throw new Error('SSE response has no body');
}
yield* parseSseStream(res.body, opts.signal);
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this._initialized = false;
// Tear down the connection-scoped SSE stream.
this.connStreamAbort?.abort();
this.connStreamAbort = undefined;
// Reject all pending requests.
for (const [id, entry] of this.pending) {
entry.reject(new DaemonTransportClosedError());
this.pending.delete(id);
}
}
// -- Internal ----------------------------------------------------------
private async ensureInitialized(): Promise<void> {
if (this._initialized) return;
if (this.initPromise) {
await this.initPromise;
return;
}
// Reset on failure so the next call retries instead of parking
// on a permanently rejected promise.
this.initPromise = this.initialize().catch((err) => {
this.initPromise = undefined;
throw err;
});
await this.initPromise;
}
private async initialize(): Promise<void> {
const initReq: JsonRpcRequest = {
jsonrpc: '2.0',
id: this.nextId++,
method: 'initialize',
params: {
clientInfo: { name: 'qwen-code-sdk', version: '1.0.0' },
},
};
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
const res = await this._fetch(`${this.baseUrl}/acp`, {
method: 'POST',
headers,
body: JSON.stringify(initReq),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
throw new Error(`ACP initialize failed: HTTP ${res.status} ${text}`);
}
const response = (await res.json()) as JsonRpcResponse;
if (response.error) {
throw new Error(`ACP initialize error: ${response.error.message}`);
}
// Extract connectionId: try the response header first (canonical),
// then the JSON body at agentCapabilities._meta.qwen.connectionId,
// then the legacy path _meta.qwen.connectionId.
const result = response.result;
const headerConnId = res.headers.get('acp-connection-id');
this.connectionId =
(headerConnId || undefined) ??
extractConnectionId(result, [
'agentCapabilities',
'_meta',
'qwen',
'connectionId',
]) ??
extractConnectionId(result, ['_meta', 'qwen', 'connectionId']);
this.initResult = result;
this._initialized = true;
// Fetch REST /capabilities separately so capabilities() returns the
// right shape (the ACP initialize result has a different schema).
try {
const capHeaders: Record<string, string> = {};
if (this.token) {
capHeaders['Authorization'] = `Bearer ${this.token}`;
}
const capRes = await this._fetch(`${this.baseUrl}/capabilities`, {
headers: capHeaders,
});
if (capRes.ok) {
this.initResult = await capRes.json();
}
} catch {
// Non-fatal — initResult stays as the ACP initialize result.
}
}
/**
* Open a connection-scoped SSE stream at `GET /acp` with the
* `Acp-Connection-Id` header. Incoming JSON-RPC responses are
* matched to pending requests by `id`.
*/
private openConnStream(): void {
const abort = new AbortController();
this.connStreamAbort = abort;
const headers: Record<string, string> = {
Accept: 'text/event-stream',
};
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (this.connectionId) {
headers['Acp-Connection-Id'] = this.connectionId;
}
// Fire-and-forget: pump the SSE stream in the background.
void this.pumpConnStream(headers, abort.signal).catch(() => {
// Stream ended or errored — reject any remaining pending requests.
if (!this._disposed) {
for (const [id, entry] of this.pending) {
entry.reject(new Error('Connection SSE stream closed unexpectedly'));
this.pending.delete(id);
}
}
});
}
private async pumpConnStream(
headers: Record<string, string>,
signal: AbortSignal,
): Promise<void> {
const res = await this._fetch(`${this.baseUrl}/acp`, {
headers,
signal,
});
if (!res.ok || !res.body) return;
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = '';
// Build an abort-aware read helper: `reader.read()` does not
// respect the signal on its own (the fetch mock may return a
// pre-built ReadableStream that isn't wired to the signal).
// Race each read against a signal-based rejection so dispose()
// can unblock a hanging `reader.read()`.
const abortPromise = new Promise<never>((_, reject) => {
if (signal.aborted) {
reject(signal.reason ?? new DOMException('Aborted', 'AbortError'));
return;
}
signal.addEventListener(
'abort',
() =>
reject(signal.reason ?? new DOMException('Aborted', 'AbortError')),
{ once: true },
);
});
try {
while (!signal.aborted) {
const { value, done } = await Promise.race([
reader.read(),
abortPromise,
]);
if (done) break;
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) continue;
try {
const parsed = JSON.parse(
dataLine.slice('data: '.length),
) as JsonRpcResponse;
if (
typeof parsed === 'object' &&
parsed !== null &&
'id' in parsed
) {
const pending = this.pending.get(parsed.id);
if (pending) {
this.pending.delete(parsed.id);
pending.resolve(parsed);
}
}
} catch {
// Ignore unparseable frames (heartbeats, etc.)
}
}
}
} catch {
// Abort or read error — fall through to cleanup.
} finally {
// Best-effort cancel with a timeout guard — some ReadableStream
// implementations (especially in test environments) can hang on
// cancel() if the underlying source never closes.
try {
reader.cancel().catch(() => {});
} catch {
/* already closed */
}
}
}
private async sendNotification(
method: string,
params: Record<string, unknown>,
callerHeaders?: HeadersInit,
): Promise<void> {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method,
params,
};
const transportHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
transportHeaders['Authorization'] = `Bearer ${this.token}`;
}
if (this.connectionId) {
transportHeaders['Acp-Connection-Id'] = this.connectionId;
}
// Merge caller headers (from init.headers) with transport headers.
const headers = mergeHeaders(transportHeaders, callerHeaders);
await this._fetch(`${this.baseUrl}/acp`, {
method: 'POST',
headers,
body: JSON.stringify(notification),
});
}
/**
* Ensure the connection-scoped SSE stream is open. Called lazily on
* the first sendRequest that needs it (i.e. when the server returns
* 202, meaning the real response rides the SSE stream).
*/
private ensureConnStream(): void {
if (this.connStreamAbort) return;
this.openConnStream();
}
/**
* Send a JSON-RPC request via `POST /acp` (returns 202 ack) and wait
* for the matching response on the connection-scoped SSE stream.
*/
private async sendRequest(
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
callerHeaders?: HeadersInit,
): Promise<JsonRpcResponse> {
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id: this.nextId++,
method,
params,
};
const transportHeaders: Record<string, string> = {
'Content-Type': 'application/json',
};
if (this.token) {
transportHeaders['Authorization'] = `Bearer ${this.token}`;
}
if (this.connectionId) {
transportHeaders['Acp-Connection-Id'] = this.connectionId;
}
// Merge caller headers with transport headers.
const headers = mergeHeaders(transportHeaders, callerHeaders);
const res = await this._fetch(`${this.baseUrl}/acp`, {
method: 'POST',
headers,
body: JSON.stringify(req),
signal,
});
if (!res.ok) {
// POST itself failed — return a synthetic error response.
const text = await res.text().catch(() => '');
return {
jsonrpc: '2.0',
id: req.id,
error: {
code: -res.status,
message: `HTTP ${res.status}: ${text}`,
data: { httpStatus: res.status },
},
};
}
// If the server returned 200 with a JSON body (e.g. a server
// that doesn't use 202+SSE), consume it directly.
const ct = res.headers.get('content-type') ?? '';
if (res.status === 200 && ct.includes('application/json')) {
return (await res.json()) as JsonRpcResponse;
}
// 202 (ack) — the real response rides the connection-scoped SSE
// stream. Ensure it's open and register the pending request.
this.ensureConnStream();
const responsePromise = new Promise<JsonRpcResponse>((resolve, reject) => {
this.pending.set(req.id, { resolve, reject });
});
// Handle abort signal: if the caller aborts, reject the pending
// request and clean up.
if (signal) {
const abortHandler = () => {
const entry = this.pending.get(req.id);
if (entry) {
this.pending.delete(req.id);
entry.reject(
signal.reason ?? new DOMException('Aborted', 'AbortError'),
);
}
};
signal.addEventListener('abort', abortHandler, { once: true });
}
return responsePromise;
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Walk an object along a key path and return the leaf value if it's a
* string, otherwise `undefined`.
*/
function extractConnectionId(obj: unknown, path: string[]): string | undefined {
let cur: unknown = obj;
for (const key of path) {
if (!isRecord(cur)) return undefined;
cur = cur[key];
}
return typeof cur === 'string' ? cur : undefined;
}

View file

@ -0,0 +1,615 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
import type {
DaemonTransport,
DaemonTransportFetchOptions,
DaemonTransportSubscribeOptions,
} from './DaemonTransport.js';
import { DaemonTransportClosedError } from './DaemonTransport.js';
import {
denormalizeAcpNotification,
type JsonRpcNotification,
} from './AcpEventDenormalizer.js';
import {
matchRoute,
synthesizeResponse,
jsonRpcErrorToHttpStatus,
isRecord,
} from './acpTransportUtils.js';
// ---------------------------------------------------------------------------
// JSON-RPC message types
// ---------------------------------------------------------------------------
interface JsonRpcRequest {
jsonrpc: '2.0';
id: number;
method: string;
params?: Record<string, unknown>;
}
interface JsonRpcResponse {
jsonrpc: '2.0';
id: number;
result?: unknown;
error?: { code: number; message: string; data?: unknown };
}
interface PendingRequest {
resolve: (value: JsonRpcResponse) => void;
reject: (reason: Error) => void;
signal?: AbortSignal;
sessionId?: string;
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
/** Maximum queued events per generator before drop-oldest. */
const MAX_GENERATOR_QUEUE_SIZE = 256;
/** Default timeout for the initialize handshake (ms). */
const INIT_TIMEOUT_MS = 30_000;
// ---------------------------------------------------------------------------
// AcpWsTransport
// ---------------------------------------------------------------------------
/**
* WebSocket-based ACP transport. Multiplexes all requests over a
* single WS connection using JSON-RPC 2.0 framing.
*
* Lazy-init: the WebSocket connection is established on the first
* `fetch()` call. An `initialize` JSON-RPC request is sent on
* connect and its result is cached for `GET /capabilities` requests.
*
* **Browser limitation**: The browser WebSocket API does not support
* custom headers on the upgrade request. In Node (>=22), the token
* is passed via an `Authorization` header. In browser environments,
* the transport connects without auth headers callers must rely on
* token-less loopback access or a proxy that injects auth. A future
* enhancement may use a subprotocol or query-param based token
* exchange for browser contexts.
*/
export class AcpWsTransport implements DaemonTransport {
private readonly wsUrl: string;
private readonly token: string | undefined;
private ws: WebSocket | null = null;
private _connected = false;
private _disposed = false;
private nextId = 1;
private readonly pending = new Map<number, PendingRequest>();
/**
* Shared notification stream. Every JSON-RPC notification that
* arrives on the WS is denormalized into a `DaemonEvent` and
* pushed into this array of listeners. `subscribeEvents` registers
* a per-session filter.
*/
private readonly notificationListeners = new Set<
(event: DaemonEvent) => void
>();
/**
* Active async generators. Aborted when the WS closes so parked
* generators throw `DaemonTransportClosedError` instead of hanging.
*/
private readonly _activeGenerators = new Set<AbortController>();
/** Cached `initialize` result for `GET /capabilities`. */
private initResult: unknown = undefined;
private initPromise: Promise<void> | undefined = undefined;
readonly type = 'acp-ws' as const;
readonly supportsReplay = false;
constructor(wsUrl: string, token?: string) {
this.wsUrl = wsUrl;
this.token = token;
}
get connected(): boolean {
return this._connected && !this._disposed;
}
async fetch(
url: string,
init: RequestInit,
_opts?: DaemonTransportFetchOptions,
): Promise<Response> {
if (this._disposed) throw new DaemonTransportClosedError();
// Ensure WS is connected and initialized.
await this.ensureConnected();
// Parse the URL to extract the path relative to the base.
const parsedUrl = new URL(url);
const path = parsedUrl.pathname;
// Parse the body if present.
let body: unknown;
if (typeof init.body === 'string') {
try {
body = JSON.parse(init.body);
} catch {
body = init.body;
}
}
const httpMethod = (init.method ?? 'GET').toUpperCase();
// Match against the route table.
const match = matchRoute(path, httpMethod);
if (!match) {
// Unrecognized route — fall through with an error response.
return synthesizeResponse(404, {
error: `No ACP mapping for ${httpMethod} ${path}`,
});
}
const { mapping, segments } = match;
// Special handling for capabilities — return cached init result.
if (mapping.method === '_capabilities') {
return synthesizeResponse(200, this.initResult ?? { v: 1 });
}
// For notifications, send and return 204 immediately.
if (mapping.notification) {
const params = mapping.extractParams(segments, body, httpMethod);
const notifMeta = extractHeaderMeta(init.headers);
if (notifMeta) {
params._meta = {
...(isRecord(params._meta) ? params._meta : {}),
...notifMeta,
};
}
this.sendNotification(mapping.method, params);
return synthesizeResponse(204, null);
}
// Normal request-response.
const params = mapping.extractParams(segments, body, httpMethod);
// Forward per-request headers as JSON-RPC _meta so the server can
// see X-Qwen-Client-Id and similar metadata that HTTP transports
// carry natively.
const headerMeta = extractHeaderMeta(init.headers);
if (headerMeta) {
params._meta = {
...(isRecord(params._meta) ? params._meta : {}),
...headerMeta,
};
}
const response = await this.sendRequest(
mapping.method,
params,
init.signal ?? undefined,
// Extract sessionId for abort→cancel forwarding.
typeof (params as { sessionId?: unknown }).sessionId === 'string'
? (params as { sessionId: string }).sessionId
: undefined,
);
if (response.error) {
const status = jsonRpcErrorToHttpStatus(response.error.code);
return synthesizeResponse(status, {
error: response.error.message,
...(response.error.data != null ? { data: response.error.data } : {}),
});
}
return synthesizeResponse(200, response.result);
}
async *subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
if (this._disposed) throw new DaemonTransportClosedError();
await this.ensureConnected();
// Track this generator so we can abort it when the WS closes.
const genAbort = new AbortController();
this._activeGenerators.add(genAbort);
// Create a queue that the notification listener pushes into.
// Capped at MAX_GENERATOR_QUEUE_SIZE with drop-oldest to prevent
// unbounded memory growth if the consumer is slow.
const queue: DaemonEvent[] = [];
let resolve: (() => void) | null = null;
let done = false;
const listener = (event: DaemonEvent) => {
// Filter by session: if the event has a sessionId, only yield
// if it matches. Workspace-scoped events (no sessionId) pass.
const data = event.data;
if (isRecord(data)) {
const evtSessionId = data['sessionId'];
if (
typeof evtSessionId === 'string' &&
evtSessionId.length > 0 &&
evtSessionId !== sessionId
) {
return;
}
}
// Drop oldest if queue is full.
if (queue.length >= MAX_GENERATOR_QUEUE_SIZE) {
queue.shift();
}
queue.push(event);
if (resolve) {
resolve();
resolve = null;
}
};
this.notificationListeners.add(listener);
// Wire abort to cleanup.
const onAbort = () => {
done = true;
this.notificationListeners.delete(listener);
if (resolve) {
resolve();
resolve = null;
}
};
if (opts.signal) {
if (opts.signal.aborted) {
onAbort();
this._activeGenerators.delete(genAbort);
return;
}
opts.signal.addEventListener('abort', onAbort, { once: true });
}
// Also wire the generator-level abort (fired on WS close).
genAbort.signal.addEventListener('abort', onAbort, { once: true });
try {
while (!done && !this._disposed) {
// Check if the generator was aborted (WS close).
if (genAbort.signal.aborted) {
throw new DaemonTransportClosedError(
'WebSocket closed while generator was active',
);
}
if (queue.length > 0) {
yield queue.shift()!;
continue;
}
// Wait for the next event.
await new Promise<void>((r) => {
resolve = r;
});
// Re-check abort after waking up.
if (genAbort.signal.aborted) {
throw new DaemonTransportClosedError(
'WebSocket closed while generator was active',
);
}
}
} finally {
this._activeGenerators.delete(genAbort);
this.notificationListeners.delete(listener);
if (opts.signal) {
opts.signal.removeEventListener('abort', onAbort);
}
genAbort.signal.removeEventListener('abort', onAbort);
}
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this._connected = false;
// Reject all pending requests.
for (const [, pending] of this.pending) {
pending.reject(new DaemonTransportClosedError());
}
this.pending.clear();
// Abort all active generators.
for (const ac of this._activeGenerators) {
ac.abort();
}
// Close the WebSocket.
if (this.ws) {
try {
this.ws.close(1000, 'transport disposed');
} catch {
/* already closed */
}
this.ws = null;
}
}
// -- Internal ----------------------------------------------------------
private async ensureConnected(): Promise<void> {
if (this._connected) return;
if (this.initPromise) {
await this.initPromise;
return;
}
// Reset on failure so the next call retries instead of parking
// on a permanently rejected promise.
this.initPromise = this.connect().catch((err) => {
this.initPromise = undefined;
throw err;
});
await this.initPromise;
}
private async connect(): Promise<void> {
return new Promise<void>((resolveConnect, rejectConnect) => {
// Pass token via Authorization header on the upgrade request.
// Node >=22 supports an options object as the second argument:
// new WebSocket(url, { headers: { ... } })
// The browser WebSocket API does NOT support custom headers —
// in browser environments we connect without auth and rely on
// loopback/proxy auth (see class JSDoc).
const isBrowser =
typeof globalThis.window !== 'undefined' &&
typeof globalThis.window.document !== 'undefined';
let ws: WebSocket;
if (isBrowser || !this.token) {
ws = new WebSocket(this.wsUrl);
} else {
// Node: cast through unknown because DOM typings only declare
// (url, protocols?) — Node accepts an options bag.
ws = new (WebSocket as unknown as new (
url: string,
opts?: { headers?: Record<string, string> },
) => WebSocket)(this.wsUrl, {
headers: { Authorization: `Bearer ${this.token}` },
});
}
this.ws = ws;
// Timeout for the initialize handshake.
const initTimeout = setTimeout(() => {
ws.close(1002, 'Initialize timeout');
rejectConnect(
new DaemonTransportClosedError(
`WebSocket initialize timed out after ${INIT_TIMEOUT_MS}ms`,
),
);
}, INIT_TIMEOUT_MS);
ws.onopen = () => {
this._connected = true;
// Send initialize request.
const initId = this.nextId++;
const initReq: JsonRpcRequest = {
jsonrpc: '2.0',
id: initId,
method: 'initialize',
params: {
clientInfo: { name: 'qwen-code-sdk', version: '1.0.0' },
},
};
this.pending.set(initId, {
resolve: (response) => {
clearTimeout(initTimeout);
this.initResult = response.result;
resolveConnect();
},
reject: (err) => {
clearTimeout(initTimeout);
rejectConnect(err);
},
});
ws.send(JSON.stringify(initReq));
};
ws.onmessage = (event) => {
let msg: Record<string, unknown>;
try {
msg = JSON.parse(
typeof event.data === 'string' ? event.data : String(event.data),
);
} catch {
return; // ignore non-JSON messages
}
// JSON-RPC response (has `id` field).
if ('id' in msg && typeof msg['id'] === 'number') {
const pending = this.pending.get(msg['id'] as number);
if (pending) {
this.pending.delete(msg['id'] as number);
pending.resolve(msg as unknown as JsonRpcResponse);
}
return;
}
// JSON-RPC notification (no `id` field, has `method`).
if (
'method' in msg &&
typeof msg['method'] === 'string' &&
msg['jsonrpc'] === '2.0'
) {
const notification = msg as unknown as JsonRpcNotification;
const daemonEvent = denormalizeAcpNotification(notification);
if (daemonEvent) {
for (const listener of this.notificationListeners) {
try {
listener(daemonEvent);
} catch {
/* swallow listener errors */
}
}
}
}
};
ws.onerror = () => {
// Node WebSocket may only fire 'error' without 'close' on
// connection refused / unreachable. Reject the connect
// promise so the caller doesn't hang forever.
if (!this._connected) {
clearTimeout(initTimeout);
rejectConnect(
new DaemonTransportClosedError('WebSocket connection failed'),
);
}
};
ws.onclose = (event) => {
clearTimeout(initTimeout);
this._connected = false;
this.ws = null;
this.initPromise = undefined;
const closeError = new DaemonTransportClosedError(
`WebSocket closed: ${event.code} ${event.reason}`,
);
// Reject all pending requests.
for (const [, pending] of this.pending) {
pending.reject(closeError);
}
this.pending.clear();
// Abort all active generators so they throw instead of parking.
for (const ac of this._activeGenerators) {
ac.abort();
}
// If we never connected, reject the connect promise.
if (!this._disposed) {
rejectConnect(closeError);
}
};
});
}
private sendNotification(
method: string,
params: Record<string, unknown>,
): void {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) return;
const msg: JsonRpcNotification = {
jsonrpc: '2.0',
method,
params,
};
this.ws.send(JSON.stringify(msg));
}
private async sendRequest(
method: string,
params: Record<string, unknown>,
signal?: AbortSignal,
sessionId?: string,
): Promise<JsonRpcResponse> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new DaemonTransportClosedError();
}
const id = this.nextId++;
const req: JsonRpcRequest = {
jsonrpc: '2.0',
id,
method,
params,
};
return new Promise<JsonRpcResponse>((resolve, reject) => {
// Wire abort signal: if the caller aborts a prompt request,
// send a cancel notification.
let onAbort: (() => void) | undefined;
if (signal) {
onAbort = () => {
this.pending.delete(id);
if (sessionId && method === 'session/prompt') {
this.sendNotification('session/cancel', { sessionId });
}
reject(new DOMException('The operation was aborted', 'AbortError'));
};
if (signal.aborted) {
onAbort();
return;
}
signal.addEventListener('abort', onAbort, { once: true });
}
this.pending.set(id, {
resolve: (response) => {
if (signal && onAbort) {
signal.removeEventListener('abort', onAbort);
}
resolve(response);
},
reject: (err) => {
if (signal && onAbort) {
signal.removeEventListener('abort', onAbort);
}
reject(err);
},
});
this.ws!.send(JSON.stringify(req));
});
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/** Headers forwarded from per-request `init.headers` into JSON-RPC `_meta`. */
const FORWARDED_HEADERS = ['x-qwen-client-id'] as const;
/**
* Extract metadata-relevant headers from `RequestInit.headers` and
* return them as a plain object suitable for merging into `_meta`.
* Returns `undefined` when no relevant headers are present.
*/
function extractHeaderMeta(
headers: HeadersInit | undefined,
): Record<string, string> | undefined {
if (!headers) return undefined;
const meta: Record<string, string> = {};
const get = (key: string): string | undefined => {
if (headers instanceof Headers) return headers.get(key) ?? undefined;
if (Array.isArray(headers)) {
const pair = headers.find(([k]) => k.toLowerCase() === key.toLowerCase());
return pair ? pair[1] : undefined;
}
// Plain object
const h = headers as Record<string, string>;
for (const k of Object.keys(h)) {
if (k.toLowerCase() === key.toLowerCase()) return h[k];
}
return undefined;
};
for (const hdr of FORWARDED_HEADERS) {
const value = get(hdr);
if (value !== undefined) {
// Normalize header name to a camelCase _meta key.
// 'x-qwen-client-id' → 'clientId'
if (hdr === 'x-qwen-client-id') {
meta['clientId'] = value;
}
}
}
return Object.keys(meta).length > 0 ? meta : undefined;
}

View file

@ -0,0 +1,158 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
import type {
DaemonTransport,
DaemonTransportFetchOptions,
DaemonTransportSubscribeOptions,
DaemonTransportType,
} from './DaemonTransport.js';
import { DaemonTransportClosedError } from './DaemonTransport.js';
import { RestSseTransport } from './RestSseTransport.js';
/**
* Factory function that creates a transport given the type hint.
* The caller supplies this to `AutoReconnectTransport` so the
* reconnect logic can recreate the preferred transport without
* importing every concrete class itself.
*/
export type TransportFactory = (
type: DaemonTransportType,
) => DaemonTransport | Promise<DaemonTransport>;
/**
* Optional wrapper transport that handles reconnection on
* `DaemonTransportClosedError`.
*
* On a transport-closed error:
* 1. Attempt to recreate the preferred transport via `factory`.
* 2. If that fails, fall back to a `RestSseTransport` (always works
* against a daemon that's still running).
* 3. Re-initialize the new transport and retry the failed call.
*
* **Session-level recovery** (re-attaching the ACP session) is NOT
* handled here the caller must `session/load` after the transport
* layer reconnects. This transport only provides transport-level
* reconnection.
*/
export class AutoReconnectTransport implements DaemonTransport {
private inner: DaemonTransport;
private readonly baseUrl: string;
private readonly token: string | undefined;
private readonly fetchFn: typeof globalThis.fetch;
private readonly preferredType: DaemonTransportType;
private readonly factory?: TransportFactory;
private _disposed = false;
/** Mutex: only one reconnect attempt at a time. */
private reconnecting: Promise<void> | undefined;
readonly supportsReplay: boolean;
constructor(opts: {
baseUrl: string;
token?: string;
fetch?: typeof globalThis.fetch;
preferredType?: DaemonTransportType;
factory?: TransportFactory;
initial?: DaemonTransport;
}) {
this.baseUrl = opts.baseUrl;
this.token = opts.token;
this.fetchFn = opts.fetch ?? globalThis.fetch.bind(globalThis);
this.preferredType = opts.preferredType ?? 'rest';
this.factory = opts.factory;
this.inner =
opts.initial ??
new RestSseTransport(this.baseUrl, this.token, this.fetchFn);
this.supportsReplay = this.inner.supportsReplay;
}
get type(): DaemonTransportType {
return this.inner.type;
}
get connected(): boolean {
return !this._disposed && this.inner.connected;
}
async fetch(
url: string,
init: RequestInit,
opts?: DaemonTransportFetchOptions,
): Promise<Response> {
if (this._disposed) throw new DaemonTransportClosedError();
try {
return await this.inner.fetch(url, init, opts);
} catch (err) {
if (err instanceof DaemonTransportClosedError && !this._disposed) {
await this.reconnect();
return this.inner.fetch(url, init, opts);
}
throw err;
}
}
async *subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
if (this._disposed) throw new DaemonTransportClosedError();
try {
yield* this.inner.subscribeEvents(sessionId, opts);
} catch (err) {
if (err instanceof DaemonTransportClosedError && !this._disposed) {
await this.reconnect();
yield* this.inner.subscribeEvents(sessionId, opts);
} else {
throw err;
}
}
}
dispose(): void {
if (this._disposed) return;
this._disposed = true;
this.inner.dispose();
}
// -- Internal ----------------------------------------------------------
private async reconnect(): Promise<void> {
// Mutex: if a reconnect is already in progress, wait for it
// instead of starting a concurrent attempt (reconnect storm).
if (this.reconnecting) return this.reconnecting;
this.reconnecting = this._doReconnect().finally(() => {
this.reconnecting = undefined;
});
return this.reconnecting;
}
private async _doReconnect(): Promise<void> {
// Dispose the old transport.
try {
this.inner.dispose();
} catch {
/* already disposed */
}
// Try preferred transport via factory.
if (this.factory) {
try {
this.inner = await this.factory(this.preferredType);
return;
} catch {
// Factory failed — fall back to REST.
}
}
// Fallback: create a fresh RestSseTransport.
this.inner = new RestSseTransport(this.baseUrl, this.token, this.fetchFn);
}
}

View file

@ -9,7 +9,9 @@ import {
MCP_RESTART_CLIENT_HEADROOM_MS,
} from '@qwen-code/acp-bridge/mcpTimeouts';
import { DaemonAuthFlow } from './DaemonAuthFlow.js';
import { parseSseStream } from './sse.js';
import { DaemonHttpError } from './DaemonHttpError.js';
import type { DaemonTransport } from './DaemonTransport.js';
import { RestSseTransport } from './RestSseTransport.js';
import type {
DaemonAgentMutationResult,
DaemonAuthProviderId,
@ -129,6 +131,13 @@ export interface DaemonClientOptions {
* `/capabilities.limits` passthrough.
*/
maxPendingPromptsPerSession?: number | null;
/**
* Pluggable transport. When omitted, a `RestSseTransport` is created
* automatically this preserves the existing REST+SSE behavior with
* zero caller-side changes. Pass an `AcpWsTransport` or
* `AcpHttpTransport` to use JSON-RPC over WebSocket or HTTP.
*/
transport?: DaemonTransport;
}
const DEFAULT_FETCH_TIMEOUT_MS = 30_000;
@ -205,21 +214,11 @@ function readTokenFromEnv(): string | undefined {
}
}
/**
* Thrown for any non-2xx daemon response. `status` and `body` are surfaced
* so callers can branch on the standard daemon HTTP semantics (404 missing
* session, 401 bad token, 400 malformed body, 500 agent failure).
*/
export class DaemonHttpError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, body: unknown, message: string) {
super(message);
this.name = 'DaemonHttpError';
this.status = status;
this.body = body;
}
}
// Re-export DaemonHttpError from its dedicated module so existing
// `import { DaemonHttpError } from './DaemonClient.js'` continues to
// work. The class itself lives in DaemonHttpError.ts to break the
// import chain from RestSseTransport → DaemonClient (browser bundle).
export { DaemonHttpError } from './DaemonHttpError.js';
/**
* SDK-side representation of the daemon's `prompt_queue_full` condition.
@ -345,6 +344,12 @@ export class DaemonClient {
private readonly fetchTimeoutMs: number;
private readonly promptLimit: number;
private readonly promptCounts: Record<string, number> = Object.create(null);
/**
* Pluggable transport layer. Defaults to `RestSseTransport` when
* no explicit transport is supplied preserving the pre-abstraction
* REST+SSE behavior with zero breaking changes.
*/
readonly transport: DaemonTransport;
// Lazy singleton so clients that never touch auth pay no allocation cost.
// Exposed via the readonly `auth` accessor below.
private _authFlow?: DaemonAuthFlow;
@ -383,6 +388,9 @@ export class DaemonClient {
this.promptLimit = normalizePendingPromptLimit(
opts.maxPendingPromptsPerSession,
);
this.transport =
opts.transport ??
new RestSseTransport(this.baseUrl, this.token, this._fetch);
}
get maxPendingPromptsPerSession(): number {
@ -455,7 +463,7 @@ export class DaemonClient {
effectiveTimeoutMs = perCallTimeoutMs;
}
if (!effectiveTimeoutMs || !Number.isFinite(effectiveTimeoutMs)) {
const res = await this._fetch(url, init);
const res = await this.transport.fetch(url, init);
if (consume) return consume(res);
return res as unknown as T;
}
@ -480,7 +488,7 @@ export class DaemonClient {
? composeAbortSignals([callerSignal, ctrl.signal])
: ctrl.signal;
try {
const res = await this._fetch(url, { ...init, signal });
const res = await this.transport.fetch(url, { ...init, signal });
if (consume) return await consume(res);
return res as unknown as T;
} finally {
@ -1432,7 +1440,7 @@ export class DaemonClient {
sessionId: string,
opts?: { signal?: AbortSignal; clientId?: string },
): Promise<DaemonSessionRecapResult> {
const res = await this._fetch(
const res = await this.transport.fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/recap`,
{
method: 'POST',
@ -1453,7 +1461,7 @@ export class DaemonClient {
question: string,
opts?: { signal?: AbortSignal; clientId?: string },
): Promise<DaemonSessionBtwResult> {
const res = await this._fetch(
const res = await this.transport.fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/btw`,
{
method: 'POST',
@ -1481,7 +1489,7 @@ export class DaemonClient {
command: string,
opts?: { signal?: AbortSignal; clientId?: string },
): Promise<DaemonShellCommandResult> {
const res = await this._fetch(
const res = await this.transport.fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/shell`,
{
method: 'POST',
@ -1859,7 +1867,7 @@ export class DaemonClient {
const releasePromptSlot = this.reservePromptSlot(sessionId);
let releaseOnExit = true;
try {
const res = await this._fetch(
const res = await this.transport.fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`,
{
method: 'POST',
@ -1925,7 +1933,7 @@ export class DaemonClient {
signal?: AbortSignal,
clientId?: string,
): Promise<NonBlockingPromptAccepted | PromptResult> {
const res = await this._fetch(
const res = await this.transport.fetch(
`${this.baseUrl}/session/${encodeURIComponent(sessionId)}/prompt`,
{
method: 'POST',
@ -2039,87 +2047,16 @@ export class DaemonClient {
sessionId: string,
opts: SubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
const headers = this.headers({ Accept: 'text/event-stream' });
if (opts.lastEventId !== undefined) {
headers['Last-Event-ID'] = String(opts.lastEventId);
}
// Apply `fetchTimeoutMs` to the CONNECT phase only (request → headers
// received). The SSE body itself must NOT be timed out — it's
// long-lived by design — so once `_fetch` returns the timer is
// cleared. Without this, an unresponsive daemon (TCP open but no
// headers) blocks `subscribeEvents` indefinitely instead of
// failing with the same 30s default the rest of the SDK uses.
const connectCtrl = new AbortController();
let connectTimer: ReturnType<typeof setTimeout> | undefined;
if (this.fetchTimeoutMs && Number.isFinite(this.fetchTimeoutMs)) {
connectTimer = setTimeout(
() =>
connectCtrl.abort(
new DOMException('connect timeout', 'TimeoutError'),
),
this.fetchTimeoutMs,
);
if (
typeof connectTimer === 'object' &&
connectTimer &&
'unref' in connectTimer
) {
(connectTimer as { unref: () => void }).unref();
}
}
const fetchSignal = opts.signal
? composeAbortSignals([opts.signal, connectCtrl.signal])
: connectCtrl.signal;
// Build the SSE URL, optionally with `?maxQueued=N`. We don't
// validate the value client-side — the daemon's
// `parseMaxQueuedQuery` is the source of truth on the range
// `[16, 2048]` and returns a structured `400 invalid_max_queued`
// for anything outside, so duplicating the bounds here would
// diverge if the daemon's range ever shifts.
let url = `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`;
if (opts.maxQueued !== undefined) {
url += `?maxQueued=${encodeURIComponent(String(opts.maxQueued))}`;
}
let res: Response;
try {
res = await this._fetch(url, { headers, signal: fetchSignal });
} finally {
if (connectTimer !== undefined) clearTimeout(connectTimer);
}
if (!res.ok) {
throw await this.failOnError(res, 'GET /session/:id/events');
}
// A 200 with the wrong content type usually means a misconfigured
// proxy or middleware swallowed our SSE response and replaced it
// with JSON/HTML. Without this check `parseSseStream` would
// silently produce zero frames — a confusing "no events" symptom
// that's easy to misdiagnose. Fail fast with the actual mime type.
//
// Cancel the body before throwing so undici doesn't keep the
// underlying socket pinned waiting for the consumer. Same
// reasoning as `respondToPermission` — long-running clients
// hitting this path repeatedly would otherwise exhaust the
// connection pool.
const ct = res.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('text/event-stream')) {
try {
await res.body?.cancel();
} catch {
/* body already consumed or no body */
}
throw new DaemonHttpError(
res.status,
ct,
`Bad SSE content-type: "${ct}"`,
);
}
if (!res.body) {
throw new Error('No SSE body');
}
// Forward the abort signal so post-200 aborts stop the iteration.
// Without this, callers who `controller.abort()` after the response
// arrives keep receiving frames until the upstream closes.
yield* parseSseStream(res.body, opts.signal);
// Delegate entirely to the transport. The transport handles
// connect-phase timeout, Last-Event-ID, maxQueued, content-type
// validation, and SSE parsing (for REST) or JSON-RPC notification
// filtering (for ACP transports).
yield* this.transport.subscribeEvents(sessionId, {
lastEventId: opts.lastEventId,
maxQueued: opts.maxQueued,
signal: opts.signal,
connectTimeoutMs: this.fetchTimeoutMs || undefined,
});
}
// -- Permissions -------------------------------------------------------
@ -2408,6 +2345,17 @@ export class DaemonClient {
);
}
// -- Lifecycle / disposal ------------------------------------------------
/**
* Release transport resources (WS close, etc.). Idempotent.
* After `dispose()`, further calls to `fetch` / `subscribeEvents`
* on the underlying transport throw `DaemonTransportClosedError`.
*/
dispose(): void {
this.transport.dispose();
}
// -- Session metadata ----------------------------------------------------
/**

View file

@ -0,0 +1,25 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Thrown for any non-2xx daemon response. `status` and `body` are surfaced
* so callers can branch on the standard daemon HTTP semantics (404 missing
* session, 401 bad token, 400 malformed body, 500 agent failure).
*
* Extracted to its own module so that transports (e.g. `RestSseTransport`)
* can import it without pulling in the entire `DaemonClient` module,
* keeping the browser bundle under budget.
*/
export class DaemonHttpError extends Error {
readonly status: number;
readonly body: unknown;
constructor(status: number, body: unknown, message: string) {
super(message);
this.name = 'DaemonHttpError';
this.status = status;
this.body = body;
}
}

View file

@ -0,0 +1,113 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
// ---------------------------------------------------------------------------
// Transport abstraction layer
// ---------------------------------------------------------------------------
/**
* Options for {@link DaemonTransport.fetch}. Mirrors the subset of
* per-call tuning knobs that `DaemonClient.fetchWithTimeout` supports.
*/
export interface DaemonTransportFetchOptions {
/** Per-call timeout in ms. `0` = no timeout. */
timeout?: number;
}
/**
* Options for {@link DaemonTransport.subscribeEvents}. Mirrors
* `DaemonClient.SubscribeOptions` the transport layer consumes
* these to build the appropriate wire representation (SSE query
* params, JSON-RPC params, etc.).
*/
export interface DaemonTransportSubscribeOptions {
/** Resume from after this event id (`Last-Event-ID` for REST/SSE). */
lastEventId?: number;
/** Per-subscriber backlog cap (SSE `?maxQueued=N`). */
maxQueued?: number;
/** Aborts the subscription cleanly. */
signal?: AbortSignal;
/**
* Connect-phase timeout in ms. Applied to the initial request
* headers-received phase; the long-lived event body itself is NOT
* timed. `0` or `undefined` = no connect timeout.
*/
connectTimeoutMs?: number;
}
/** Transport type discriminant. */
export type DaemonTransportType = 'rest' | 'acp-http' | 'acp-ws';
/**
* Pluggable transport for the daemon SDK.
*
* The default transport (`RestSseTransport`) speaks the existing
* `qwen serve` REST+SSE surface. ACP transports (`AcpHttpTransport`,
* `AcpWsTransport`) map the same URL-shaped calls to JSON-RPC over
* HTTP or WebSocket, synthesizing standard `Response` objects so
* `DaemonClient` needs no control-flow changes.
*/
export interface DaemonTransport {
/**
* Issue an HTTP-shaped request. REST transports delegate to the
* underlying `fetch`; ACP transports translate the URL + body into
* a JSON-RPC request and synthesize a `Response`.
*/
fetch(
url: string,
init: RequestInit,
opts?: DaemonTransportFetchOptions,
): Promise<Response>;
/**
* Open a session event stream. REST transports open an SSE
* connection; ACP transports filter a shared notification stream
* by session id.
*/
subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions,
): AsyncGenerator<DaemonEvent>;
/** Transport family discriminant. */
readonly type: DaemonTransportType;
/**
* Whether this transport supports `Last-Event-ID` replay. SSE
* transports return `true`; WebSocket transports return `false`
* (notifications are fire-and-forget on the WS).
*/
readonly supportsReplay: boolean;
/**
* Whether the underlying connection is currently open. Stateless
* transports (REST) always return `true`.
*/
readonly connected: boolean;
/**
* Release any underlying connection resources (WebSocket close,
* SSE abort, etc.). Idempotent safe to call multiple times.
*/
dispose(): void;
}
// ---------------------------------------------------------------------------
// Transport errors
// ---------------------------------------------------------------------------
/**
* Thrown when an operation is attempted on a transport whose
* connection has been closed (disposed, WS close, etc.).
*/
export class DaemonTransportClosedError extends Error {
constructor(message?: string) {
super(message ?? 'Transport connection closed');
this.name = 'DaemonTransportClosedError';
}
}

View file

@ -0,0 +1,215 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonEvent } from './types.js';
import type {
DaemonTransport,
DaemonTransportFetchOptions,
DaemonTransportSubscribeOptions,
} from './DaemonTransport.js';
import { DaemonTransportClosedError } from './DaemonTransport.js';
import { DaemonHttpError } from './DaemonHttpError.js';
import { parseSseStream } from './sse.js';
/**
* Default REST+SSE transport. Delegates `fetch()` to the underlying
* `_fetch` callable and implements `subscribeEvents()` by opening an
* SSE connection to `GET /session/:id/events`.
*
* This is the transport `DaemonClient` uses when no explicit transport
* is provided it exactly reproduces the pre-abstraction behavior.
*/
export class RestSseTransport implements DaemonTransport {
private readonly baseUrl: string;
private readonly token: string | undefined;
private readonly _fetch: typeof globalThis.fetch;
private _disposed = false;
readonly type = 'rest' as const;
readonly supportsReplay = true;
constructor(
baseUrl: string,
token: string | undefined,
fetchFn: typeof globalThis.fetch,
) {
this.baseUrl = baseUrl;
this.token = token;
this._fetch = fetchFn;
}
get connected(): boolean {
return !this._disposed;
}
async fetch(
url: string,
init: RequestInit,
_opts?: DaemonTransportFetchOptions,
): Promise<Response> {
if (this._disposed) {
throw new DaemonTransportClosedError();
}
return this._fetch(url, init);
}
/**
* Open an SSE stream for the given session. Mirrors the inline
* logic that previously lived in `DaemonClient.subscribeEvents`:
* - connect-phase timeout via AbortController
* - `Last-Event-ID` header
* - `?maxQueued=N` query param
* - content-type validation
* - delegation to `parseSseStream`
*/
async *subscribeEvents(
sessionId: string,
opts: DaemonTransportSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
if (this._disposed) {
throw new DaemonTransportClosedError();
}
const headers: Record<string, string> = { Accept: 'text/event-stream' };
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
if (opts.lastEventId !== undefined) {
headers['Last-Event-ID'] = String(opts.lastEventId);
}
// Connect-phase timeout (request → headers received). The SSE
// body itself is long-lived and must NOT be timed out.
const connectCtrl = new AbortController();
let connectTimer: ReturnType<typeof setTimeout> | undefined;
const connectTimeoutMs = opts.connectTimeoutMs;
if (connectTimeoutMs && Number.isFinite(connectTimeoutMs)) {
connectTimer = setTimeout(
() =>
connectCtrl.abort(
new DOMException('Initial connect timed out', 'TimeoutError'),
),
connectTimeoutMs,
);
if (
typeof connectTimer === 'object' &&
connectTimer &&
'unref' in connectTimer
) {
(connectTimer as { unref: () => void }).unref();
}
}
const fetchSignal = opts.signal
? composeAbortSignals([opts.signal, connectCtrl.signal])
: connectCtrl.signal;
// Build the SSE URL, optionally with `?maxQueued=N`.
let url = `${this.baseUrl}/session/${encodeURIComponent(sessionId)}/events`;
if (opts.maxQueued !== undefined) {
url += `?maxQueued=${encodeURIComponent(String(opts.maxQueued))}`;
}
let res: Response;
try {
res = await this._fetch(url, { headers, signal: fetchSignal });
} finally {
if (connectTimer !== undefined) clearTimeout(connectTimer);
}
if (!res.ok) {
// Read the error body for the caller.
let body: unknown;
try {
const text = await res.text();
try {
body = JSON.parse(text);
} catch {
body = text;
}
} catch {
/* body unreadable */
}
const detail =
body && typeof body === 'object' && 'error' in body
? String((body as { error: unknown }).error)
: `HTTP ${res.status}`;
throw new DaemonHttpError(
res.status,
body,
`GET /session/:id/events: ${detail}`,
);
}
// Content-type validation — a misconfigured proxy that swallows
// the SSE response would otherwise silently produce zero frames.
const ct = res.headers.get('content-type') ?? '';
if (!ct.toLowerCase().includes('text/event-stream')) {
try {
await res.body?.cancel();
} catch {
/* body already consumed or no body */
}
throw new DaemonHttpError(
res.status,
ct,
`GET /session/:id/events: expected content-type text/event-stream, got "${ct}"`,
);
}
if (!res.body) {
throw new Error('No SSE body');
}
yield* parseSseStream(res.body, opts.signal);
}
dispose(): void {
this._disposed = true;
}
}
// ---------------------------------------------------------------------------
// Minimal abort-signal composition (same logic as DaemonClient's
// `composeAbortSignals` but kept transport-local to avoid a circular
// import). REST is the only transport that needs this inline; the ACP
// transports compose differently.
// ---------------------------------------------------------------------------
function composeAbortSignals(signals: AbortSignal[]): AbortSignal {
const anyFn = (
AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }
).any;
if (typeof anyFn === 'function') return anyFn.call(AbortSignal, signals);
const ctrl = new AbortController();
const cleanups: Array<() => void> = [];
const detachAll = () => {
while (cleanups.length > 0) {
const fn = cleanups.pop();
try {
fn?.();
} catch {
/* swallow */
}
}
};
for (const s of signals) {
if (s.aborted) {
ctrl.abort(s.reason);
detachAll();
return ctrl.signal;
}
const onAbort = () => {
ctrl.abort(s.reason);
detachAll();
};
s.addEventListener('abort', onAbort, { once: true });
cleanups.push(() => s.removeEventListener('abort', onAbort));
}
ctrl.signal.addEventListener('abort', detachAll, { once: true });
return ctrl.signal;
}

View file

@ -0,0 +1,611 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// ---------------------------------------------------------------------------
// Shared ACP route table
// ---------------------------------------------------------------------------
// Single source of truth for the URL→JSON-RPC mapping used by both
// `AcpWsTransport` and `AcpHttpTransport`. Keeping a single table
// prevents route inconsistencies between the two transport variants.
// ---------------------------------------------------------------------------
import { isRecord } from './acpTransportUtils.js';
export interface RouteMapping {
method: string;
/** Extract JSON-RPC params from URL path segments + request body. */
extractParams: (
segments: string[],
body: unknown,
httpMethod: string,
) => Record<string, unknown>;
/**
* True for notifications (no response expected). The transport will
* NOT wait for a JSON-RPC response from the server.
*/
notification?: boolean;
}
export interface RouteEntry {
httpMethod: string;
pattern: RegExp;
mapping: RouteMapping;
}
/**
* Map of `METHOD PATH_PATTERN` to JSON-RPC method + params extractor.
* Path segments are split by `/` after stripping the base URL prefix.
*
* Pattern conventions:
* - `:param` = named path param (consumed positionally)
* - `*` = rest wildcard
*/
export const ROUTE_TABLE: readonly RouteEntry[] = [
// POST /session → session/new
// ACP standard: session/new always creates an isolated session.
// Strip non-standard params (sessionScope) — the server enforces
// 'thread' regardless, so passing it is harmless but misleading.
{
httpMethod: 'POST',
pattern: /^\/session\/?$/,
mapping: {
method: 'session/new',
extractParams: (_s, body) => {
if (!isRecord(body)) return {};
const { sessionScope: _, ...rest } = body as Record<string, unknown>;
return rest;
},
},
},
// POST /session/:id/prompt → session/prompt
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/prompt$/,
mapping: {
method: 'session/prompt',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/cancel → session/cancel (notification)
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/cancel$/,
mapping: {
method: 'session/cancel',
extractParams: (segs) => ({ sessionId: segs[0] }),
notification: true,
},
},
// DELETE /session/:id → session/close
{
httpMethod: 'DELETE',
pattern: /^\/session\/([^/]+)\/?$/,
mapping: {
method: 'session/close',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// POST /session/:id/load → session/load
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/load$/,
mapping: {
method: 'session/load',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/resume → session/resume
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/resume$/,
mapping: {
method: 'session/resume',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/permission/:reqId → session/permission
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/permission\/([^/]+)$/,
mapping: {
method: 'session/permission',
extractParams: (segs, body) => ({
sessionId: segs[0],
requestId: segs[1],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /permission/:reqId (without session prefix)
{
httpMethod: 'POST',
pattern: /^\/permission\/([^/]+)$/,
mapping: {
method: 'session/permission',
extractParams: (segs, body) => ({
requestId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/model → session/set_model
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/model$/,
mapping: {
method: 'session/set_model',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// GET /capabilities → use initialize result (handled specially)
{
httpMethod: 'GET',
pattern: /^\/capabilities\/?$/,
mapping: {
method: '_capabilities',
extractParams: () => ({}),
},
},
// GET /health
{
httpMethod: 'GET',
pattern: /^\/health\/?$/,
mapping: {
method: '_qwen/health',
extractParams: () => ({}),
},
},
// ---- Vendor session extensions (_qwen/ prefix) -------------------------
// PATCH /session/:id/metadata → _qwen/session/update_metadata
{
httpMethod: 'PATCH',
pattern: /^\/session\/([^/]+)\/metadata$/,
mapping: {
method: '_qwen/session/update_metadata',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/heartbeat → _qwen/session/heartbeat
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/heartbeat$/,
mapping: {
method: '_qwen/session/heartbeat',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/recap → _qwen/session/recap
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/recap$/,
mapping: {
method: '_qwen/session/recap',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/btw → _qwen/session/btw
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/btw$/,
mapping: {
method: '_qwen/session/btw',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/shell → _qwen/session/shell
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/shell$/,
mapping: {
method: '_qwen/session/shell',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/branch → session/fork
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/branch$/,
mapping: {
method: 'session/fork',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /session/:id/detach → _qwen/session/detach
{
httpMethod: 'POST',
pattern: /^\/session\/([^/]+)\/detach$/,
mapping: {
method: '_qwen/session/detach',
extractParams: (segs, body) => ({
sessionId: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// ---- Session diagnostic routes (_qwen/ prefix) -------------------------
// GET /session/:id/context → _qwen/session/context
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/context$/,
mapping: {
method: '_qwen/session/context',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// GET /session/:id/context-usage → _qwen/session/context_usage
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/context-usage$/,
mapping: {
method: '_qwen/session/context_usage',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// GET /session/:id/supported-commands → _qwen/session/supported_commands
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/supported-commands$/,
mapping: {
method: '_qwen/session/supported_commands',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// GET /session/:id/tasks → _qwen/session/tasks
{
httpMethod: 'GET',
pattern: /^\/session\/([^/]+)\/tasks$/,
mapping: {
method: '_qwen/session/tasks',
extractParams: (segs) => ({ sessionId: segs[0] }),
},
},
// ---- Granular workspace routes (_qwen/workspace/*) ---------------------
// GET /workspace/mcp → _qwen/workspace/mcp
{
httpMethod: 'GET',
pattern: /^\/workspace\/mcp\/?$/,
mapping: {
method: '_qwen/workspace/mcp',
extractParams: () => ({}),
},
},
// GET /workspace/skills → _qwen/workspace/skills
{
httpMethod: 'GET',
pattern: /^\/workspace\/skills\/?$/,
mapping: {
method: '_qwen/workspace/skills',
extractParams: () => ({}),
},
},
// GET /workspace/providers → _qwen/workspace/providers
{
httpMethod: 'GET',
pattern: /^\/workspace\/providers\/?$/,
mapping: {
method: '_qwen/workspace/providers',
extractParams: () => ({}),
},
},
// GET /workspace/env → _qwen/workspace/env
{
httpMethod: 'GET',
pattern: /^\/workspace\/env\/?$/,
mapping: {
method: '_qwen/workspace/env',
extractParams: () => ({}),
},
},
// GET /workspace/preflight → _qwen/workspace/preflight
{
httpMethod: 'GET',
pattern: /^\/workspace\/preflight\/?$/,
mapping: {
method: '_qwen/workspace/preflight',
extractParams: () => ({}),
},
},
// POST /workspace/init → _qwen/workspace/init
{
httpMethod: 'POST',
pattern: /^\/workspace\/init\/?$/,
mapping: {
method: '_qwen/workspace/init',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// GET /workspace/tools → _qwen/workspace/tools
{
httpMethod: 'GET',
pattern: /^\/workspace\/tools\/?$/,
mapping: {
method: '_qwen/workspace/tools',
extractParams: () => ({}),
},
},
// GET /workspace/memory → _qwen/workspace/memory
{
httpMethod: 'GET',
pattern: /^\/workspace\/memory\/?$/,
mapping: {
method: '_qwen/workspace/memory',
extractParams: () => ({}),
},
},
// POST /workspace/memory → _qwen/workspace/memory/write
{
httpMethod: 'POST',
pattern: /^\/workspace\/memory\/?$/,
mapping: {
method: '_qwen/workspace/memory/write',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// GET /workspace/agents → _qwen/workspace/agents/list
{
httpMethod: 'GET',
pattern: /^\/workspace\/agents\/?$/,
mapping: {
method: '_qwen/workspace/agents/list',
extractParams: () => ({}),
},
},
// POST /workspace/agents → _qwen/workspace/agents/create
{
httpMethod: 'POST',
pattern: /^\/workspace\/agents\/?$/,
mapping: {
method: '_qwen/workspace/agents/create',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// GET /workspace/agents/:agentType → _qwen/workspace/agents/get
{
httpMethod: 'GET',
pattern: /^\/workspace\/agents\/([^/]+)\/?$/,
mapping: {
method: '_qwen/workspace/agents/get',
extractParams: (segs) => ({ agentType: segs[0] }),
},
},
// DELETE /workspace/agents/:agentType → _qwen/workspace/agents/delete
{
httpMethod: 'DELETE',
pattern: /^\/workspace\/agents\/([^/]+)\/?$/,
mapping: {
method: '_qwen/workspace/agents/delete',
extractParams: (segs, body) => ({
agentType: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// GET /workspace/mcp/:server/tools → _qwen/workspace/mcp/tools
{
httpMethod: 'GET',
pattern: /^\/workspace\/mcp\/([^/]+)\/tools\/?$/,
mapping: {
method: '_qwen/workspace/mcp/tools',
extractParams: (segs) => ({ serverName: segs[0] }),
},
},
// POST /workspace/mcp/servers → _qwen/workspace/mcp/servers/add
{
httpMethod: 'POST',
pattern: /^\/workspace\/mcp\/servers\/?$/,
mapping: {
method: '_qwen/workspace/mcp/servers/add',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// DELETE /workspace/mcp/servers/:name → _qwen/workspace/mcp/servers/remove
{
httpMethod: 'DELETE',
pattern: /^\/workspace\/mcp\/servers\/([^/]+)\/?$/,
mapping: {
method: '_qwen/workspace/mcp/servers/remove',
extractParams: (segs, body) => ({
name: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// POST /workspace/set-tool-enabled → _qwen/workspace/set_tool_enabled
{
httpMethod: 'POST',
pattern: /^\/workspace\/set-tool-enabled\/?$/,
mapping: {
method: '_qwen/workspace/set_tool_enabled',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// POST /workspace/mcp/:server/restart → _qwen/workspace/restart_mcp_server
{
httpMethod: 'POST',
pattern: /^\/workspace\/mcp\/([^/]+)\/restart\/?$/,
mapping: {
method: '_qwen/workspace/restart_mcp_server',
extractParams: (segs, body) => ({
serverName: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// GET /workspace/auth/status → _qwen/workspace/auth/status
{
httpMethod: 'GET',
pattern: /^\/workspace\/auth\/status\/?$/,
mapping: {
method: '_qwen/workspace/auth/status',
extractParams: () => ({}),
},
},
// POST /workspace/auth/device-flow → _qwen/workspace/auth/device_flow/start
{
httpMethod: 'POST',
pattern: /^\/workspace\/auth\/device-flow\/?$/,
mapping: {
method: '_qwen/workspace/auth/device_flow/start',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// GET /workspace/auth/device-flow/:id → _qwen/workspace/auth/device_flow/get
{
httpMethod: 'GET',
pattern: /^\/workspace\/auth\/device-flow\/([^/]+)\/?$/,
mapping: {
method: '_qwen/workspace/auth/device_flow/get',
extractParams: (segs) => ({ id: segs[0] }),
},
},
// DELETE /workspace/auth/device-flow/:id → _qwen/workspace/auth/device_flow/cancel
{
httpMethod: 'DELETE',
pattern: /^\/workspace\/auth\/device-flow\/([^/]+)\/?$/,
mapping: {
method: '_qwen/workspace/auth/device_flow/cancel',
extractParams: (segs) => ({ id: segs[0] }),
},
},
// ---- Workspace catch-all (must be AFTER all specific workspace routes) --
// Handles any workspace path not matched above (e.g., /workspace/custom/path).
{
httpMethod: 'GET',
pattern: /^\/workspace\/(.+)$/,
mapping: {
method: '_qwen/workspace',
extractParams: (segs) => ({ path: segs[0] }),
},
},
{
httpMethod: 'POST',
pattern: /^\/workspace\/(.+)$/,
mapping: {
method: '_qwen/workspace',
extractParams: (segs, body) => ({
path: segs[0],
...(isRecord(body) ? body : {}),
}),
},
},
// ---- File system routes -----------------------------------------------
// These map the DaemonClient's file-system helpers to _qwen/file/* RPC
// methods on the ACP daemon.
// GET /file → _qwen/file/read (query params forwarded as RPC params)
{
httpMethod: 'GET',
pattern: /^\/file\/?$/,
mapping: {
method: '_qwen/file/read',
extractParams: () => ({}),
},
},
// GET /file/bytes → _qwen/file/read_bytes
{
httpMethod: 'GET',
pattern: /^\/file\/bytes\/?$/,
mapping: {
method: '_qwen/file/read_bytes',
extractParams: () => ({}),
},
},
// GET /stat → _qwen/file/stat
{
httpMethod: 'GET',
pattern: /^\/stat\/?$/,
mapping: {
method: '_qwen/file/stat',
extractParams: () => ({}),
},
},
// GET /list → _qwen/file/list
{
httpMethod: 'GET',
pattern: /^\/list\/?$/,
mapping: {
method: '_qwen/file/list',
extractParams: () => ({}),
},
},
// GET /glob → _qwen/file/glob
{
httpMethod: 'GET',
pattern: /^\/glob\/?$/,
mapping: {
method: '_qwen/file/glob',
extractParams: () => ({}),
},
},
// POST /file/write → _qwen/file/write
{
httpMethod: 'POST',
pattern: /^\/file\/write\/?$/,
mapping: {
method: '_qwen/file/write',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// POST /file/edit → _qwen/file/edit
{
httpMethod: 'POST',
pattern: /^\/file\/edit\/?$/,
mapping: {
method: '_qwen/file/edit',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
// ---- Bulk session operations -------------------------------------------
// POST /sessions/delete → _qwen/sessions/delete
{
httpMethod: 'POST',
pattern: /^\/sessions\/delete\/?$/,
mapping: {
method: '_qwen/sessions/delete',
extractParams: (_s, body) => (isRecord(body) ? body : {}),
},
},
];

View file

@ -0,0 +1,134 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
// ---------------------------------------------------------------------------
// Shared helpers for ACP transports
// ---------------------------------------------------------------------------
// Extracted from AcpWsTransport and AcpHttpTransport to avoid
// duplicated code and ensure consistent behavior.
// ---------------------------------------------------------------------------
import { ROUTE_TABLE, type RouteMapping } from './acpRouteTable.js';
/**
* Match a URL path + HTTP method against the shared route table.
*/
export function matchRoute(
path: string,
httpMethod: string,
): { mapping: RouteMapping; segments: string[] } | null {
for (const route of ROUTE_TABLE) {
if (route.httpMethod !== httpMethod) continue;
const m = path.match(route.pattern);
if (m) {
// Groups 1..N are the captured segments.
const segments = Array.from(m).slice(1).map(decodeURIComponent);
return { mapping: route.mapping, segments };
}
}
return null;
}
/**
* Create a synthetic `Response` object from a status code and body.
*/
export function synthesizeResponse(status: number, body: unknown): Response {
const bodyStr = body !== null ? JSON.stringify(body) : '';
const headers: Record<string, string> = {};
if (bodyStr) {
headers['content-type'] = 'application/json';
}
return new Response(bodyStr || null, { status, headers });
}
/**
* Map a JSON-RPC error code to an HTTP status code.
*/
export function jsonRpcErrorToHttpStatus(code: number): number {
// JSON-RPC error code → HTTP status mapping.
// -32600 = invalid request → 400
// -32601 = method not found → 404
// -32602 = invalid params → 400
// -32603 = internal error → 500
// -32700 = parse error → 400
if (code === -32601) return 404;
if (code === -32600 || code === -32602 || code === -32700) return 400;
if (code === -32603) return 500;
// Application-specific error codes. Use 500 as default.
return 500;
}
/**
* Type guard for plain objects.
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Compose multiple `AbortSignal` instances into one that aborts when
* ANY of the inputs aborts. Uses `AbortSignal.any()` when available
* (Node 20+), otherwise falls back to a manual wiring approach.
*/
export function composeAbortSignals(signals: AbortSignal[]): AbortSignal {
const anyFn = (
AbortSignal as unknown as { any?: (s: AbortSignal[]) => AbortSignal }
).any;
if (typeof anyFn === 'function') return anyFn.call(AbortSignal, signals);
const ctrl = new AbortController();
const cleanups: Array<() => void> = [];
const detachAll = () => {
while (cleanups.length > 0) {
const fn = cleanups.pop();
try {
fn?.();
} catch {
/* swallow */
}
}
};
for (const s of signals) {
if (s.aborted) {
ctrl.abort(s.reason);
detachAll();
return ctrl.signal;
}
const onAbort = () => {
ctrl.abort(s.reason);
detachAll();
};
s.addEventListener('abort', onAbort, { once: true });
cleanups.push(() => s.removeEventListener('abort', onAbort));
}
ctrl.signal.addEventListener('abort', detachAll, { once: true });
return ctrl.signal;
}
/**
* Merge transport-specific headers with caller-provided headers from
* `RequestInit`. Caller headers take precedence for any conflicts.
*/
export function mergeHeaders(
transportHeaders: Record<string, string>,
initHeaders: HeadersInit | undefined,
): Record<string, string> {
if (!initHeaders) return transportHeaders;
const merged = { ...transportHeaders };
if (initHeaders instanceof Headers) {
initHeaders.forEach((value, key) => {
merged[key] = value;
});
} else if (Array.isArray(initHeaders)) {
for (const [key, value] of initHeaders) {
merged[key] = value;
}
} else {
Object.assign(merged, initHeaders);
}
return merged;
}

View file

@ -19,6 +19,28 @@ export {
type RestoreSessionRequest,
type SubscribeOptions,
} from './DaemonClient.js';
// Transport abstraction layer
export { DaemonTransportClosedError } from './DaemonTransport.js';
export type {
DaemonTransport,
DaemonTransportFetchOptions,
DaemonTransportSubscribeOptions,
DaemonTransportType,
} from './DaemonTransport.js';
export type { RestSseTransport } from './RestSseTransport.js';
// negotiateTransport + ACP transport classes live in their own files to
// break the static import chain from this barrel, keeping the browser
// bundle under budget. Monorepo consumers import from source paths:
// import { negotiateTransport } from '../../sdk-typescript/src/daemon/negotiateTransport.js';
// import { AcpWsTransport } from '../../sdk-typescript/src/daemon/AcpWsTransport.js';
// import { AcpHttpTransport } from '../../sdk-typescript/src/daemon/AcpHttpTransport.js';
// import { AutoReconnectTransport } from '../../sdk-typescript/src/daemon/AutoReconnectTransport.js';
// Deep package exports are intentionally omitted: the SDK barrel does not
// re-export these classes, and the package's `files` field ships only
// `dist/` which does not include per-module entry points for them.
export type { NegotiateTransportOptions } from './negotiateTransport.js';
export type { JsonRpcNotification } from './AcpEventDenormalizer.js';
export type { TransportFactory } from './AutoReconnectTransport.js';
export {
DaemonAuthFlow,
DEVICE_FLOW_EXPIRY_GRACE_MS,

View file

@ -0,0 +1,135 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import type { DaemonTransport } from './DaemonTransport.js';
// ---------------------------------------------------------------------------
// Transport negotiation
// ---------------------------------------------------------------------------
/** Options for {@link negotiateTransport}. */
export interface NegotiateTransportOptions {
/** Timeout for the capabilities probe and WS handshake. Default 5000ms. */
probeTimeoutMs?: number;
}
/**
* Auto-detect the best available transport by probing the daemon's
* `GET /capabilities` endpoint and inspecting the `transports` array.
*
* Preference order: `acp-ws` > `acp-http` > `rest`.
*
* For `acp-ws`, a WebSocket probe with timeout is performed. If the
* probe fails (timeout, connection refused, etc.), the next-best
* transport is tried.
*
* When the daemon's `/capabilities` response does not include a
* `transports` field, the factory falls back to REST (the universal
* baseline).
*
* Usage:
* ```ts
* const transport = await negotiateTransport(baseUrl, token);
* const client = new DaemonClient({ baseUrl, token, transport });
* ```
*/
export async function negotiateTransport(
baseUrl: string,
token?: string,
opts?: NegotiateTransportOptions,
): Promise<DaemonTransport> {
const fetchFn = globalThis.fetch.bind(globalThis);
const probeTimeoutMs = opts?.probeTimeoutMs ?? 5_000;
// Lazy imports to avoid circular module initialization. These
// modules are always available at runtime (same package), but we
// don't want to eagerly load ACP transports when the caller never
// negotiates.
const { RestSseTransport } = await import('./RestSseTransport.js');
// Probe capabilities.
const headers: Record<string, string> = {};
if (token) headers['Authorization'] = `Bearer ${token}`;
let transports: string[] = [];
try {
const ctrl = new AbortController();
const timer = setTimeout(() => ctrl.abort(), probeTimeoutMs);
try {
const res = await fetchFn(`${baseUrl}/capabilities`, {
headers,
signal: ctrl.signal,
});
clearTimeout(timer);
if (res.ok) {
const caps = (await res.json()) as {
transports?: string[];
[key: string]: unknown;
};
transports = Array.isArray(caps.transports) ? caps.transports : [];
}
} catch {
clearTimeout(timer);
// Probe failed — fall through to REST.
}
} catch {
// Outer catch for timer setup errors (unlikely).
}
// Try best available in preference order.
if (transports.includes('acp-ws')) {
try {
const { AcpWsTransport } = await import('./AcpWsTransport.js');
// Convert http(s) → ws(s) for the WS URL.
const wsUrl = baseUrl.replace(/^https:/, 'wss:').replace(/^http:/, 'ws:');
const transport = new AcpWsTransport(wsUrl + '/acp', token);
// Probe: try to connect with a timeout.
const probeTimer = setTimeout(() => {
/* timeout — handled by race */
}, probeTimeoutMs);
try {
const probeRes = await Promise.race([
transport.fetch(`${baseUrl}/capabilities`, { method: 'GET' }),
new Promise<null>((resolve) =>
setTimeout(() => resolve(null), probeTimeoutMs),
),
]);
clearTimeout(probeTimer);
if (probeRes) {
return transport;
}
// Probe timed out — dispose and try next.
try {
transport.dispose();
} catch {
/* ignore dispose errors */
}
} catch {
clearTimeout(probeTimer);
// WS probe failed — dispose and try next.
try {
transport.dispose();
} catch {
/* ignore dispose errors */
}
}
} catch {
// WS import/creation failed — try next.
}
}
if (transports.includes('acp-http')) {
try {
const { AcpHttpTransport } = await import('./AcpHttpTransport.js');
return new AcpHttpTransport(baseUrl, token, fetchFn);
} catch {
// ACP-HTTP creation failed — fall back to REST.
}
}
// Universal fallback.
return new RestSseTransport(baseUrl, token, fetchFn);
}

View file

@ -49,6 +49,13 @@ export interface DaemonCapabilities {
*/
limits?: DaemonCapabilitiesLimits;
modelServices: string[];
/**
* Transport protocols the daemon advertises. Clients use this to
* negotiate the preferred transport (e.g. `['rest-sse', 'acp-ws',
* 'acp-http']`). Optional because older v=1 daemons predate
* transport negotiation absence implies `['rest-sse']` only.
*/
transports?: readonly string[];
/**
* Absolute canonical workspace path this daemon is bound to
* (1 daemon = 1 workspace). Clients use this to

View file

@ -0,0 +1,249 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, beforeEach } from 'vitest';
import {
denormalizeAcpNotification,
filterEventsBySession,
_resetSyntheticIdCounter,
type JsonRpcNotification,
} from '../../src/daemon/AcpEventDenormalizer.js';
import type { DaemonEvent } from '../../src/daemon/types.js';
beforeEach(() => {
_resetSyntheticIdCounter();
});
// ---------------------------------------------------------------------------
// denormalizeAcpNotification
// ---------------------------------------------------------------------------
describe('denormalizeAcpNotification', () => {
it('converts session/update with type and data', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: {
type: 'session_update',
data: { sessionId: 's1', content: 'hello' },
},
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('session_update');
expect(event!.data).toEqual({ sessionId: 's1', content: 'hello' });
expect(event!.id).toBe(1);
expect(event!.v).toBe(1);
});
it('converts session/update using params as data when data is missing', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: {
type: 'session_update',
sessionId: 's2',
},
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('session_update');
// data falls back to the full params object.
expect(event!.data).toEqual({
type: 'session_update',
sessionId: 's2',
});
});
it('converts session/update with nested update.sessionUpdate (current daemon format)', () => {
// This is the shape the daemon actually sends:
// { method: "session/update", params: { sessionId, update: { sessionUpdate: "<type>", ...data } } }
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: {
sessionId: 's1',
update: {
sessionUpdate: 'assistant.text.delta',
content: { text: 'Hello' },
_meta: { serverTimestamp: 12345 },
},
},
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('assistant.text.delta');
// data is the update object with sessionId merged in.
expect(event!.data).toEqual({
sessionUpdate: 'assistant.text.delta',
content: { text: 'Hello' },
_meta: { serverTimestamp: 12345 },
sessionId: 's1',
});
// _meta is extracted from the update object.
expect(event!._meta).toEqual({ serverTimestamp: 12345 });
});
it('returns undefined for session/update without a type field', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: { sessionId: 's1' },
};
expect(denormalizeAcpNotification(notification)).toBeUndefined();
});
it('returns undefined for session/update with empty type', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: { type: '' },
};
expect(denormalizeAcpNotification(notification)).toBeUndefined();
});
it('converts _qwen/notify', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: '_qwen/notify',
params: {
type: 'workspace_initialized',
data: { cwd: '/tmp' },
},
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('workspace_initialized');
expect(event!.data).toEqual({ cwd: '/tmp' });
});
it('converts workspace-scoped snake_case methods', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'memory_changed',
params: { file: 'CLAUDE.md' },
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('memory_changed');
expect(event!.data).toEqual({ file: 'CLAUDE.md' });
});
it('converts namespace/event methods, using the event part as type', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'workspace/agent_changed',
params: { agentId: 'a1' },
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('agent_changed');
});
it('returns undefined for unrecognized methods without _ or /', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'ping',
params: {},
};
expect(denormalizeAcpNotification(notification)).toBeUndefined();
});
it('produces monotonically increasing synthetic ids', () => {
const n1: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: { type: 'a', data: {} },
};
const n2: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: { type: 'b', data: {} },
};
const e1 = denormalizeAcpNotification(n1);
const e2 = denormalizeAcpNotification(n2);
expect(e1!.id).toBe(1);
expect(e2!.id).toBe(2);
});
it('preserves _meta when present as a plain object', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: {
type: 'test',
data: {},
_meta: { custom: 'value' },
},
};
const event = denormalizeAcpNotification(notification);
expect(event!._meta).toEqual({ custom: 'value' });
});
it('preserves originatorClientId when present', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'session/update',
params: {
type: 'test',
data: {},
originatorClientId: 'client-42',
},
};
const event = denormalizeAcpNotification(notification);
expect(event!.originatorClientId).toBe('client-42');
});
it('handles missing params gracefully', () => {
const notification: JsonRpcNotification = {
jsonrpc: '2.0',
method: 'memory_changed',
};
const event = denormalizeAcpNotification(notification);
expect(event).toBeDefined();
expect(event!.type).toBe('memory_changed');
expect(event!.data).toEqual({});
});
});
// ---------------------------------------------------------------------------
// filterEventsBySession
// ---------------------------------------------------------------------------
describe('filterEventsBySession', () => {
const events: DaemonEvent[] = [
{ id: 1, v: 1, type: 'a', data: { sessionId: 'sess-1' } },
{ id: 2, v: 1, type: 'b', data: { sessionId: 'sess-2' } },
{ id: 3, v: 1, type: 'c', data: {} }, // workspace-scoped
{ id: 4, v: 1, type: 'd', data: { sessionId: 'sess-1' } },
{ id: 5, v: 1, type: 'e', data: 'string-data' }, // non-record data
];
it('yields events matching the specified session', () => {
const filtered = [...filterEventsBySession(events, 'sess-1')];
expect(filtered.map((e) => e.type)).toEqual(['a', 'c', 'd', 'e']);
});
it('filters out events from other sessions', () => {
const filtered = [...filterEventsBySession(events, 'sess-1')];
expect(filtered.find((e) => e.type === 'b')).toBeUndefined();
});
it('passes through workspace-scoped events (no sessionId)', () => {
const filtered = [...filterEventsBySession(events, 'sess-2')];
expect(filtered.map((e) => e.type)).toEqual(['b', 'c', 'e']);
});
it('passes through events with non-record data', () => {
const filtered = [...filterEventsBySession(events, 'sess-1')];
expect(filtered.find((e) => e.type === 'e')).toBeDefined();
});
it('handles empty input', () => {
const filtered = [...filterEventsBySession([], 'sess-1')];
expect(filtered).toEqual([]);
});
});

View file

@ -0,0 +1,573 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { AcpHttpTransport } from '../../src/daemon/AcpHttpTransport.js';
import { DaemonTransportClosedError } from '../../src/daemon/DaemonTransport.js';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
signal?: AbortSignal | null;
}
function jsonResponse(
status: number,
body: unknown,
extraHeaders?: Record<string, string>,
): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json', ...extraHeaders },
});
}
/**
* Build a mock fetch that handles the initialize handshake and
* subsequent requests. Returns calls for inspection.
*/
function initAwareFetch(opts?: {
initResult?: unknown;
connectionIdHeader?: string;
capabilitiesResult?: unknown;
subsequentReply?: (req: CapturedRequest) => Response;
}): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } {
const calls: CapturedRequest[] = [];
let initDone = false;
let capsDone = false;
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<string, string> = {};
if (init?.headers) {
if (init.headers instanceof Headers) {
init.headers.forEach((v, k) => (headers[k.toLowerCase()] = v));
} else if (
typeof init.headers === 'object' &&
!Array.isArray(init.headers)
) {
for (const [k, v] of Object.entries(init.headers)) {
headers[k.toLowerCase()] = v;
}
}
}
const body = typeof init?.body === 'string' ? init.body : null;
const captured: CapturedRequest = {
url,
method,
headers,
body,
signal: init?.signal ?? null,
};
calls.push(captured);
// Handle ACP initialize
if (url.endsWith('/acp') && method === 'POST' && !initDone) {
const parsed = body ? JSON.parse(body) : {};
if (parsed.method === 'initialize') {
initDone = true;
const extraHeaders: Record<string, string> = {};
if (opts?.connectionIdHeader) {
extraHeaders['acp-connection-id'] = opts.connectionIdHeader;
}
return jsonResponse(
200,
{
jsonrpc: '2.0',
id: parsed.id,
result: opts?.initResult ?? { v: 1 },
},
extraHeaders,
);
}
}
// Handle GET /capabilities (called after init)
if (url.endsWith('/capabilities') && method === 'GET' && !capsDone) {
capsDone = true;
if (opts?.capabilitiesResult) {
return jsonResponse(200, opts.capabilitiesResult);
}
return jsonResponse(200, { v: 1, transports: ['rest'] });
}
// Subsequent requests
if (opts?.subsequentReply) {
return opts.subsequentReply(captured);
}
// Default: parse body as JSON-RPC and return a success
if (body) {
const parsed = JSON.parse(body);
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { ok: true },
});
}
return jsonResponse(200, { ok: true });
},
) as unknown as typeof globalThis.fetch;
return { fetch: fetchImpl, calls };
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AcpHttpTransport', () => {
// ---- Static properties ------------------------------------------------
describe('static properties', () => {
it('type is "acp-http"', () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
expect(transport.type).toBe('acp-http');
transport.dispose();
});
it('supportsReplay is true', () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
expect(transport.supportsReplay).toBe(true);
transport.dispose();
});
it('connected is false before initialization', () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
expect(transport.connected).toBe(false);
transport.dispose();
});
});
// ---- Initialize handshake ---------------------------------------------
describe('initialize handshake', () => {
it('sends initialize JSON-RPC request to /acp on first fetch', async () => {
const { fetch, calls } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
// Trigger init by calling fetch for capabilities
await transport.fetch('http://d/capabilities', { method: 'GET' });
// First call should be POST /acp with initialize
const initCall = calls.find(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
expect(initCall).toBeDefined();
const initBody = JSON.parse(initCall!.body!);
expect(initBody.method).toBe('initialize');
expect(initBody.jsonrpc).toBe('2.0');
expect(initBody.params.clientInfo).toBeDefined();
transport.dispose();
});
it('connected is true after initialization', async () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
await transport.fetch('http://d/capabilities', { method: 'GET' });
expect(transport.connected).toBe(true);
transport.dispose();
});
it('sets Authorization header when token provided', async () => {
const { fetch, calls } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', 'my-token', fetch);
await transport.fetch('http://d/capabilities', { method: 'GET' });
const initCall = calls.find(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
expect(initCall!.headers['authorization']).toBe('Bearer my-token');
transport.dispose();
});
});
// ---- ConnectionId extraction ------------------------------------------
describe('connectionId extraction', () => {
it('extracts connectionId from response header', async () => {
const { fetch, calls } = initAwareFetch({
connectionIdHeader: 'conn-hdr-123',
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
// Trigger init
await transport.fetch('http://d/capabilities', { method: 'GET' });
// Make a subsequent request that should include the connection id
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({ model: 'test' }),
});
// Find the POST /acp call after init
const postCalls = calls.filter(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
// The second POST /acp call (after init) should have the header
const lastPost = postCalls[postCalls.length - 1];
expect(lastPost.headers['acp-connection-id']).toBe('conn-hdr-123');
transport.dispose();
});
it('extracts connectionId from JSON body fallback', async () => {
const { fetch, calls } = initAwareFetch({
initResult: {
v: 1,
_meta: { qwen: { connectionId: 'conn-body-456' } },
},
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
await transport.fetch('http://d/capabilities', { method: 'GET' });
// Make a subsequent request
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
});
const postCalls = calls.filter(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
const lastPost = postCalls[postCalls.length - 1];
expect(lastPost.headers['acp-connection-id']).toBe('conn-body-456');
transport.dispose();
});
it('extracts connectionId from agentCapabilities path', async () => {
const { fetch, calls } = initAwareFetch({
initResult: {
agentCapabilities: {
_meta: { qwen: { connectionId: 'conn-agent-789' } },
},
},
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
await transport.fetch('http://d/capabilities', { method: 'GET' });
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
});
const postCalls = calls.filter(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
const lastPost = postCalls[postCalls.length - 1];
expect(lastPost.headers['acp-connection-id']).toBe('conn-agent-789');
transport.dispose();
});
it('header takes precedence over body connectionId', async () => {
const { fetch, calls } = initAwareFetch({
connectionIdHeader: 'from-header',
initResult: {
_meta: { qwen: { connectionId: 'from-body' } },
},
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
await transport.fetch('http://d/capabilities', { method: 'GET' });
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
});
const postCalls = calls.filter(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
const lastPost = postCalls[postCalls.length - 1];
expect(lastPost.headers['acp-connection-id']).toBe('from-header');
transport.dispose();
});
});
// ---- URL→JSON-RPC mapping ---------------------------------------------
describe('URL→JSON-RPC mapping', () => {
it('GET /capabilities returns cached init result', async () => {
const { fetch } = initAwareFetch({
capabilitiesResult: { v: 2, transports: ['acp-ws'] },
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/capabilities', {
method: 'GET',
});
expect(res.status).toBe(200);
const body = await res.json();
expect(body.v).toBe(2);
transport.dispose();
});
it('POST /session sends session/new JSON-RPC', async () => {
const { fetch, calls } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({ model: 'test' }),
});
// Find the POST /acp call that carries session/new
const postCalls = calls.filter(
(c) => c.url.endsWith('/acp') && c.method === 'POST',
);
const sessionNewCall = postCalls.find((c) => {
if (!c.body) return false;
const parsed = JSON.parse(c.body);
return parsed.method === 'session/new';
});
expect(sessionNewCall).toBeDefined();
const parsed = JSON.parse(sessionNewCall!.body!);
expect(parsed.params.model).toBe('test');
transport.dispose();
});
it('returns 404 for unknown routes', async () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/totally-unknown', {
method: 'GET',
});
expect(res.status).toBe(404);
transport.dispose();
});
it('POST /session/:id/cancel sends notification and returns 204', async () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/session/s1/cancel', {
method: 'POST',
});
expect(res.status).toBe(204);
transport.dispose();
});
});
// ---- Error handling ---------------------------------------------------
describe('error handling', () => {
it('maps JSON-RPC error to HTTP error status', async () => {
const { fetch } = initAwareFetch({
subsequentReply: () =>
jsonResponse(200, {
jsonrpc: '2.0',
id: 2,
error: { code: -32601, message: 'Method not found' },
}),
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
});
expect(res.status).toBe(404);
transport.dispose();
});
it('preserves HTTP status from error data.httpStatus', async () => {
const { fetch } = initAwareFetch({
subsequentReply: () =>
jsonResponse(200, {
jsonrpc: '2.0',
id: 2,
error: {
code: -401,
message: 'HTTP 401: Unauthorized',
data: { httpStatus: 401 },
},
}),
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
});
expect(res.status).toBe(401);
transport.dispose();
});
});
// ---- Abort signal forwarding ------------------------------------------
describe('abort signal forwarding', () => {
it('forwards abort signal to underlying fetch', async () => {
let capturedSignal: AbortSignal | null = null;
const { fetch } = initAwareFetch({
subsequentReply: (req) => {
capturedSignal = req.signal ?? null;
return jsonResponse(200, {
jsonrpc: '2.0',
id: 2,
result: { ok: true },
});
},
});
const transport = new AcpHttpTransport('http://d', undefined, fetch);
const ctrl = new AbortController();
await transport.fetch('http://d/session', {
method: 'POST',
body: JSON.stringify({}),
signal: ctrl.signal,
});
expect(capturedSignal).not.toBeNull();
transport.dispose();
});
});
// ---- Initialize retry -------------------------------------------------
describe('initialize retry on failure', () => {
it('retries initialize after failure', async () => {
let initAttempt = 0;
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<string, string> = {};
if (init?.headers) {
if (
typeof init.headers === 'object' &&
!Array.isArray(init.headers) &&
!(init.headers instanceof Headers)
) {
for (const [k, v] of Object.entries(init.headers)) {
headers[k.toLowerCase()] = v;
}
}
}
const body = typeof init?.body === 'string' ? init.body : null;
calls.push({ url, method, headers, body });
if (url.endsWith('/acp') && method === 'POST') {
const parsed = body ? JSON.parse(body) : {};
if (parsed.method === 'initialize') {
initAttempt++;
if (initAttempt === 1) {
// First attempt fails
return jsonResponse(500, { error: 'server error' });
}
// Second attempt succeeds
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { v: 1 },
});
}
return jsonResponse(200, {
jsonrpc: '2.0',
id: parsed.id,
result: { ok: true },
});
}
if (url.endsWith('/capabilities')) {
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
},
) as unknown as typeof globalThis.fetch;
const transport = new AcpHttpTransport('http://d', undefined, fetchImpl);
// First attempt should fail
await expect(
transport.fetch('http://d/capabilities', { method: 'GET' }),
).rejects.toThrow();
// Second attempt should succeed (initPromise was reset)
const res = await transport.fetch('http://d/capabilities', {
method: 'GET',
});
expect(res.status).toBe(200);
expect(initAttempt).toBe(2);
transport.dispose();
});
});
// ---- dispose() --------------------------------------------------------
describe('dispose()', () => {
it('fetch throws after dispose', async () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
transport.dispose();
await expect(
transport.fetch('http://d/capabilities', { method: 'GET' }),
).rejects.toThrow(DaemonTransportClosedError);
});
it('is idempotent', () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
transport.dispose();
expect(() => transport.dispose()).not.toThrow();
});
it('connected is false after dispose', async () => {
const { fetch } = initAwareFetch();
const transport = new AcpHttpTransport('http://d', undefined, fetch);
// Initialize first
await transport.fetch('http://d/capabilities', { method: 'GET' });
expect(transport.connected).toBe(true);
transport.dispose();
expect(transport.connected).toBe(false);
});
});
});

View file

@ -0,0 +1,275 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { AcpWsTransport } from '../../src/daemon/AcpWsTransport.js';
import { DaemonTransportClosedError } from '../../src/daemon/DaemonTransport.js';
import {
matchRoute,
synthesizeResponse,
jsonRpcErrorToHttpStatus,
} from '../../src/daemon/acpTransportUtils.js';
// ---------------------------------------------------------------------------
// Since real WebSocket testing is impractical, we test:
// 1. Constructor & static properties
// 2. The route matching + response synthesis used by AcpWsTransport
// 3. Dispose behavior
// 4. acpTransportUtils shared helpers
// ---------------------------------------------------------------------------
describe('AcpWsTransport', () => {
// ---- Static properties ------------------------------------------------
describe('static properties', () => {
it('type is "acp-ws"', () => {
const transport = new AcpWsTransport('ws://localhost:8080/acp');
expect(transport.type).toBe('acp-ws');
transport.dispose();
});
it('supportsReplay is false', () => {
const transport = new AcpWsTransport('ws://localhost:8080/acp');
expect(transport.supportsReplay).toBe(false);
transport.dispose();
});
it('connected starts as false', () => {
const transport = new AcpWsTransport('ws://localhost:8080/acp');
expect(transport.connected).toBe(false);
transport.dispose();
});
});
// ---- Constructor ------------------------------------------------------
describe('constructor', () => {
it('stores wsUrl and optional token', () => {
const t1 = new AcpWsTransport('ws://host/acp', 'tok-123');
expect(t1.type).toBe('acp-ws');
t1.dispose();
const t2 = new AcpWsTransport('ws://host/acp');
expect(t2.type).toBe('acp-ws');
t2.dispose();
});
});
// ---- dispose() --------------------------------------------------------
describe('dispose()', () => {
it('sets connected to false', () => {
const transport = new AcpWsTransport('ws://host/acp');
transport.dispose();
expect(transport.connected).toBe(false);
});
it('is idempotent', () => {
const transport = new AcpWsTransport('ws://host/acp');
transport.dispose();
expect(() => transport.dispose()).not.toThrow();
});
it('fetch throws after dispose', async () => {
const transport = new AcpWsTransport('ws://host/acp');
transport.dispose();
await expect(
transport.fetch('http://h/capabilities', { method: 'GET' }),
).rejects.toThrow(DaemonTransportClosedError);
});
it('subscribeEvents throws after dispose', async () => {
const transport = new AcpWsTransport('ws://host/acp');
transport.dispose();
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow(DaemonTransportClosedError);
});
});
});
// ---------------------------------------------------------------------------
// acpTransportUtils — shared by both AcpWsTransport and AcpHttpTransport
// ---------------------------------------------------------------------------
describe('acpTransportUtils', () => {
// ---- matchRoute -------------------------------------------------------
describe('matchRoute', () => {
it('POST /session → session/new', () => {
const result = matchRoute('/session', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/new');
});
it('POST /session/:id/prompt → session/prompt with sessionId', () => {
const result = matchRoute('/session/abc/prompt', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/prompt');
const params = result!.mapping.extractParams(
result!.segments,
{ message: 'hello' },
'POST',
);
expect(params.sessionId).toBe('abc');
expect(params.message).toBe('hello');
});
it('DELETE /session/:id → session/close', () => {
const result = matchRoute('/session/xyz', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/close');
});
it('GET /capabilities → _capabilities', () => {
const result = matchRoute('/capabilities', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_capabilities');
});
it('GET /health → _qwen/health', () => {
const result = matchRoute('/health', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/health');
});
it('returns null for unknown path', () => {
expect(matchRoute('/totally-unknown', 'GET')).toBeNull();
});
it('returns null for wrong HTTP method', () => {
expect(matchRoute('/session', 'GET')).toBeNull();
});
});
// ---- synthesizeResponse -----------------------------------------------
describe('synthesizeResponse', () => {
it('creates a response with the given status', () => {
const res = synthesizeResponse(200, { hello: 'world' });
expect(res.status).toBe(200);
});
it('JSON-encodes the body', async () => {
const res = synthesizeResponse(200, { key: 'value' });
const body = await res.json();
expect(body).toEqual({ key: 'value' });
});
it('sets content-type to application/json when body is present', () => {
const res = synthesizeResponse(200, { data: true });
expect(res.headers.get('content-type')).toBe('application/json');
});
it('returns empty body for null', async () => {
const res = synthesizeResponse(204, null);
expect(res.status).toBe(204);
const text = await res.text();
expect(text).toBe('');
});
it('handles nested objects', async () => {
const res = synthesizeResponse(200, { a: { b: [1, 2, 3] } });
const body = await res.json();
expect(body.a.b).toEqual([1, 2, 3]);
});
});
// ---- jsonRpcErrorToHttpStatus -----------------------------------------
describe('jsonRpcErrorToHttpStatus', () => {
it('-32600 (invalid request) → 400', () => {
expect(jsonRpcErrorToHttpStatus(-32600)).toBe(400);
});
it('-32601 (method not found) → 404', () => {
expect(jsonRpcErrorToHttpStatus(-32601)).toBe(404);
});
it('-32602 (invalid params) → 400', () => {
expect(jsonRpcErrorToHttpStatus(-32602)).toBe(400);
});
it('-32603 (internal error) → 500', () => {
expect(jsonRpcErrorToHttpStatus(-32603)).toBe(500);
});
it('-32700 (parse error) → 400', () => {
expect(jsonRpcErrorToHttpStatus(-32700)).toBe(400);
});
it('unknown code → 500 (default)', () => {
expect(jsonRpcErrorToHttpStatus(-1)).toBe(500);
expect(jsonRpcErrorToHttpStatus(42)).toBe(500);
});
});
});
// ---------------------------------------------------------------------------
// Event queue cap & session filter behavior
// These are tested via the exported MAX_GENERATOR_QUEUE_SIZE constant
// behavior description; we verify the constant indirectly via route table
// since the queue is internal.
// ---------------------------------------------------------------------------
describe('AcpWsTransport route mapping used by fetch()', () => {
// These tests verify that the URL→method mapping the transport would
// use at runtime is correct. In a real scenario, `fetch()` calls
// `matchRoute()` internally.
it('POST /session/:id/cancel is a notification', () => {
const result = matchRoute('/session/s1/cancel', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.notification).toBe(true);
expect(result!.mapping.method).toBe('session/cancel');
});
it('POST /session/:id/load extracts sessionId', () => {
const result = matchRoute('/session/my-session/load', 'POST');
expect(result).not.toBeNull();
const params = result!.mapping.extractParams(
result!.segments,
{ checkpoint: 5 },
'POST',
);
expect(params).toEqual({ sessionId: 'my-session', checkpoint: 5 });
});
it('POST /session/:id/permission/:reqId extracts both params', () => {
const result = matchRoute('/session/s1/permission/r2', 'POST');
expect(result).not.toBeNull();
const params = result!.mapping.extractParams(
result!.segments,
{ allow: true },
'POST',
);
expect(params.sessionId).toBe('s1');
expect(params.requestId).toBe('r2');
expect(params.allow).toBe(true);
});
it('GET /workspace/path/to/file extracts path', () => {
const result = matchRoute('/workspace/path/to/file', 'GET');
expect(result).not.toBeNull();
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
);
expect(params.path).toBe('path/to/file');
});
it('PATCH /session/:id/metadata extracts sessionId', () => {
const result = matchRoute('/session/s5/metadata', 'PATCH');
expect(result).not.toBeNull();
const params = result!.mapping.extractParams(
result!.segments,
{ title: 'new' },
'PATCH',
);
expect(params.sessionId).toBe('s5');
expect(params.title).toBe('new');
});
});

View file

@ -0,0 +1,397 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { AutoReconnectTransport } from '../../src/daemon/AutoReconnectTransport.js';
import {
DaemonTransportClosedError,
type DaemonTransport,
type DaemonTransportType,
} from '../../src/daemon/DaemonTransport.js';
import type { DaemonEvent } from '../../src/daemon/types.js';
// ---------------------------------------------------------------------------
// Mock transport helper
// ---------------------------------------------------------------------------
function createMockTransport(
overrides?: Partial<DaemonTransport> & { connected?: boolean },
): DaemonTransport {
const base: DaemonTransport = {
type: 'rest' as DaemonTransportType,
supportsReplay: true,
get connected() {
return true;
},
fetch: vi.fn(async () => new Response('{}', { status: 200 })),
subscribeEvents: vi.fn(async function* () {
yield { type: 'test', data: {}, id: 1, v: 1 } as DaemonEvent;
}),
dispose: vi.fn(),
};
if (overrides) {
// Use Object.defineProperty for getters and direct assignment for others
for (const key of Object.keys(overrides) as (keyof typeof overrides)[]) {
if (key === 'connected') {
// Skip — connected is handled separately when provided as a getter
continue;
}
const descriptor = Object.getOwnPropertyDescriptor(overrides, key);
if (descriptor) {
Object.defineProperty(base, key, descriptor);
}
}
}
return base;
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('AutoReconnectTransport', () => {
// ---- Delegation to inner transport ------------------------------------
describe('delegation', () => {
it('type delegates to inner transport', () => {
const inner = createMockTransport({ type: 'acp-ws' });
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
expect(transport.type).toBe('acp-ws');
transport.dispose();
});
it('connected delegates to inner transport', () => {
let isConnected = true;
const inner = createMockTransport();
// Override connected with a dynamic getter
Object.defineProperty(inner, 'connected', {
get: () => isConnected,
configurable: true,
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
expect(transport.connected).toBe(true);
isConnected = false;
expect(transport.connected).toBe(false);
transport.dispose();
});
it('fetch delegates to inner transport', async () => {
const mockResponse = new Response('{"ok":true}', { status: 200 });
const inner = createMockTransport({
fetch: vi.fn(async () => mockResponse),
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
const res = await transport.fetch('http://d/health', { method: 'GET' });
expect(res).toBe(mockResponse);
expect(inner.fetch).toHaveBeenCalledTimes(1);
transport.dispose();
});
it('subscribeEvents delegates to inner transport', async () => {
const inner = createMockTransport();
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
const events: DaemonEvent[] = [];
for await (const event of transport.subscribeEvents('s1')) {
events.push(event);
}
expect(events).toHaveLength(1);
expect(inner.subscribeEvents).toHaveBeenCalledTimes(1);
transport.dispose();
});
});
// ---- Reconnect on DaemonTransportClosedError --------------------------
describe('reconnect', () => {
it('reconnects on DaemonTransportClosedError from fetch', async () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError('closed');
}),
});
// Provide a mock fetch so the fallback RestSseTransport uses it
// instead of the real globalThis.fetch.
const mockFetch = vi.fn(
async () => new Response('{"ok":true}', { status: 200 }),
) as unknown as typeof globalThis.fetch;
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
fetch: mockFetch,
});
// Should succeed after reconnect (inner replaced by RestSseTransport)
const res = await transport.fetch('http://d/health', { method: 'GET' });
expect(res.status).toBe(200);
expect(transport.type).toBe('rest');
transport.dispose();
});
it('propagates non-transport errors without reconnecting', async () => {
const inner = createMockTransport({
fetch: vi.fn(async () => {
throw new Error('network error');
}),
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
await expect(
transport.fetch('http://d/health', { method: 'GET' }),
).rejects.toThrow('network error');
transport.dispose();
});
it('uses factory for reconnect when available', async () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError();
}),
});
const newTransport = createMockTransport({
type: 'acp-ws',
fetch: vi.fn(async () => new Response('{"ws":true}', { status: 200 })),
});
const factory = vi.fn(async (_type: DaemonTransportType) => {
return newTransport;
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
factory,
preferredType: 'acp-ws',
});
const res = await transport.fetch('http://d/health', { method: 'GET' });
expect(res.status).toBe(200);
expect(factory).toHaveBeenCalledWith('acp-ws');
transport.dispose();
});
it('falls back to REST when factory fails', async () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError();
}),
});
const factory = vi.fn(async () => {
throw new Error('factory failed');
});
const mockFetch = vi.fn(
async () => new Response('{"rest":true}', { status: 200 }),
) as unknown as typeof globalThis.fetch;
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
factory,
fetch: mockFetch,
});
const res = await transport.fetch('http://d/health', { method: 'GET' });
expect(res.status).toBe(200);
// After reconnect, the inner should be a RestSseTransport
expect(transport.type).toBe('rest');
transport.dispose();
});
});
// ---- Reconnect mutex --------------------------------------------------
describe('reconnect mutex', () => {
it('prevents concurrent reconnects', async () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError();
}),
});
const factory = vi.fn(async () => {
// Simulate async factory work
await new Promise((resolve) => setTimeout(resolve, 10));
return createMockTransport({
fetch: vi.fn(async () => new Response('ok', { status: 200 })),
});
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
factory,
});
// Fire two concurrent fetches that both trigger reconnect
const [res1, res2] = await Promise.all([
transport.fetch('http://d/health', { method: 'GET' }),
transport.fetch('http://d/status', { method: 'GET' }),
]);
expect(res1.status).toBe(200);
expect(res2.status).toBe(200);
// Factory should only be called once due to mutex
expect(factory).toHaveBeenCalledTimes(1);
transport.dispose();
});
});
// ---- dispose() --------------------------------------------------------
describe('dispose()', () => {
it('disposes inner transport', () => {
const inner = createMockTransport();
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
transport.dispose();
expect(inner.dispose).toHaveBeenCalledTimes(1);
});
it('is idempotent', () => {
const inner = createMockTransport();
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
transport.dispose();
expect(() => transport.dispose()).not.toThrow();
});
it('fetch throws after dispose', async () => {
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
});
transport.dispose();
await expect(
transport.fetch('http://d/health', { method: 'GET' }),
).rejects.toThrow(DaemonTransportClosedError);
});
it('subscribeEvents throws after dispose', async () => {
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
});
transport.dispose();
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow(DaemonTransportClosedError);
});
it('does not reconnect after dispose', async () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError();
}),
});
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
});
transport.dispose();
await expect(
transport.fetch('http://d/health', { method: 'GET' }),
).rejects.toThrow(DaemonTransportClosedError);
});
});
// ---- supportsReplay from inner ----------------------------------------
describe('supportsReplay', () => {
it('reflects inner transport supportsReplay=true', () => {
const inner = createMockTransport({ supportsReplay: true });
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
expect(transport.supportsReplay).toBe(true);
transport.dispose();
});
it('reflects inner transport supportsReplay=false', () => {
const inner = createMockTransport({ supportsReplay: false });
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: inner,
});
expect(transport.supportsReplay).toBe(false);
transport.dispose();
});
});
// ---- Default behavior -------------------------------------------------
describe('defaults', () => {
it('creates RestSseTransport as default inner when no initial given', () => {
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
});
expect(transport.type).toBe('rest');
transport.dispose();
});
it('preferredType defaults to rest', () => {
const failingInner = createMockTransport({
fetch: vi.fn(async () => {
throw new DaemonTransportClosedError();
}),
});
const mockFetch = vi.fn(
async () => new Response('ok', { status: 200 }),
) as unknown as typeof globalThis.fetch;
const transport = new AutoReconnectTransport({
baseUrl: 'http://d',
initial: failingInner,
fetch: mockFetch,
});
// After a reconnect (no factory), it creates a REST transport
// (can't easily verify preferredType directly, but we verify
// the fallback behavior results in a REST transport)
expect(transport.type).toBe('rest'); // from initial mock
transport.dispose();
});
});
});

View file

@ -0,0 +1,424 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi } from 'vitest';
import { RestSseTransport } from '../../src/daemon/RestSseTransport.js';
import { DaemonTransportClosedError } from '../../src/daemon/DaemonTransport.js';
import { DaemonHttpError } from '../../src/daemon/DaemonHttpError.js';
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
interface CapturedRequest {
url: string;
method: string;
headers: Record<string, string>;
body: string | null;
signal?: AbortSignal | null;
}
function recordingFetch(
reply: (req: CapturedRequest) => Response | Promise<Response>,
): { 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<string, string> = {};
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,
signal: init?.signal ?? null,
};
calls.push(captured);
return reply(captured);
},
) as unknown as typeof globalThis.fetch;
return { fetch: fetchImpl, calls };
}
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
function sseResponse(frames: string): Response {
const encoder = new TextEncoder();
const body = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoder.encode(frames));
controller.close();
},
});
return new Response(body, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('RestSseTransport', () => {
// ---- Static properties ------------------------------------------------
describe('static properties', () => {
it('type is "rest"', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
expect(transport.type).toBe('rest');
});
it('supportsReplay is true', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
expect(transport.supportsReplay).toBe(true);
});
it('connected is true before dispose', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
expect(transport.connected).toBe(true);
});
it('connected is false after dispose', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
transport.dispose();
expect(transport.connected).toBe(false);
});
});
// ---- fetch() ----------------------------------------------------------
describe('fetch()', () => {
it('delegates url and init to the injected fetch', async () => {
const { fetch, calls } = recordingFetch(() =>
jsonResponse(200, { ok: 1 }),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/health', {
method: 'GET',
headers: { 'X-Custom': 'val' },
});
expect(res.status).toBe(200);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('http://d/health');
expect(calls[0].method).toBe('GET');
});
it('passes headers through', async () => {
const { fetch, calls } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
await transport.fetch('http://d/foo', {
method: 'POST',
headers: {
Authorization: 'Bearer xyz',
'Content-Type': 'application/json',
},
body: '{}',
});
expect(calls[0].headers['authorization']).toBe('Bearer xyz');
expect(calls[0].headers['content-type']).toBe('application/json');
});
it('passes body through', async () => {
const { fetch, calls } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
const body = JSON.stringify({ prompt: 'hi' });
await transport.fetch('http://d/session', {
method: 'POST',
body,
});
expect(calls[0].body).toBe(body);
});
it('returns the injected fetch response directly', async () => {
const { fetch } = recordingFetch(() =>
jsonResponse(201, { sessionId: 's1' }),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const res = await transport.fetch('http://d/session', { method: 'POST' });
expect(res.status).toBe(201);
const data = await res.json();
expect(data).toEqual({ sessionId: 's1' });
});
it('throws DaemonTransportClosedError after dispose', async () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
transport.dispose();
await expect(
transport.fetch('http://d/health', { method: 'GET' }),
).rejects.toThrow(DaemonTransportClosedError);
});
it('propagates fetch errors', async () => {
const { fetch } = recordingFetch(() => {
throw new Error('network down');
});
const transport = new RestSseTransport('http://d', undefined, fetch);
await expect(
transport.fetch('http://d/health', { method: 'GET' }),
).rejects.toThrow('network down');
});
});
// ---- subscribeEvents() ------------------------------------------------
describe('subscribeEvents()', () => {
it('builds correct URL from baseUrl + sessionId', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport(
'http://daemon:8080',
undefined,
fetch,
);
const gen = transport.subscribeEvents('session-42');
const first = await gen.next();
expect(first.done).toBe(false);
expect(calls[0].url).toBe('http://daemon:8080/session/session-42/events');
});
it('sets Authorization header when token provided', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport(
'http://d',
'my-secret-token',
fetch,
);
const gen = transport.subscribeEvents('s1');
await gen.next();
expect(calls[0].headers['authorization']).toBe('Bearer my-secret-token');
});
it('does not set Authorization header when no token', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await gen.next();
expect(calls[0].headers['authorization']).toBeUndefined();
});
it('sets Last-Event-ID header when lastEventId provided', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":2,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1', { lastEventId: 99 });
await gen.next();
expect(calls[0].headers['last-event-id']).toBe('99');
});
it('applies maxQueued query parameter', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1', { maxQueued: 50 });
await gen.next();
expect(calls[0].url).toContain('?maxQueued=50');
});
it('does not append maxQueued when not specified', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await gen.next();
expect(calls[0].url).not.toContain('maxQueued');
});
it('rejects non-SSE content-type', async () => {
const { fetch } = recordingFetch(
() =>
new Response('not sse', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow(DaemonHttpError);
});
it('throws DaemonHttpError on non-ok response', async () => {
const { fetch } = recordingFetch(() =>
jsonResponse(404, { error: 'session not found' }),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow(DaemonHttpError);
});
it('throws DaemonHttpError with correct status on non-ok response', async () => {
const { fetch } = recordingFetch(() =>
jsonResponse(401, { error: 'unauthorized' }),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
try {
await gen.next();
expect.fail('should have thrown');
} catch (err) {
expect(err).toBeInstanceOf(DaemonHttpError);
expect((err as DaemonHttpError).status).toBe(401);
}
});
it('throws when response has no body', async () => {
const { fetch } = recordingFetch(
() =>
new Response(null, {
status: 200,
headers: { 'content-type': 'text/event-stream' },
}),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow('No SSE body');
});
it('parses SSE frames into DaemonEvents', async () => {
const frames = [
'data: {"type":"update","data":{"msg":"hello"},"id":1,"v":1}\n\n',
'data: {"type":"done","data":{},"id":2,"v":1}\n\n',
].join('');
const { fetch } = recordingFetch(() => sseResponse(frames));
const transport = new RestSseTransport('http://d', undefined, fetch);
const events: unknown[] = [];
for await (const event of transport.subscribeEvents('s1')) {
events.push(event);
}
expect(events).toHaveLength(2);
expect((events[0] as { type: string }).type).toBe('update');
expect((events[1] as { type: string }).type).toBe('done');
});
it('URL-encodes sessionId in path', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('has space/slash');
await gen.next();
expect(calls[0].url).toContain('has%20space%2Fslash');
});
it('throws DaemonTransportClosedError after dispose', async () => {
const { fetch } = recordingFetch(() => sseResponse(''));
const transport = new RestSseTransport('http://d', undefined, fetch);
transport.dispose();
const gen = transport.subscribeEvents('s1');
await expect(gen.next()).rejects.toThrow(DaemonTransportClosedError);
});
it('sets Accept header to text/event-stream', async () => {
const { fetch, calls } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1');
await gen.next();
expect(calls[0].headers['accept']).toBe('text/event-stream');
});
it('connect-phase timeout triggers abort', async () => {
// Create a fetch that hangs until aborted
const fetchFn = vi.fn(
async (_input: RequestInfo | URL, init?: RequestInit) => {
return new Promise<Response>((_, reject) => {
if (init?.signal) {
init.signal.addEventListener('abort', () => {
reject(
new DOMException('The operation was aborted', 'AbortError'),
);
});
}
});
},
) as unknown as typeof globalThis.fetch;
const transport = new RestSseTransport('http://d', undefined, fetchFn);
const gen = transport.subscribeEvents('s1', { connectTimeoutMs: 50 });
await expect(gen.next()).rejects.toThrow();
});
it('signal abort prevents iteration', async () => {
const ctrl = new AbortController();
ctrl.abort();
// When the signal is pre-aborted, the fetch call will receive
// the aborted signal. The mock fetch proceeds anyway, but
// parseSseStream sees the aborted signal and returns immediately.
const { fetch } = recordingFetch(() =>
sseResponse('data: {"type":"a","data":{},"id":1,"v":1}\n\n'),
);
const transport = new RestSseTransport('http://d', undefined, fetch);
const gen = transport.subscribeEvents('s1', { signal: ctrl.signal });
// With a pre-aborted signal, the generator should either throw
// or return done immediately (no events yielded).
const events: unknown[] = [];
try {
for await (const event of gen) {
events.push(event);
}
} catch {
// AbortError is acceptable too
}
// Either way, no events should be yielded
expect(events).toHaveLength(0);
});
});
// ---- dispose() --------------------------------------------------------
describe('dispose()', () => {
it('does not throw on first call', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
expect(() => transport.dispose()).not.toThrow();
});
it('is idempotent', () => {
const { fetch } = recordingFetch(() => jsonResponse(200, {}));
const transport = new RestSseTransport('http://d', undefined, fetch);
transport.dispose();
expect(() => transport.dispose()).not.toThrow();
});
});
});

View file

@ -0,0 +1,576 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect } from 'vitest';
import { ROUTE_TABLE } from '../../src/daemon/acpRouteTable.js';
import { matchRoute } from '../../src/daemon/acpTransportUtils.js';
// ---------------------------------------------------------------------------
// ROUTE_TABLE shape
// ---------------------------------------------------------------------------
describe('acpRouteTable ROUTE_TABLE', () => {
it('is a non-empty readonly array', () => {
expect(Array.isArray(ROUTE_TABLE)).toBe(true);
expect(ROUTE_TABLE.length).toBeGreaterThan(0);
});
it('every entry has httpMethod, pattern, and mapping', () => {
for (const entry of ROUTE_TABLE) {
expect(typeof entry.httpMethod).toBe('string');
expect(entry.pattern).toBeInstanceOf(RegExp);
expect(typeof entry.mapping.method).toBe('string');
expect(typeof entry.mapping.extractParams).toBe('function');
}
});
});
// ---------------------------------------------------------------------------
// matchRoute session routes
// ---------------------------------------------------------------------------
describe('acpRouteTable matchRoute', () => {
// ---- POST /session → session/new ------------------------------------
it('POST /session maps to session/new', () => {
const result = matchRoute('/session', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/new');
});
it('POST /session/ (trailing slash) maps to session/new', () => {
const result = matchRoute('/session/', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/new');
});
it('POST /session passes body through as params', () => {
const result = matchRoute('/session', 'POST')!;
const params = result.mapping.extractParams(
result.segments,
{ model: 'gpt-4' },
'POST',
);
expect(params).toEqual({ model: 'gpt-4' });
});
it('POST /session with non-record body returns empty params', () => {
const result = matchRoute('/session', 'POST')!;
const params = result.mapping.extractParams(
result.segments,
'not-an-object',
'POST',
);
expect(params).toEqual({});
});
// ---- POST /session/:id/prompt → session/prompt ---------------------
it('POST /session/:id/prompt maps to session/prompt', () => {
const result = matchRoute('/session/abc-123/prompt', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/prompt');
});
it('POST /session/:id/prompt extracts sessionId', () => {
const result = matchRoute('/session/abc-123/prompt', 'POST')!;
expect(result.segments[0]).toBe('abc-123');
const params = result.mapping.extractParams(
result.segments,
{ message: 'hello' },
'POST',
);
expect(params).toEqual({ sessionId: 'abc-123', message: 'hello' });
});
// ---- POST /session/:id/cancel → session/cancel (notification) ------
it('POST /session/:id/cancel maps to session/cancel', () => {
const result = matchRoute('/session/s1/cancel', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/cancel');
expect(result!.mapping.notification).toBe(true);
});
it('POST /session/:id/cancel extracts sessionId', () => {
const result = matchRoute('/session/s1/cancel', 'POST')!;
const params = result.mapping.extractParams(result.segments, {}, 'POST');
expect(params).toEqual({ sessionId: 's1' });
});
// ---- DELETE /session/:id → session/close ----------------------------
it('DELETE /session/:id maps to session/close', () => {
const result = matchRoute('/session/s2', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/close');
});
it('DELETE /session/:id/ (trailing slash) maps to session/close', () => {
const result = matchRoute('/session/s2/', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/close');
});
// ---- POST /session/:id/load → session/load --------------------------
it('POST /session/:id/load maps to session/load', () => {
const result = matchRoute('/session/s3/load', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/load');
const params = result!.mapping.extractParams(
result!.segments,
{ resumeFrom: 5 },
'POST',
);
expect(params).toEqual({ sessionId: 's3', resumeFrom: 5 });
});
// ---- POST /session/:id/resume → session/resume ----------------------
it('POST /session/:id/resume maps to session/resume', () => {
const result = matchRoute('/session/s4/resume', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/resume');
});
// ---- POST /session/:id/permission/:reqId → session/permission ------
it('POST /session/:id/permission/:reqId maps to session/permission', () => {
const result = matchRoute('/session/s5/permission/req-7', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/permission');
const params = result!.mapping.extractParams(
result!.segments,
{ allow: true },
'POST',
);
expect(params).toEqual({
sessionId: 's5',
requestId: 'req-7',
allow: true,
});
});
// ---- POST /permission/:reqId (no session prefix) --------------------
it('POST /permission/:reqId maps to session/permission', () => {
const result = matchRoute('/permission/req-9', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/permission');
const params = result!.mapping.extractParams(
result!.segments,
{ allow: false },
'POST',
);
expect(params).toEqual({ requestId: 'req-9', allow: false });
});
// ---- GET /capabilities → _capabilities (special) -------------------
it('GET /capabilities maps to _capabilities', () => {
const result = matchRoute('/capabilities', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_capabilities');
});
it('GET /capabilities/ (trailing slash) maps to _capabilities', () => {
const result = matchRoute('/capabilities/', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_capabilities');
});
// ---- GET /health → _qwen/health ------------------------------------
it('GET /health maps to _qwen/health', () => {
const result = matchRoute('/health', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/health');
});
// ---- POST /session/:id/model → session/set_model --------------------
it('POST /session/:id/model maps to session/set_model', () => {
const result = matchRoute('/session/s7/model', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/set_model');
});
// ---- Vendor session extensions (_qwen/ prefix) ----------------------
it('PATCH /session/:id/metadata maps to _qwen/session/update_metadata', () => {
const result = matchRoute('/session/s6/metadata', 'PATCH');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/update_metadata');
});
it('POST /session/:id/heartbeat maps to _qwen/session/heartbeat', () => {
const result = matchRoute('/session/s8/heartbeat', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/heartbeat');
});
it('POST /session/:id/recap maps to _qwen/session/recap', () => {
const result = matchRoute('/session/s9/recap', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/recap');
});
it('POST /session/:id/btw maps to _qwen/session/btw', () => {
const result = matchRoute('/session/s10/btw', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/btw');
});
it('POST /session/:id/shell maps to _qwen/session/shell', () => {
const result = matchRoute('/session/s11/shell', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/shell');
});
it('POST /session/:id/branch maps to session/fork', () => {
const result = matchRoute('/session/s13/branch', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('session/fork');
});
it('POST /session/:id/detach maps to _qwen/session/detach', () => {
const result = matchRoute('/session/s14/detach', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/detach');
});
// ---- Session diagnostic routes (_qwen/ prefix) ----------------------
it('GET /session/:id/context maps to _qwen/session/context', () => {
const result = matchRoute('/session/s14/context', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/context');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
);
expect(params).toEqual({ sessionId: 's14' });
});
it('GET /session/:id/context-usage maps to _qwen/session/context_usage', () => {
const result = matchRoute('/session/s15/context-usage', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/context_usage');
});
it('GET /session/:id/supported-commands maps to _qwen/session/supported_commands', () => {
const result = matchRoute('/session/s16/supported-commands', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/supported_commands');
});
it('GET /session/:id/tasks maps to _qwen/session/tasks', () => {
const result = matchRoute('/session/s17/tasks', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/session/tasks');
});
// ---- Granular workspace routes ----------------------------------------
it('GET /workspace/mcp maps to _qwen/workspace/mcp', () => {
const result = matchRoute('/workspace/mcp', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/mcp');
});
it('GET /workspace/skills maps to _qwen/workspace/skills', () => {
const result = matchRoute('/workspace/skills', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/skills');
});
it('GET /workspace/providers maps to _qwen/workspace/providers', () => {
const result = matchRoute('/workspace/providers', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/providers');
});
it('GET /workspace/env maps to _qwen/workspace/env', () => {
const result = matchRoute('/workspace/env', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/env');
});
it('GET /workspace/preflight maps to _qwen/workspace/preflight', () => {
const result = matchRoute('/workspace/preflight', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/preflight');
});
it('POST /workspace/init maps to _qwen/workspace/init', () => {
const result = matchRoute('/workspace/init', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/init');
});
it('GET /workspace/tools maps to _qwen/workspace/tools', () => {
const result = matchRoute('/workspace/tools', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/tools');
});
it('GET /workspace/memory maps to _qwen/workspace/memory', () => {
const result = matchRoute('/workspace/memory', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/memory');
});
it('POST /workspace/memory maps to _qwen/workspace/memory/write', () => {
const result = matchRoute('/workspace/memory', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/memory/write');
const params = result!.mapping.extractParams(
result!.segments,
{ content: 'hi' },
'POST',
);
expect(params).toEqual({ content: 'hi' });
});
it('GET /workspace/agents maps to _qwen/workspace/agents/list', () => {
const result = matchRoute('/workspace/agents', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/agents/list');
});
it('POST /workspace/agents maps to _qwen/workspace/agents/create', () => {
const result = matchRoute('/workspace/agents', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/agents/create');
});
it('GET /workspace/agents/:agentType maps to _qwen/workspace/agents/get', () => {
const result = matchRoute('/workspace/agents/coder', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/agents/get');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
);
expect(params).toEqual({ agentType: 'coder' });
});
it('DELETE /workspace/agents/:agentType maps to _qwen/workspace/agents/delete', () => {
const result = matchRoute('/workspace/agents/coder', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/agents/delete');
const params = result!.mapping.extractParams(
result!.segments,
{ scope: 'workspace' },
'DELETE',
);
expect(params).toEqual({ agentType: 'coder', scope: 'workspace' });
});
it('GET /workspace/mcp/:server/tools maps to _qwen/workspace/mcp/tools', () => {
const result = matchRoute('/workspace/mcp/fs/tools', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/mcp/tools');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
);
expect(params).toEqual({ serverName: 'fs' });
});
it('POST /workspace/mcp/servers maps to _qwen/workspace/mcp/servers/add', () => {
const result = matchRoute('/workspace/mcp/servers', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/mcp/servers/add');
const params = result!.mapping.extractParams(
result!.segments,
{ name: 'test', config: {} },
'POST',
);
expect(params).toEqual({ name: 'test', config: {} });
});
it('DELETE /workspace/mcp/servers/:name maps to _qwen/workspace/mcp/servers/remove', () => {
const result = matchRoute('/workspace/mcp/servers/test', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/mcp/servers/remove');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'DELETE',
);
expect(params).toEqual({ name: 'test' });
});
it('POST /workspace/set-tool-enabled maps to _qwen/workspace/set_tool_enabled', () => {
const result = matchRoute('/workspace/set-tool-enabled', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/set_tool_enabled');
});
it('POST /workspace/mcp/:server/restart maps to _qwen/workspace/restart_mcp_server', () => {
const result = matchRoute('/workspace/mcp/fs/restart', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/restart_mcp_server');
const params = result!.mapping.extractParams(
result!.segments,
{ entryIndex: 0 },
'POST',
);
expect(params).toEqual({ serverName: 'fs', entryIndex: 0 });
});
it('GET /workspace/auth/status maps to _qwen/workspace/auth/status', () => {
const result = matchRoute('/workspace/auth/status', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/auth/status');
});
it('POST /workspace/auth/device-flow maps to _qwen/workspace/auth/device_flow/start', () => {
const result = matchRoute('/workspace/auth/device-flow', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe(
'_qwen/workspace/auth/device_flow/start',
);
});
it('GET /workspace/auth/device-flow/:id maps to _qwen/workspace/auth/device_flow/get', () => {
const result = matchRoute('/workspace/auth/device-flow/flow-1', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/workspace/auth/device_flow/get');
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'GET',
);
expect(params).toEqual({ id: 'flow-1' });
});
it('DELETE /workspace/auth/device-flow/:id maps to _qwen/workspace/auth/device_flow/cancel', () => {
const result = matchRoute('/workspace/auth/device-flow/flow-1', 'DELETE');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe(
'_qwen/workspace/auth/device_flow/cancel',
);
const params = result!.mapping.extractParams(
result!.segments,
undefined,
'DELETE',
);
expect(params).toEqual({ id: 'flow-1' });
});
// ---- File system routes -----------------------------------------------
it('GET /file maps to _qwen/file/read', () => {
const result = matchRoute('/file', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/read');
});
it('GET /file/ (trailing slash) maps to _qwen/file/read', () => {
const result = matchRoute('/file/', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/read');
});
it('GET /file/bytes maps to _qwen/file/read_bytes', () => {
const result = matchRoute('/file/bytes', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/read_bytes');
});
it('GET /stat maps to _qwen/file/stat', () => {
const result = matchRoute('/stat', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/stat');
});
it('GET /list maps to _qwen/file/list', () => {
const result = matchRoute('/list', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/list');
});
it('GET /glob maps to _qwen/file/glob', () => {
const result = matchRoute('/glob', 'GET');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/glob');
});
it('POST /file/write maps to _qwen/file/write', () => {
const result = matchRoute('/file/write', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/write');
const params = result!.mapping.extractParams(
result!.segments,
{ path: '/a.txt', content: 'hi' },
'POST',
);
expect(params).toEqual({ path: '/a.txt', content: 'hi' });
});
it('POST /file/edit maps to _qwen/file/edit', () => {
const result = matchRoute('/file/edit', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/file/edit');
const params = result!.mapping.extractParams(
result!.segments,
{ path: '/b.txt', oldText: 'a', newText: 'b' },
'POST',
);
expect(params).toEqual({ path: '/b.txt', oldText: 'a', newText: 'b' });
});
// ---- Bulk session operations -------------------------------------------
it('POST /sessions/delete maps to _qwen/sessions/delete', () => {
const result = matchRoute('/sessions/delete', 'POST');
expect(result).not.toBeNull();
expect(result!.mapping.method).toBe('_qwen/sessions/delete');
const params = result!.mapping.extractParams(
result!.segments,
{ sessionIds: ['a', 'b'] },
'POST',
);
expect(params).toEqual({ sessionIds: ['a', 'b'] });
});
// ---- Removed routes (no dispatcher handler) ----------------------------
it('returns null for removed route /session/:id/approval-mode', () => {
expect(matchRoute('/session/s12/approval-mode', 'POST')).toBeNull();
});
// ---- Unknown/unmatched routes ---------------------------------------
it('returns null for unknown path', () => {
expect(matchRoute('/unknown/path', 'GET')).toBeNull();
});
it('returns null for wrong HTTP method on known path', () => {
// /session is POST-only
expect(matchRoute('/session', 'GET')).toBeNull();
// /capabilities is GET-only
expect(matchRoute('/capabilities', 'POST')).toBeNull();
});
it('returns null for empty path', () => {
expect(matchRoute('', 'GET')).toBeNull();
});
// ---- URL-encoded path segments --------------------------------------
it('decodes URL-encoded sessionId from path', () => {
const result = matchRoute('/session/has%20space/prompt', 'POST');
expect(result).not.toBeNull();
expect(result!.segments[0]).toBe('has space');
});
});

View file

@ -0,0 +1,292 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { negotiateTransport } from '../../src/daemon/negotiateTransport.js';
// ---------------------------------------------------------------------------
// We need to mock globalThis.fetch since negotiateTransport uses it.
// ---------------------------------------------------------------------------
const originalFetch = globalThis.fetch;
function installMockFetch(
handler: (url: string, init?: RequestInit) => Response | Promise<Response>,
): void {
globalThis.fetch = vi.fn(
async (input: RequestInfo | URL, init?: RequestInit) => {
const url =
typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
return handler(url, init);
},
) as unknown as typeof globalThis.fetch;
}
afterEach(() => {
globalThis.fetch = originalFetch;
});
function jsonResponse(status: number, body: unknown): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'content-type': 'application/json' },
});
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('negotiateTransport', () => {
// ---- REST fallback cases ----------------------------------------------
describe('REST fallback', () => {
it('returns REST when capabilities has no transports field', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
it('returns REST when capabilities has transports: ["rest"]', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1, transports: ['rest'] });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
it('returns REST when capabilities has empty transports array', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1, transports: [] });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
it('falls back to REST on capabilities fetch failure', async () => {
installMockFetch(() => {
throw new Error('connection refused');
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
it('falls back to REST on capabilities non-ok response', async () => {
installMockFetch(() => {
return jsonResponse(500, { error: 'server error' });
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
});
// ---- ACP-HTTP preference ----------------------------------------------
describe('acp-http preference', () => {
it('returns acp-http when capabilities includes acp-http but not acp-ws', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1, transports: ['rest', 'acp-http'] });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('acp-http');
transport.dispose();
});
});
// ---- ACP-WS probe -----------------------------------------------------
describe('acp-ws probe', () => {
it('attempts WS probe when acp-ws is in transports list', async () => {
// WS probe will fail since no real WebSocket server exists
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1, transports: ['acp-ws'] });
}
return jsonResponse(200, {});
});
// Should fall back to REST since WS probe fails
const transport = await negotiateTransport(
'http://localhost:8080',
undefined,
{
probeTimeoutMs: 100,
},
);
// It will fall back because the WS probe can't connect
expect(['rest', 'acp-ws']).toContain(transport.type);
transport.dispose();
});
it('falls back to REST on WS probe failure with acp-ws only', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1, transports: ['acp-ws'] });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport(
'http://localhost:8080',
undefined,
{
probeTimeoutMs: 100,
},
);
// WS won't connect in test, should fall back
expect(transport.type).toBe('rest');
transport.dispose();
});
it('falls back to acp-http when WS probe fails and acp-http is available', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, {
v: 1,
transports: ['acp-ws', 'acp-http'],
});
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport(
'http://localhost:8080',
undefined,
{
probeTimeoutMs: 100,
},
);
// WS fails, should try acp-http next
expect(['acp-http', 'rest']).toContain(transport.type);
transport.dispose();
});
});
// ---- Probe timeout behavior -------------------------------------------
describe('probe timeout', () => {
it('uses default 5000ms timeout when not specified', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
});
// Should complete quickly since we get a response
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.type).toBe('rest');
transport.dispose();
});
it('custom probeTimeoutMs is respected', async () => {
installMockFetch((url) => {
if (url.includes('/capabilities')) {
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport(
'http://localhost:8080',
undefined,
{ probeTimeoutMs: 100 },
);
expect(transport.type).toBe('rest');
transport.dispose();
});
});
// ---- Token handling ---------------------------------------------------
describe('token handling', () => {
it('passes token as Authorization header to capabilities probe', async () => {
const capturedHeaders: Record<string, string> = {};
installMockFetch((url, init) => {
if (url.includes('/capabilities')) {
if (init?.headers) {
const h = new Headers(init.headers);
h.forEach((v, k) => (capturedHeaders[k.toLowerCase()] = v));
}
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport(
'http://localhost:8080',
'secret-token',
);
expect(capturedHeaders['authorization']).toBe('Bearer secret-token');
transport.dispose();
});
it('does not send Authorization when no token', async () => {
const capturedHeaders: Record<string, string> = {};
installMockFetch((url, init) => {
if (url.includes('/capabilities')) {
if (init?.headers) {
const h = new Headers(init.headers);
h.forEach((v, k) => (capturedHeaders[k.toLowerCase()] = v));
}
return jsonResponse(200, { v: 1 });
}
return jsonResponse(200, {});
});
const transport = await negotiateTransport('http://localhost:8080');
expect(capturedHeaders['authorization']).toBeUndefined();
transport.dispose();
});
});
// ---- supportsReplay ---------------------------------------------------
describe('transport properties', () => {
it('REST transport supports replay', async () => {
installMockFetch(() => jsonResponse(200, { v: 1 }));
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.supportsReplay).toBe(true);
transport.dispose();
});
it('returned transport is connected (REST)', async () => {
installMockFetch(() => jsonResponse(200, { v: 1 }));
const transport = await negotiateTransport('http://localhost:8080');
expect(transport.connected).toBe(true);
transport.dispose();
});
});
});

View file

@ -140,6 +140,7 @@ const sdkMocks = vi.hoisted(() => {
getWorkspaceAgent = getWorkspaceAgent;
createWorkspaceAgent = createWorkspaceAgent;
deleteWorkspaceAgent = deleteWorkspaceAgent;
dispose = vi.fn();
}
function takeSession(client: unknown): MockSession {

View file

@ -56,6 +56,7 @@ const sdkMocks = vi.hoisted(() => {
workspaceProviders = workspaceProviders;
listWorkspaceSessions = listWorkspaceSessions;
deleteSessionsData = deleteSessionsData;
dispose = vi.fn();
}
return {

View file

@ -38,11 +38,13 @@ export function DaemonWorkspaceProvider({
token,
workspaceCwd,
autoConnect = true,
transport,
children,
}: DaemonWorkspaceProviderProps) {
const client = useMemo(
() => (autoConnect ? new DaemonClient({ baseUrl, token }) : undefined),
[autoConnect, baseUrl, token],
() =>
autoConnect ? new DaemonClient({ baseUrl, token, transport }) : undefined,
[autoConnect, baseUrl, token, transport],
);
const clientRef = useRef<DaemonClient | undefined>(client);
clientRef.current = client;
@ -102,6 +104,7 @@ export function DaemonWorkspaceProvider({
return () => {
disposed = true;
client.dispose();
};
}, [client, getCapabilities]);

View file

@ -213,6 +213,13 @@ export function createDaemonWorkspaceActions({
);
},
// TODO(transport-parity): globWorkspace, stat, and listDirectory
// bypass the DaemonClient transport layer by calling global fetch()
// directly. This means ACP transports (WS, HTTP+JSON-RPC) never
// see these requests. DaemonClient exposes client.glob(),
// client.fileStat(), and client.dirList() that go through the
// transport — migrate to those once the route table covers
// /glob, /stat, /list (see acpRouteTable.ts).
async globWorkspace(pattern, opts) {
requireClient(getClient, 'Glob workspace failed');
const url = createDaemonRequestUrl(baseUrl, '/glob');

View file

@ -70,6 +70,11 @@ export interface DaemonWorkspaceProviderProps {
token?: string;
workspaceCwd?: string;
autoConnect?: boolean;
/**
* Optional pluggable transport forwarded to `DaemonClient`. When
* omitted the client uses the default REST+SSE transport.
*/
transport?: import('@qwen-code/sdk/daemon').DaemonTransport;
children: ReactNode;
}