diff --git a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts index fbb74348bb4..d2d044ea450 100644 --- a/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts +++ b/packages/sdk-typescript/src/daemon/DaemonSessionClient.ts @@ -27,7 +27,9 @@ export interface DaemonSessionClientOptions { state?: DaemonSessionState; /** * Seed replay state for callers that persisted the last seen SSE event id. - * When omitted, the first event subscription starts live. + * When omitted, the first event subscription starts live. Values must be + * finite, non-negative integers because the daemon uses these ids as + * `Last-Event-ID` resume cursors. */ lastEventId?: number; } @@ -62,7 +64,7 @@ export class DaemonSessionClient { this.client = opts.client; this.session = { ...opts.session }; this.state = { ...(opts.state ?? {}) }; - this.lastSeenEventId = opts.lastEventId; + this.lastSeenEventId = validateLastEventId(opts.lastEventId); } /** @@ -76,7 +78,10 @@ export class DaemonSessionClient { // `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. + // `model_switch_failed` / `model_switched` events. The daemon treats + // Last-Event-ID: 0 as "replay from the beginning of the bounded ring"; + // if older events have already been evicted, clients receive the retained + // suffix and continue live from there. const lastEventId = req.modelServiceId ? 0 : undefined; return new DaemonSessionClient({ client, session, lastEventId }); } @@ -140,7 +145,7 @@ export class DaemonSessionClient { } setLastEventId(lastEventId: number | undefined): void { - this.lastSeenEventId = lastEventId; + this.lastSeenEventId = validateLastEventId(lastEventId); } async prompt( @@ -167,21 +172,77 @@ export class DaemonSessionClient { events( opts: DaemonSessionSubscribeOptions = {}, - ): AsyncGenerator { - return this.subscribeEvents(opts); + ): AsyncGenerator { + return this.openEventSubscription(opts); } - async *subscribeEvents( + /** + * @deprecated Use {@link events} instead. Both methods are equivalent. + */ + 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.', - ); - } + ): AsyncGenerator { + return this.openEventSubscription(opts); + } + + private openEventSubscription( + opts: DaemonSessionSubscribeOptions, + ): AsyncGenerator { + const requestedLastEventId = validateLastEventId(opts.lastEventId); + let started = false; + let released = false; + const release = () => { + if (released) return; + released = true; + this.subscriptionActive = false; + }; + const acquire = () => { + if (started) return; + 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; + started = true; + }; + const iterator = this.iterateEvents( + { ...opts, lastEventId: requestedLastEventId }, + release, + ); - this.subscriptionActive = true; + return { + next: async (value?: unknown) => { + if (!released) { + acquire(); + } + return await iterator.next(value); + }, + return: async () => { + try { + return await iterator.return(undefined); + } finally { + release(); + } + }, + throw: async (error?: unknown) => { + try { + return await iterator.throw(error); + } finally { + release(); + } + }, + [Symbol.asyncIterator]() { + return this; + }, + }; + } + + private async *iterateEvents( + opts: DaemonSessionSubscribeOptions, + release: () => void, + ): AsyncGenerator { try { const { resume = true, ...subscribeOpts } = opts; const lastEventId = @@ -193,11 +254,36 @@ export class DaemonSessionClient { lastEventId, })) { yield event; - // Terminal/synthetic frames may not carry an SSE id. - if (event.id !== undefined) this.lastSeenEventId = event.id; + // Cursor updates happen after the consumer resumes iteration. That + // avoids acknowledging an event before the adapter has processed it, + // but means `lastEventId` intentionally lags while the handler for the + // just-yielded event is still running. + // The cursor is a replay watermark, so it only moves forward even if a + // replayed or synthetic frame arrives with an older id. + if (event.id !== undefined) { + this.lastSeenEventId = Math.max( + this.lastSeenEventId ?? 0, + validateLastEventId(event.id), + ); + } } } finally { - this.subscriptionActive = false; + release(); } } } + +function validateLastEventId(lastEventId: number): number; +function validateLastEventId(lastEventId: undefined): undefined; +function validateLastEventId( + lastEventId: number | undefined, +): number | undefined; +function validateLastEventId( + lastEventId: number | undefined, +): number | undefined { + if (lastEventId === undefined) return undefined; + if (!Number.isInteger(lastEventId) || lastEventId < 0) { + throw new TypeError('lastEventId must be a finite non-negative integer'); + } + return lastEventId; +} diff --git a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts index e908b183a1c..70c725a9bb5 100644 --- a/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts +++ b/packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts @@ -29,6 +29,22 @@ function sseResponse(frames: string): Response { }); } +function pendingSseResponse(onCancel: () => void): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode(': keepalive\n\n')); + }, + cancel() { + onCancel(); + }, + }); + return new Response(body, { + status: 200, + headers: { 'content-type': 'text/event-stream' }, + }); +} + interface CapturedRequest { url: string; method: string; @@ -186,6 +202,35 @@ describe('DaemonSessionClient', () => { expect(calls[1]?.headers['last-event-id']).toBe('0'); }); + it('starts live when createOrAttach has no model service replay need', 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', + }); + + for await (const _event of session.events()) { + /* empty */ + } + + expect(session.lastEventId).toBeUndefined(); + expect(calls[1]?.url).toBe('http://daemon/session/s-1/events'); + expect(calls[1]?.headers['last-event-id']).toBeUndefined(); + }); + it('forwards session-scoped operations through DaemonClient', async () => { const { fetch, calls } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/prompt')) { @@ -238,6 +283,40 @@ describe('DaemonSessionClient', () => { expect(calls[0]?.signal).toBe(controller.signal); }); + it('surfaces permission races and session operation failures', async () => { + const { fetch } = recordingFetch((req) => { + if (req.url.endsWith('/permission/missing-req')) { + return jsonResponse(404, { error: 'unknown request' }); + } + if (req.url.endsWith('/session/s-1/model')) { + return jsonResponse(404, { error: 'unknown session' }); + } + if (req.url.endsWith('/session/s-1/cancel')) { + return jsonResponse(500, { error: 'cancel 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.respondToPermission('missing-req', { + outcome: { outcome: 'cancelled' }, + }), + ).resolves.toBe(false); + await expect(session.setModel('qwen3-coder')).rejects.toMatchObject({ + status: 404, + }); + await expect(session.cancel()).rejects.toMatchObject({ status: 500 }); + }); + it('tracks Last-Event-ID across event subscriptions', async () => { let eventCallCount = 0; const { fetch, calls } = recordingFetch((req) => { @@ -310,6 +389,28 @@ describe('DaemonSessionClient', () => { expect(session.lastEventId).toBe(4); }); + it('does not acquire the subscription guard until iteration starts', 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, + }, + }); + + const abandoned = session.events(); + await expect(session.events().next()).resolves.toEqual({ + done: true, + value: undefined, + }); + + expect(calls).toHaveLength(1); + await abandoned.return(undefined); + }); + it('rejects concurrent subscriptions on one session client', async () => { const { fetch } = recordingFetch(() => sseResponse( @@ -336,7 +437,12 @@ describe('DaemonSessionClient', () => { await expect(second.next()).rejects.toThrow( 'Another event subscription is already active', ); + await first.return(undefined); + + for await (const _event of session.events()) { + /* guard recovered */ + } }); it('allows callers to seed, override, and disable replay state', async () => { @@ -367,6 +473,114 @@ describe('DaemonSessionClient', () => { expect(calls[2]?.headers['last-event-id']).toBeUndefined(); }); + it('allows callers to set and clear replay state explicitly', 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, + }, + }); + + session.setLastEventId(12); + expect(session.lastEventId).toBe(12); + for await (const _event of session.events()) { + /* empty */ + } + + session.setLastEventId(undefined); + expect(session.lastEventId).toBeUndefined(); + for await (const _event of session.events()) { + /* empty */ + } + + expect(calls[0]?.headers['last-event-id']).toBe('12'); + expect(calls[1]?.headers['last-event-id']).toBeUndefined(); + expect(() => session.setLastEventId(-1)).toThrow(TypeError); + expect(() => session.setLastEventId(1.5)).toThrow(TypeError); + expect(() => session.setLastEventId(Number.NaN)).toThrow(TypeError); + expect( + () => + new DaemonSessionClient({ + client, + session: { + sessionId: 's-1', + workspaceCwd: '/work/a', + attached: true, + }, + lastEventId: Number.POSITIVE_INFINITY, + }), + ).toThrow(TypeError); + expect(() => session.events({ lastEventId: -1 })).toThrow(TypeError); + }); + + it('honors abort signals and releases the subscription guard', async () => { + let cancelled = false; + const { fetch, calls } = recordingFetch(() => + pendingSseResponse(() => { + cancelled = true; + }), + ); + 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(); + + const events = session.events({ signal: controller.signal }); + const next = events.next(); + await Promise.resolve(); + expect(calls).toHaveLength(1); + + controller.abort(); + + await expect(next).resolves.toEqual({ + done: true, + value: undefined, + }); + expect(cancelled).toBe(true); + + const retry = session.events(); + await retry.return(undefined); + }); + + it('releases the subscription guard when consumers throw into the iterator', 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 events = session.events(); + await expect(events.next()).resolves.toMatchObject({ + done: false, + value: { id: 4 }, + }); + + await expect(events.throw(new Error('boom'))).rejects.toThrow('boom'); + + for await (const _event of session.events()) { + /* guard recovered */ + } + }); + it('propagates prompt and subscription errors', async () => { const { fetch } = recordingFetch((req) => { if (req.url.endsWith('/session/s-1/prompt')) { @@ -395,5 +609,10 @@ describe('DaemonSessionClient', () => { await expect(events.next()).rejects.toThrow( 'GET /session/:id/events: stream failed', ); + + const retry = session.events({ resume: false }); + await expect(retry.next()).rejects.toThrow( + 'GET /session/:id/events: stream failed', + ); }); });