Skip to content

feat(sdk): add DaemonSessionClient skeleton#4195

Closed
chiga0 wants to merge 1 commit into
feat/serve-capability-registryfrom
codex/daemon-session-client
Closed

feat(sdk): add DaemonSessionClient skeleton#4195
chiga0 wants to merge 1 commit into
feat/serve-capability-registryfrom
codex/daemon-session-client

Conversation

@chiga0

@chiga0 chiga0 commented May 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Added an experimental DaemonSessionClient SDK wrapper that binds a DaemonClient to one daemon session, forwards existing Stage 1 HTTP/SSE operations, tracks Last-Event-ID replay state, and exports the new API from the daemon and package entrypoints.
  • Why it changed: TUI, channel, IDE, and web backend adapters need a session-scoped client surface before they can consume qwen serve through HTTP + SSE without threading sessionId through every call site.
  • Reviewer focus: API naming/shape, replay-state behavior, and keeping this PR additive with no daemon server route changes.

Validation

  • Commands run:
    cd packages/sdk-typescript && npx vitest run test/unit/DaemonClient.test.ts test/unit/DaemonSessionClient.test.ts
    cd packages/sdk-typescript && npm run typecheck
    cd packages/sdk-typescript && npm run build
    npx eslint packages/sdk-typescript/src/daemon/DaemonSessionClient.ts packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts --max-warnings 0 --no-warn-ignored
    npx prettier --check packages/sdk-typescript/src/daemon/DaemonSessionClient.ts packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts packages/sdk-typescript/src/daemon/index.ts packages/sdk-typescript/src/index.ts packages/sdk-typescript/README.md
  • Prompts / inputs used: N/A; SDK unit-level PR.
  • Expected result: New session wrapper forwards existing daemon operations and preserves replay state without changing raw DaemonClient behavior.
  • Observed result: DaemonClient + DaemonSessionClient unit tests passed, typecheck passed, SDK build passed, targeted eslint passed, prettier check passed.
  • Quickest reviewer verification path:
    cd packages/sdk-typescript && npx vitest run test/unit/DaemonSessionClient.test.ts
    cd packages/sdk-typescript && npm run typecheck
  • Evidence:
    • DaemonSessionClient.test.ts: 4 tests passed.
    • DaemonClient.test.ts + DaemonSessionClient.test.ts: 47 tests passed.
    • npm run typecheck: passed.
    • npm run build: passed.
    • Targeted root eslint command above: passed.

Note: cd packages/sdk-typescript && npm run lint currently fails before linting on the local dependency/config mix with @typescript-eslint/no-unused-expressions reading missing allowShortCircuit. The staged-file pre-commit eslint and the targeted root eslint command both passed.

Scope / Risk

  • Main risk or tradeoff: DaemonSessionClient tracks replay state by default; callers that want a fresh SSE attach should pass { resume: false }.
  • Not covered / not validated: No live daemon E2E, no TUI/channel/IDE adapter migration, no typed daemon event reducer, no session-scoped permission route.
  • Breaking changes / migration notes: None. This is additive SDK surface over existing DaemonClient methods.

Engineering principles checklist (Stage 1.5 wave PRs)

  • Independently mergeable (main stays releasable after merge)
  • Backward compatible (no removed routes / event fields / CLI behavior)
  • Default off (feature flag or dual-stack; old path unchanged)
  • qwen serve Stage 1 routes / SDK behavior preserved
  • Gradual migration (P0/P1 base before adapters; behind flag first)
  • Reversible (this PR can be individually rolled back)
  • Tests-first (unit / smoke / e2e where relevant)

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Tested locally on macOS only.
  • npm run coverage: typecheck, build; package-level lint has the local rule-load issue noted above.
  • npx coverage: focused vitest, targeted eslint, and prettier check.

Linked Issues / Bugs

Related to #4175 Wave 1 PR 3 and #3803.
Depends on #4191.

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR introduces an experimental DaemonSessionClient that wraps DaemonClient to provide a session-scoped interface for daemon HTTP/SSE operations. The implementation is clean, well-tested, and appropriately scoped as an additive SDK layer with no breaking changes. The code follows established patterns from DaemonClient and includes comprehensive unit tests covering the key replay-state behavior.

