From 7295faee499310e3af8f7ece93718893d3499fca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 16 May 2026 14:56:18 +0800 Subject: [PATCH 1/3] feat(sdk): add daemon session client --- packages/sdk-typescript/README.md | 42 ++++ .../src/daemon/DaemonSessionClient.ts | 133 ++++++++++++ packages/sdk-typescript/src/daemon/index.ts | 5 + packages/sdk-typescript/src/index.ts | 3 + .../test/unit/DaemonSessionClient.test.ts | 204 ++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 packages/sdk-typescript/src/daemon/DaemonSessionClient.ts create mode 100644 packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index d0de0aab9f8..c7099d6ef5e 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -101,6 +101,48 @@ 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`. + ### 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..454b489f419 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -0,0 +1,133 @@ +/** + * @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; + + constructor(opts: DaemonSessionClientOptions) { + this.client = opts.client; + this.session = { ...opts.session }; + this.lastSeenEventId = opts.lastEventId; + } + + static async createOrAttach( + client: DaemonClient, + req: CreateSessionRequest = {}, + ): Promise { + const session = await client.createOrAttachSession(req); + return new DaemonSessionClient({ client, session }); + } + + 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 { + 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, + })) { + if (event.id !== undefined) this.lastSeenEventId = event.id; + yield event; + } + } +} 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..b3cd775986f --- /dev/null +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -0,0 +1,204 @@ +/** + * @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; +} + +function recordingFetch( + reply: (req: CapturedRequest) => Response | Promise, +): { fetch: typeof globalThis.fetch; calls: CapturedRequest[] } { + const calls: CapturedRequest[] = []; + const fetchImpl = vi.fn( + async (input: RequestInfo | URL, init?: RequestInit) => { + const url = + typeof input === 'string' + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? 'GET'; + const headers: Record = {}; + if (init?.headers) { + const h = new Headers(init.headers); + h.forEach((v, k) => (headers[k.toLowerCase()] = v)); + } + const body = typeof init?.body === 'string' ? init.body : null; + const captured: CapturedRequest = { url, method, headers, body }; + calls.push(captured); + return reply(captured); + }, + ) as unknown as typeof globalThis.fetch; + return { fetch: fetchImpl, calls }; +} + +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('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, + }, + }); + + await expect( + session.prompt({ prompt: [{ type: 'text', text: 'hi' }] }), + ).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', + ]); + }); + + 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 events = []; + for await (const event of session.events()) events.push(event); + expect(events.map((event) => event.id)).toEqual([4, 5]); + 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('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(); + }); +}); From 66aba78fbecd9d30677c8802f7afae77eec19119 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sat, 16 May 2026 22:54:31 +0800 Subject: [PATCH 2/3] fix(sdk): harden daemon session event replay --- .../src/daemon/DaemonSessionClient.ts | 41 +++++-- .../test/unit/DaemonSessionClient.test.ts | 115 +++++++++++++++++- 2 files changed, 139 insertions(+), 17 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index 454b489f419..33128b9a859 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -4,8 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - DaemonClient} from './DaemonClient.js'; +import type { DaemonClient } from './DaemonClient.js'; import { type CreateSessionRequest, type PromptRequest, @@ -52,6 +51,7 @@ export class DaemonSessionClient { readonly client: DaemonClient; readonly session: DaemonSession; private lastSeenEventId: number | undefined; + private subscriptionActive = false; constructor(opts: DaemonSessionClientOptions) { this.client = opts.client; @@ -59,6 +59,9 @@ export class DaemonSessionClient { this.lastSeenEventId = opts.lastEventId; } + /** + * Creates a new daemon session or attaches to an existing matching session. + */ static async createOrAttach( client: DaemonClient, req: CreateSessionRequest = {}, @@ -118,16 +121,30 @@ export class DaemonSessionClient { async *subscribeEvents( opts: DaemonSessionSubscribeOptions = {}, ): AsyncGenerator { - 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, - })) { - if (event.id !== undefined) this.lastSeenEventId = event.id; - yield event; + 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/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index b3cd775986f..61d21d474b7 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -34,6 +34,7 @@ interface CapturedRequest { method: string; headers: Record; body: string | null; + signal?: AbortSignal | null; } function recordingFetch( @@ -55,7 +56,13 @@ function recordingFetch( h.forEach((v, k) => (headers[k.toLowerCase()] = v)); } const body = typeof init?.body === 'string' ? init.body : null; - const captured: CapturedRequest = { url, method, headers, body }; + const captured: CapturedRequest = { + url, + method, + headers, + body, + signal: init?.signal, + }; calls.push(captured); return reply(captured); }, @@ -115,8 +122,12 @@ describe('DaemonSessionClient', () => { }, }); + const controller = new AbortController(); await expect( - session.prompt({ prompt: [{ type: 'text', text: 'hi' }] }), + 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', @@ -134,6 +145,7 @@ describe('DaemonSessionClient', () => { '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 () => { @@ -161,9 +173,19 @@ describe('DaemonSessionClient', () => { }, }); - const events = []; - for await (const event of session.events()) events.push(event); - expect(events.map((event) => event.id)).toEqual([4, 5]); + 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()) { @@ -174,6 +196,59 @@ describe('DaemonSessionClient', () => { 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 }); @@ -201,4 +276,34 @@ describe('DaemonSessionClient', () => { 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', + ); + }); }); From da6a35cccf08adfeebb4eaa4df9f59e39634b5fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 17 May 2026 00:39:53 +0800 Subject: [PATCH 3/3] fix(sdk): replay attach-time daemon events --- packages/sdk-typescript/README.md | 8 +++++ .../src/daemon/DaemonSessionClient.ts | 7 ++++- .../test/unit/DaemonSessionClient.test.ts | 29 +++++++++++++++++++ 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/README.md b/packages/sdk-typescript/README.md index c7099d6ef5e..69ae6cba3e2 100644 --- a/packages/sdk-typescript/README.md +++ b/packages/sdk-typescript/README.md @@ -143,6 +143,14 @@ console.log(result.stopReason); 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 index 33128b9a859..cf993a8c67a 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -67,7 +67,12 @@ export class DaemonSessionClient { req: CreateSessionRequest = {}, ): Promise { const session = await client.createOrAttachSession(req); - return new DaemonSessionClient({ client, session }); + // `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 { diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index 61d21d474b7..9fb6ec73294 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -96,6 +96,35 @@ describe('DaemonSessionClient', () => { }); }); + 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')) {