diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index d0de0aab9f8..69ae6cba3e2 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -101,6 +101,56 @@ const query = qwen.query('Your prompt', { }); ``` +### Experimental Daemon Session Client + +`DaemonSessionClient` is an experimental wrapper for clients that talk to a +running `qwen serve` daemon over HTTP + SSE. It binds one daemon session so TUI, +channel, IDE, or web backend adapters do not need to pass `sessionId` into every +call. + +```typescript +import { DaemonClient, DaemonSessionClient } from '@qwen-code/sdk'; + +const daemon = new DaemonClient({ + baseUrl: 'http://127.0.0.1:4170', + token: process.env['QWEN_SERVER_TOKEN'], +}); + +const caps = await daemon.capabilities(); +const session = await DaemonSessionClient.createOrAttach(daemon, { + workspaceCwd: caps.workspaceCwd, +}); + +const eventController = new AbortController(); +const eventTask = (async () => { + for await (const event of session.events({ + signal: eventController.signal, + })) { + console.log(event.type, event.data); + } +})(); + +const result = await session.prompt({ + prompt: [{ type: 'text', text: 'Summarize this workspace.' }], +}); + +eventController.abort(); +await eventTask; +console.log(result.stopReason); +``` + +`session.events()` tracks the last seen SSE event id and reuses it on the next +subscription by default. Pass `{ resume: false }` to start a fresh subscription +without sending `Last-Event-ID`. + +When `createOrAttach()` is called with `modelServiceId`, the returned session +client seeds its first event subscription with `Last-Event-ID: 0`. This replays +the daemon ring from the oldest available event so adapters can observe +attach-time `model_switch_failed` or `model_switched` events that are not +reported on the create/attach HTTP response. Raw `DaemonClient` callers should +pass `{ lastEventId: 0 }` on their first `subscribeEvents()` call when they use +`modelServiceId`. + ### Message Types The SDK provides type guards to identify different message types: diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts new file mode 100644 index 00000000000..cf993a8c67a --- /dev/null +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DaemonClient } from './DaemonClient.js'; +import { + type CreateSessionRequest, + type PromptRequest, + type SubscribeOptions, +} from './DaemonClient.js'; +import type { + DaemonEvent, + DaemonSession, + PermissionResponse, + PromptResult, + SetModelResult, +} from './types.js'; + +export interface DaemonSessionClientOptions { + client: DaemonClient; + session: DaemonSession; + /** + * Seed replay state for callers that persisted the last seen SSE event id. + * When omitted, the first event subscription starts live. + */ + lastEventId?: number; +} + +export interface DaemonSessionSubscribeOptions extends SubscribeOptions { + /** + * Reuse this client's last seen SSE event id when `lastEventId` is not + * supplied. Defaults to true so reconnecting client adapters get replay + * behavior without carrying the id through every call. + */ + resume?: boolean; +} + +/** + * Session-scoped wrapper around `DaemonClient`. + * + * `DaemonClient` mirrors the raw HTTP API and requires a `sessionId` on each + * method. `DaemonSessionClient` is the adapter-facing layer for TUI, channel, + * IDE, and web backends: it binds one daemon session, forwards the existing + * Stage 1 routes, and preserves SSE replay state. It intentionally does not + * interpret daemon event payloads; typed event reducers belong to the protocol + * schema layer. + */ +export class DaemonSessionClient { + readonly client: DaemonClient; + readonly session: DaemonSession; + private lastSeenEventId: number | undefined; + private subscriptionActive = false; + + constructor(opts: DaemonSessionClientOptions) { + this.client = opts.client; + this.session = { ...opts.session }; + this.lastSeenEventId = opts.lastEventId; + } + + /** + * Creates a new daemon session or attaches to an existing matching session. + */ + static async createOrAttach( + client: DaemonClient, + req: CreateSessionRequest = {}, + ): Promise { + const session = await client.createOrAttachSession(req); + // `modelServiceId` switch failures are reported on SSE, not the + // create/attach HTTP response. Seed the first subscription from the + // daemon replay ring so create-then-subscribe clients observe attach-time + // `model_switch_failed` / `model_switched` events. + const lastEventId = req.modelServiceId ? 0 : undefined; + return new DaemonSessionClient({ client, session, lastEventId }); + } + + get sessionId(): string { + return this.session.sessionId; + } + + get workspaceCwd(): string { + return this.session.workspaceCwd; + } + + get attached(): boolean { + return this.session.attached; + } + + get lastEventId(): number | undefined { + return this.lastSeenEventId; + } + + setLastEventId(lastEventId: number | undefined): void { + this.lastSeenEventId = lastEventId; + } + + async prompt( + req: PromptRequest, + signal?: AbortSignal, + ): Promise { + return await this.client.prompt(this.sessionId, req, signal); + } + + async cancel(): Promise { + await this.client.cancel(this.sessionId); + } + + async setModel(modelId: string): Promise { + return await this.client.setSessionModel(this.sessionId, modelId); + } + + async respondToPermission( + requestId: string, + response: PermissionResponse, + ): Promise { + return await this.client.respondToPermission(requestId, response); + } + + events( + opts: DaemonSessionSubscribeOptions = {}, + ): AsyncGenerator { + return this.subscribeEvents(opts); + } + + async *subscribeEvents( + opts: DaemonSessionSubscribeOptions = {}, + ): AsyncGenerator { + if (this.subscriptionActive) { + throw new Error( + 'Another event subscription is already active on this session. ' + + 'Reuse the existing AsyncGenerator or create a separate DaemonSessionClient.', + ); + } + + this.subscriptionActive = true; + try { + const { resume = true, ...subscribeOpts } = opts; + const lastEventId = + subscribeOpts.lastEventId ?? + (resume ? this.lastSeenEventId : undefined); + + for await (const event of this.client.subscribeEvents(this.sessionId, { + ...subscribeOpts, + lastEventId, + })) { + yield event; + // Terminal/synthetic frames may not carry an SSE id. + if (event.id !== undefined) this.lastSeenEventId = event.id; + } + } finally { + this.subscriptionActive = false; + } + } +} diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 4939f97d3b2..8302fcdcf99 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -12,6 +12,11 @@ export { type PromptRequest, type SubscribeOptions, } from './DaemonClient.js'; +export { + DaemonSessionClient, + type DaemonSessionClientOptions, + type DaemonSessionSubscribeOptions, +} from './DaemonSessionClient.js'; export { parseSseStream, SseFramingError } from './sse.js'; export { DaemonCapabilityMissingError, requireWorkspaceCwd } from './types.js'; export type { diff --git a/packages/sdk-typescript/src/index.ts b/packages/sdk-typescript/src/index.ts index 6d4a0d48fd2..69e27c98b56 100644 --- a/packages/sdk-typescript/src/index.ts +++ b/packages/sdk-typescript/src/index.ts @@ -8,6 +8,7 @@ export { DaemonCapabilityMissingError, DaemonClient, DaemonHttpError, + DaemonSessionClient, parseSseStream, requireWorkspaceCwd, SseFramingError, @@ -18,6 +19,8 @@ export { type DaemonMode, type DaemonProtocolVersions, type DaemonSession, + type DaemonSessionClientOptions, + type DaemonSessionSubscribeOptions, type DaemonSessionSummary, type PermissionOutcome, type PermissionOutcomeCancelled, diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts new file mode 100644 index 00000000000..9fb6ec73294 --- /dev/null +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -0,0 +1,338 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi } from 'vitest'; +import { DaemonClient } from '../../src/daemon/DaemonClient.js'; +import { DaemonSessionClient } from '../../src/daemon/DaemonSessionClient.js'; + +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({ + start(controller) { + controller.enqueue(encoder.encode(frames)); + controller.close(); + }, + }); + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +} + +interface CapturedRequest { + url: string; + method: string; + headers: Record; + body: string | null; + signal?: AbortSignal | null; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchImpl = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? 'GET'; + const headers: Record = {}; + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } + const body = typeof init?.body === 'string' ? init.body : null; + const captured: CapturedRequest = { + url, + method, + headers, + body, + signal: init?.signal, + }; + calls.push(captured); + return reply(captured); + }, + ) as unknown as typeof globalThis.fetch; + return { fetch: fetchImpl, calls }; +} + +describe('DaemonSessionClient', () => { + it('creates or attaches a daemon session and exposes session metadata', async () => { + const { fetch, calls } = recordingFetch(() => + jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: false, + }), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const session = await DaemonSessionClient.createOrAttach(client, { + workspaceCwd: '/work/a', + modelServiceId: 'qwen-prod', + }); + + expect(session.sessionId).toBe('s-1'); + expect(session.workspaceCwd).toBe('/work/a'); + expect(session.attached).toBe(false); + expect(calls[0]?.url).toBe('http://daemon/session'); + expect(JSON.parse(calls[0]!.body!)).toEqual({ + cwd: '/work/a', + modelServiceId: 'qwen-prod', + }); + }); + + it('replays attach-time model switch events on first subscription', async () => { + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/session')) { + return jsonResponse(200, { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }); + } + if (req.url.endsWith('/session/s-1/events')) { + return sseResponse(''); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + + const session = await DaemonSessionClient.createOrAttach(client, { + workspaceCwd: '/work/a', + modelServiceId: 'qwen-prod', + }); + + for await (const _event of session.events()) { + /* empty */ + } + + expect(calls[1]?.url).toBe('http://daemon/session/s-1/events'); + expect(calls[1]?.headers['last-event-id']).toBe('0'); + }); + + it('forwards session-scoped operations through DaemonClient', async () => { + const { fetch, calls } = recordingFetch((req) => { + if (req.url.endsWith('/session/s-1/prompt')) { + return jsonResponse(200, { stopReason: 'end_turn' }); + } + if (req.url.endsWith('/session/s-1/model')) { + return jsonResponse(200, { modelId: 'qwen3-coder' }); + } + if (req.url.endsWith('/session/s-1/cancel')) { + return new Response(null, { status: 204 }); + } + if (req.url.endsWith('/permission/req-1')) { + return jsonResponse(200, {}); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + }); + + const controller = new AbortController(); + await expect( + session.prompt( + { prompt: [{ type: 'text', text: 'hi' }] }, + controller.signal, + ), + ).resolves.toEqual({ stopReason: 'end_turn' }); + await expect(session.setModel('qwen3-coder')).resolves.toEqual({ + modelId: 'qwen3-coder', + }); + await expect(session.cancel()).resolves.toBeUndefined(); + await expect( + session.respondToPermission('req-1', { + outcome: { outcome: 'selected', optionId: 'allow' }, + }), + ).resolves.toBe(true); + + expect(calls.map((c) => c.url)).toEqual([ + 'http://daemon/session/s-1/prompt', + 'http://daemon/session/s-1/model', + 'http://daemon/session/s-1/cancel', + 'http://daemon/permission/req-1', + ]); + expect(calls[0]?.signal).toBe(controller.signal); + }); + + it('tracks Last-Event-ID across event subscriptions', async () => { + let eventCallCount = 0; + const { fetch, calls } = recordingFetch((req) => { + if (!req.url.endsWith('/session/s-1/events')) { + return jsonResponse(500, { error: `unexpected ${req.url}` }); + } + eventCallCount++; + if (eventCallCount === 1) { + return sseResponse( + 'id: 4\nevent: session_update\ndata: {"id":4,"v":1,"type":"session_update","data":"a"}\n\n' + + 'id: 5\nevent: session_update\ndata: {"id":5,"v":1,"type":"session_update","data":"b"}\n\n', + ); + } + return sseResponse(''); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + }); + + const stream = session.events(); + const first = await stream.next(); + expect(first.value?.id).toBe(4); + expect(session.lastEventId).toBeUndefined(); + + const second = await stream.next(); + expect(second.value?.id).toBe(5); + expect(session.lastEventId).toBe(4); + + await expect(stream.next()).resolves.toEqual({ + done: true, + value: undefined, + }); + expect(session.lastEventId).toBe(5); + + for await (const _event of session.events()) { + /* empty */ + } + + expect(calls[0]?.headers['last-event-id']).toBeUndefined(); + expect(calls[1]?.headers['last-event-id']).toBe('5'); + }); + + it('does not overwrite replay state for events without SSE ids', async () => { + const { fetch } = recordingFetch(() => + sseResponse( + 'id: 4\nevent: session_update\ndata: {"id":4,"v":1,"type":"session_update","data":"a"}\n\n' + + 'event: session_update\ndata: {"v":1,"type":"session_update","data":"synthetic"}\n\n', + ), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + }); + + for await (const _event of session.events()) { + /* empty */ + } + + expect(session.lastEventId).toBe(4); + }); + + it('rejects concurrent subscriptions on one session client', async () => { + const { fetch } = recordingFetch(() => + sseResponse( + 'id: 4\nevent: session_update\ndata: {"id":4,"v":1,"type":"session_update","data":"a"}\n\n', + ), + ); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + }); + + const first = session.events(); + await expect(first.next()).resolves.toMatchObject({ + done: false, + value: { id: 4 }, + }); + + const second = session.events(); + await expect(second.next()).rejects.toThrow( + 'Another event subscription is already active', + ); + await first.return(undefined); + }); + + it('allows callers to seed, override, and disable replay state', async () => { + const { fetch, calls } = recordingFetch(() => sseResponse('')); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + lastEventId: 7, + }); + + for await (const _event of session.events()) { + /* empty */ + } + for await (const _event of session.events({ lastEventId: 11 })) { + /* empty */ + } + for await (const _event of session.events({ resume: false })) { + /* empty */ + } + + expect(calls[0]?.headers['last-event-id']).toBe('7'); + expect(calls[1]?.headers['last-event-id']).toBe('11'); + expect(calls[2]?.headers['last-event-id']).toBeUndefined(); + }); + + it('propagates prompt and subscription errors', async () => { + const { fetch } = recordingFetch((req) => { + if (req.url.endsWith('/session/s-1/prompt')) { + return jsonResponse(500, { error: 'boom' }); + } + if (req.url.endsWith('/session/s-1/events')) { + return jsonResponse(500, { error: 'stream failed' }); + } + return jsonResponse(500, { error: `unexpected ${req.url}` }); + }); + const client = new DaemonClient({ baseUrl: 'http://daemon', fetch }); + const session = new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + }); + + await expect( + session.prompt({ prompt: [{ type: 'text', text: 'hi' }] }), + ).rejects.toThrow('POST /session/:id/prompt: boom'); + + const events = session.events(); + await expect(events.next()).rejects.toThrow( + 'GET /session/:id/events: stream failed', + ); + }); +});