🔍 General Feedback

  • Strong test coverage: 4 focused unit tests cover creation, operation forwarding, SSE replay tracking, and the resume: false escape hatch
  • Consistent patterns: The wrapper correctly delegates to DaemonClient without re-implementing HTTP logic
  • Good documentation: JSDoc comments clearly explain the session-scoped purpose and replay-state semantics
  • Additive change: No modifications to existing daemon routes or DaemonClient behavior
  • Type safety: Proper TypeScript types exported at both daemon and package entry points
  • README example: Clear usage demonstration showing the event subscription pattern

🎯 Specific Feedback

🟡 High

  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:1 - Import statement has a formatting issue: import type { DaemonClient} from './DaemonClient.js'; is missing a space before the closing brace and should be import type { DaemonClient } from './DaemonClient.js';. This will fail the prettier check if run with stricter formatting.

  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:117-122 - The subscribeEvents method tracks lastSeenEventId by checking event.id !== undefined, but the underlying DaemonEvent type may not guarantee id is always present on all event types. Consider verifying that all SSE events from the daemon include an id field, or add a defensive check to avoid updating replay state with undefined values. The current code if (event.id !== undefined) this.lastSeenEventId = event.id; handles this correctly, but it's worth confirming the daemon always sends numeric event IDs.

🟢 Medium

  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:109 - The events() method is a simple alias that returns this.subscribeEvents(opts). Since subscribeEvents is already an async * generator, this wrapper adds minimal value. Consider whether the events() alias is necessary or if callers should use subscribeEvents() directly. The alias does provide a cleaner public API surface, but the distinction between events() and subscribeEvents() isn't immediately clear from naming alone.

  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:98-102 - The setLastEventId method allows external mutation of replay state, which is useful for persisted state restoration. However, there's no corresponding getter that returns the internal state vs. the public lastEventId getter (they're the same currently). This is fine, but consider whether callers should ever need to reset to undefined explicitly vs. just seeding a new instance.

🔵 Low

  • packages/sdk-typescript/README.md:143 - The README example shows manual event loop cleanup with eventController.abort() followed by await eventTask. Consider adding a brief note about error handling (e.g., what happens if the SSE stream throws) or a try/finally pattern to ensure cleanup even on errors.

  • packages/sdk-typescript/test/unit/DaemonSessionClient.test.ts:37-52 - The recordingFetch helper captures request headers with headers[k.toLowerCase()] = v, which is correct for case-insensitive header matching. However, the test at line 197 checks calls[0]?.headers['last-event-id'] — consider adding a comment noting that header names are normalized to lowercase for test assertions.

  • packages/sdk-typescript/src/daemon/DaemonSessionClient.ts:7 - The import import type { DaemonClient} from './DaemonClient.js'; uses type import for a class that's used at runtime (constructor and static method calls). This should be a regular import: import { DaemonClient } from './DaemonClient.js'; since the class is instantiated and referenced at runtime, not just for types.

✅ Highlights

  • Excellent test design: The recordingFetch helper cleanly captures HTTP calls for assertion without complex mocking infrastructure
  • Clear separation of concerns: The class docstring explicitly states that typed event reducers belong to the protocol schema layer, not this adapter
  • Defensive defaults: resume: true by default ensures reconnecting adapters get replay behavior without extra boilerplate
  • Consistent with Stage 1 patterns: The wrapper forwards existing Stage 1 routes without modification, maintaining backward compatibility
  • Good escape hatches: { resume: false } and explicit lastEventId options give callers full control over replay behavior

@wenshao wenshao left a comment

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.

No issues found. Clean, well-tested, additive-only SDK wrapper that correctly forwards Stage 1 HTTP/SSE operations with proper Last-Event-ID replay tracking. LGTM! ✅ — glm-5.1 via Qwen Code /review

@chiga0 chiga0 closed this May 16, 2026
@chiga0 chiga0 deleted the codex/daemon-session-client branch May 16, 2026 07:58
@chiga0 chiga0 changed the title [codex] feat(sdk): add DaemonSessionClient skeleton feat(sdk): add DaemonSessionClient skeleton May 16, 2026
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.

