mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
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:
parent
dc6edcd523
commit
ce4b0cf629
31 changed files with 6428 additions and 139 deletions
473
docs/design/daemon-transport-abstraction/README.md
Normal file
473
docs/design/daemon-transport-abstraction/README.md
Normal 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` |
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
205
packages/sdk-typescript/src/daemon/AcpEventDenormalizer.ts
Normal file
205
packages/sdk-typescript/src/daemon/AcpEventDenormalizer.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
636
packages/sdk-typescript/src/daemon/AcpHttpTransport.ts
Normal file
636
packages/sdk-typescript/src/daemon/AcpHttpTransport.ts
Normal 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;
|
||||
}
|
||||
615
packages/sdk-typescript/src/daemon/AcpWsTransport.ts
Normal file
615
packages/sdk-typescript/src/daemon/AcpWsTransport.ts
Normal 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;
|
||||
}
|
||||
158
packages/sdk-typescript/src/daemon/AutoReconnectTransport.ts
Normal file
158
packages/sdk-typescript/src/daemon/AutoReconnectTransport.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ----------------------------------------------------
|
||||
|
||||
/**
|
||||
|
|
|
|||
25
packages/sdk-typescript/src/daemon/DaemonHttpError.ts
Normal file
25
packages/sdk-typescript/src/daemon/DaemonHttpError.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
113
packages/sdk-typescript/src/daemon/DaemonTransport.ts
Normal file
113
packages/sdk-typescript/src/daemon/DaemonTransport.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
215
packages/sdk-typescript/src/daemon/RestSseTransport.ts
Normal file
215
packages/sdk-typescript/src/daemon/RestSseTransport.ts
Normal 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;
|
||||
}
|
||||
611
packages/sdk-typescript/src/daemon/acpRouteTable.ts
Normal file
611
packages/sdk-typescript/src/daemon/acpRouteTable.ts
Normal 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 : {}),
|
||||
},
|
||||
},
|
||||
];
|
||||
134
packages/sdk-typescript/src/daemon/acpTransportUtils.ts
Normal file
134
packages/sdk-typescript/src/daemon/acpTransportUtils.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
135
packages/sdk-typescript/src/daemon/negotiateTransport.ts
Normal file
135
packages/sdk-typescript/src/daemon/negotiateTransport.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
249
packages/sdk-typescript/test/unit/AcpEventDenormalizer.test.ts
Normal file
249
packages/sdk-typescript/test/unit/AcpEventDenormalizer.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
573
packages/sdk-typescript/test/unit/AcpHttpTransport.test.ts
Normal file
573
packages/sdk-typescript/test/unit/AcpHttpTransport.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
275
packages/sdk-typescript/test/unit/AcpWsTransport.test.ts
Normal file
275
packages/sdk-typescript/test/unit/AcpWsTransport.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
397
packages/sdk-typescript/test/unit/AutoReconnectTransport.test.ts
Normal file
397
packages/sdk-typescript/test/unit/AutoReconnectTransport.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
424
packages/sdk-typescript/test/unit/RestSseTransport.test.ts
Normal file
424
packages/sdk-typescript/test/unit/RestSseTransport.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
576
packages/sdk-typescript/test/unit/acpRouteTable.test.ts
Normal file
576
packages/sdk-typescript/test/unit/acpRouteTable.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
292
packages/sdk-typescript/test/unit/negotiateTransport.test.ts
Normal file
292
packages/sdk-typescript/test/unit/negotiateTransport.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -140,6 +140,7 @@ const sdkMocks = vi.hoisted(() => {
|
|||
getWorkspaceAgent = getWorkspaceAgent;
|
||||
createWorkspaceAgent = createWorkspaceAgent;
|
||||
deleteWorkspaceAgent = deleteWorkspaceAgent;
|
||||
dispose = vi.fn();
|
||||
}
|
||||
|
||||
function takeSession(client: unknown): MockSession {
|
||||
|
|
|
|||
|
|
@ -56,6 +56,7 @@ const sdkMocks = vi.hoisted(() => {
|
|||
workspaceProviders = workspaceProviders;
|
||||
listWorkspaceSessions = listWorkspaceSessions;
|
||||
deleteSessionsData = deleteSessionsData;
|
||||
dispose = vi.fn();
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue