Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions packages/sdk-typescript/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});

Copy link
Copy Markdown
Collaborator

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 modelServiceId on attach to either subscribe before POST /session or pass Last-Event-ID: 0, because model_switch_failed is emitted only on SSE and the HTTP create/attach call still succeeds. DaemonSessionClient.createOrAttach() performs the POST /session before callers can subscribe, and this first session.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/pass lastEventId: 0) and document that pattern.

Suggested change
for await (const event of session.events({
signal: eventController.signal,
lastEventId: 0,
})) {

— gpt-5.5 via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

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 seeds lastEventId: 0 whenever the request carries modelServiceId, so the first session.events() subscription replays the daemon ring and can observe attach-time model_switch_failed / model_switched events. I also documented the raw DaemonClient pattern and added a unit test that verifies the first SSE request sends Last-Event-ID: 0 in this path.

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:
Expand Down
155 changes: 155 additions & 0 deletions packages/sdk-typescript/src/daemon/DaemonSessionClient.ts
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 = {},

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] lastEventId: 0 assumption about daemon-side event ID numbering is undocumented.

createOrAttach seeds lastEventId: 0 when modelServiceId is present, assuming daemon SSE IDs start at 0. If a different daemon implementation starts event IDs at a non-zero value, this will silently miss attach-time events. The daemon-side contract should be documented.

Suggested change
req: CreateSessionRequest = {},
// Seeds lastEventId=0 because the daemon's SSE replay ring uses 0-based
// monotonic IDs; Last-Event-ID: 0 triggers replay from ring start so
// create-then-subscribe clients observe attach-time model switch events.
const lastEventId = req.modelServiceId ? 0 : undefined;

— 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 {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] setLastEventId() silently accepts NaN, negative numbers, Infinity, and non-integers. Downstream DaemonClient.subscribeEvents calls String(opts.lastEventId), producing garbage headers like Last-Event-ID: NaN. A caller restoring from corrupt storage would get confusing protocol errors with no SDK-level signal.

Suggested change
setLastEventId(lastEventId: number | undefined): void {
setLastEventId(lastEventId: number | undefined): void {
if (lastEventId !== undefined && (!Number.isFinite(lastEventId) || lastEventId < 0 || !Number.isInteger(lastEventId))) {
return;
}
this.lastSeenEventId = lastEventId;
}

— 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(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] events() and subscribeEvents() are redundant public APIs with identical behavior and signatures. The README and all tests use only events(). Having both public forces callers to wonder which is canonical.

Consider making subscribeEvents() private and keeping events() as the sole public entry point.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

opts: DaemonSessionSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] events() subscription guard activates lazily — two synchronous events() calls both succeed.

events() returns this.subscribeEvents(opts) which is an async generator. The subscriptionActive guard inside subscribeEvents() only runs when the generator body executes (first .next()). Two synchronous calls to events() both return generator objects without error. The error only appears when iteration begins, making the root cause hard to trace.

Consider setting subscriptionActive = true eagerly in events() and refactoring subscribeEvents into a private internal method that doesn't re-check:

Suggested change
): AsyncGenerator<DaemonEvent> {
events(
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.',
);
}
this.subscriptionActive = true;
return this.subscribeEventsInternal(opts);
}

— glm-5.1 via Qwen Code /review

return this.subscribeEvents(opts);
}

async *subscribeEvents(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] subscribeEvents() reads and writes this.lastSeenEventId without any synchronization, mutual exclusion, or active-subscription check. Two concurrent calls to events()/subscribeEvents() would produce non-deterministic lastSeenEventId as both async iterators interleave writes to the same field.

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
async *subscribeEvents(
async *subscribeEvents(
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.',
);
}
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,
})) {
if (event.id !== undefined) this.lastSeenEventId = event.id;
yield event;
}
} finally {
this.subscriptionActive = false;
}
}

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Valid, and fixed in the current branch. A single DaemonSessionClient now rejects concurrent subscriptions with a clear error, while callers that need fan-out can create separate session clients over the same underlying daemon session. The test suite covers the concurrent subscription guard. I intentionally kept the cursor advancement after yield, matching the ack-after-consume fix from the other thread.

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.',
);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] lastSeenEventId updates after yield event, so it lags one event behind during active iteration. This is intentional (at-least-once semantics, fixed from a prior ack-before-processing bug), but undocumented. Callers checkpointing session.lastEventId mid-stream in a for await loop will persist a stale cursor and get duplicate events on reconnect.

Add a JSDoc note to events(): lastEventId trails the most recently yielded event by one during iteration; only checkpoint it after the for await loop completes or when the stream signals completion/interruption.

— 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;
}
}
}
5 changes: 5 additions & 0 deletions packages/sdk-typescript/src/daemon/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
3 changes: 3 additions & 0 deletions packages/sdk-typescript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export {
DaemonCapabilityMissingError,
DaemonClient,
DaemonHttpError,
DaemonSessionClient,
parseSseStream,
requireWorkspaceCwd,
SseFramingError,
Expand All @@ -18,6 +19,8 @@ export {
type DaemonMode,
type DaemonProtocolVersions,
type DaemonSession,
type DaemonSessionClientOptions,
type DaemonSessionSubscribeOptions,
type DaemonSessionSummary,
type PermissionOutcome,
type PermissionOutcomeCancelled,
Expand Down
Loading
Loading