-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(sdk): add DaemonSessionClient skeleton #4201
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 = {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— glm-5.1 via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<DaemonSessionClient> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| this.lastSeenEventId = lastEventId; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async prompt( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| req: PromptRequest, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| signal?: AbortSignal, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<PromptResult> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await this.client.prompt(this.sessionId, req, signal); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async cancel(): Promise<void> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| await this.client.cancel(this.sessionId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async setModel(modelId: string): Promise<SetModelResult> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await this.client.setSessionModel(this.sessionId, modelId); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async respondToPermission( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| requestId: string, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| response: PermissionResponse, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): Promise<boolean> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return await this.client.respondToPermission(requestId, response); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| events( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Consider making — DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| opts: DaemonSessionSubscribeOptions = {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): AsyncGenerator<DaemonEvent> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion]
Consider setting
Suggested change
— glm-5.1 via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return this.subscribeEvents(opts); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async *subscribeEvents( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Critical] This breaks the documented SSE replay contract for any adapter that forks the event stream (e.g. one connection for logging, another for UI).
Suggested change
— DeepSeek/deepseek-v4-pro via Qwen Code /review
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Valid, and fixed in the current branch. A single |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| opts: DaemonSessionSubscribeOptions = {}, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ): AsyncGenerator<DaemonEvent> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (this.subscriptionActive) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| throw new Error( | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Another event subscription is already active on this session. ' + | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Reuse the existing AsyncGenerator or create a separate DaemonSessionClient.', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Add a JSDoc note to — DeepSeek/deepseek-v4-pro via Qwen Code /review |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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; | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Critical] This recommended session-client flow can miss attach-time daemon events. The existing daemon docs require clients that use
modelServiceIdon attach to either subscribe beforePOST /sessionor passLast-Event-ID: 0, becausemodel_switch_failedis emitted only on SSE and the HTTP create/attach call still succeeds.DaemonSessionClient.createOrAttach()performs thePOST /sessionbefore callers can subscribe, and this firstsession.events()call sends no cursor, so adapters following this example can silently miss a failed model switch and continue on the wrong model. Please make the first subscription replay from the start (or explicitly seed/passlastEventId: 0) and document that pattern.— gpt-5.5 via Qwen Code /review
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Valid catch, fixed in
da6a35c.DaemonSessionClient.createOrAttach()now seedslastEventId: 0whenever the request carriesmodelServiceId, so the firstsession.events()subscription replays the daemon ring and can observe attach-timemodel_switch_failed/model_switchedevents. I also documented the rawDaemonClientpattern and added a unit test that verifies the first SSE request sendsLast-Event-ID: 0in this path.