[Suggestion] subscribeEvents() is public but only called internally by events() (line 115). Having two public methods with identical behavior creates API ambiguity — callers won't know which to use, and future maintainers won't know which one to extend.

Suggested change
async *subscribeEvents(
private async *subscribeEvents(

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

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 accepts any number without validation. Values like NaN, Infinity, negative numbers, or non-integers pass through to DaemonClient.subscribeEvents() and become invalid Last-Event-ID headers (e.g., Last-Event-ID: NaN). The daemon will silently ignore these, so the client thinks it's resuming but actually starts live — events are silently dropped with no error.

Suggested change
setLastEventId(lastEventId: number | undefined): void {
setLastEventId(lastEventId: number | undefined): void {
if (lastEventId !== undefined) {
if (!Number.isSafeInteger(lastEventId) || lastEventId < 1) {
throw new TypeError(
`lastEventId must be a safe positive integer, got ${lastEventId}`,
);
}
}
this.lastSeenEventId = lastEventId;
}

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


async *subscribeEvents(
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] When the inner SSE stream throws (network error, daemon restart), this.lastSeenEventId retains its stale value. The next events() call with default resume: true sends a Last-Event-ID that belongs to a potentially dead session. After a daemon restart the new session resets IDs from 1, so the stale ID causes either a 400 error or silent replay from the wrong position.

Wrap the for await in try/finally to clear the cursor on stream failure:

Suggested change
): AsyncGenerator<DaemonEvent> {
try {
for await (const event of this.client.subscribeEvents(this.sessionId, {
...subscribeOpts,
lastEventId,
})) {
if (event.id !== undefined) this.lastSeenEventId = event.id;
yield event;
}
} catch {
this.lastSeenEventId = undefined;
throw;
}

This is the 3 AM oncall nightmare: duplicate or missing events after a network blip, with no error log to explain why.

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

async *subscribeEvents(
opts: DaemonSessionSubscribeOptions = {},
): AsyncGenerator<DaemonEvent> {
const { resume = true, ...subscribeOpts } = opts;

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] The ?? operator on this line treats explicit undefined the same as "key not present": subscribeOpts.lastEventId ?? (resume ? this.lastSeenEventId : undefined). A caller passing { lastEventId: undefined, resume: false } (intending a fresh subscription) will unexpectedly trigger replay because ?? falls through to the resume branch.

Use 'lastEventId' in subscribeOpts to distinguish "key present with value undefined" from "key absent":

Suggested change
const { resume = true, ...subscribeOpts } = opts;
const lastEventId =
'lastEventId' in subscribeOpts
? subscribeOpts.lastEventId
: resume
? this.lastSeenEventId
: undefined;

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

@wenshao wenshao left a comment

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.

Review Findings (unmapped to diff lines)

[Critical] subscribeEvents 中的 if (event.id !== undefined) 守卫分支未测试。所有 4 个测试事件都带显式 id: 行,但 daemon 规范中 stream_error 等事件故意不包含 id。如果未来重构修改该逻辑,lastEventId 可能被合成事件污染。请在 SSE 响应帧中添加一个无 id: 的事件,断言 session.lastEventId 不变。

[Suggestion] respondToPermission 仅测试了 200/true 路径;DaemonClient.respondToPermission 在 HTTP 404 时返回 false(表示权限请求已被其他客户端回复),该路径完全未覆盖。请添加一个返回 HTTP 404 的测试,断言 false 返回值。

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

...subscribeOpts,
lastEventId,
})) {
if (event.id !== undefined) this.lastSeenEventId = event.id;

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] lastSeenEventIdyield event 之前更新,导致消费者出错/崩溃时静默丢失事件。

当前代码(第 128-130 行):

if (event.id !== undefined) this.lastSeenEventId = event.id;
yield event;

如果消费者在处理 yield 的事件时抛出异常、break 出循环或进程崩溃,lastSeenEventId 已指向该事件之后。下次 events() 重连时 daemon 从下一个事件恢复,消费者永久丢失该事件。

Suggested change
if (event.id !== undefined) this.lastSeenEventId = event.id;
yield event;
if (event.id !== undefined) this.lastSeenEventId = event.id;

这样确保 lastSeenEventId 仅在消费者成功接收事件后才提交。

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants