|
| 1 | +# Migrating to `@qwen-code/sdk/daemon` v2 |
| 2 | + |
| 3 | +PR #4328 shipped the v1 daemon UI layer. PR #4353 (this PR) ships v2 with |
| 4 | +seven additive feature commits. This guide walks through the changes for |
| 5 | +adapter authors (TUI / web / IDE / channel / mobile maintainers). |
| 6 | + |
| 7 | +## TL;DR for existing consumers |
| 8 | + |
| 9 | +**No breaking changes.** Every commit in this PR is additive: |
| 10 | + |
| 11 | +- v1 fields still work (`createdAt` preserved as `@deprecated` alias for |
| 12 | + `clientReceivedAt`) |
| 13 | +- v1 normalizer still maps the same 13 event types the same way |
| 14 | +- v1 reducer still produces the same blocks for chat events |
| 15 | +- New API is opt-in via additional parameters and helpers |
| 16 | + |
| 17 | +The PR is safe to merge without any consumer changes. **Adoption of the |
| 18 | +new features is incremental.** |
| 19 | + |
| 20 | +## Recommended adoption order |
| 21 | + |
| 22 | +For each adapter, in order of effort/value ratio: |
| 23 | + |
| 24 | +### 1. Ordering: switch sort key from `createdAt` to `eventId` |
| 25 | + |
| 26 | +**Before:** |
| 27 | + |
| 28 | +```ts |
| 29 | +const ordered = [...state.blocks].sort((a, b) => a.createdAt - b.createdAt); |
| 30 | +``` |
| 31 | + |
| 32 | +**After:** |
| 33 | + |
| 34 | +```ts |
| 35 | +import { selectTranscriptBlocksOrderedByEventId } from '@qwen-code/sdk/daemon'; |
| 36 | +const ordered = selectTranscriptBlocksOrderedByEventId(state); |
| 37 | +``` |
| 38 | + |
| 39 | +**Why**: `eventId` is daemon-monotonic; survives SSE replay-after-reconnect. |
| 40 | +`createdAt` is client clock and shifts under replay. |
| 41 | + |
| 42 | +### 2. Display: switch `createdAt` to `serverTimestamp ?? clientReceivedAt` |
| 43 | + |
| 44 | +**Before:** |
| 45 | + |
| 46 | +```tsx |
| 47 | +<TimeLabel ms={block.createdAt} /> |
| 48 | +``` |
| 49 | + |
| 50 | +**After:** |
| 51 | + |
| 52 | +```tsx |
| 53 | +import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; |
| 54 | +<TimeLabel text={formatBlockTimestamp(block, { locale })} /> |
| 55 | +``` |
| 56 | + |
| 57 | +**Why**: Multiple clients see consistent "X minutes ago" only when both |
| 58 | +read daemon clock. Renderer plus `formatBlockTimestamp` handles tz + |
| 59 | +locale. |
| 60 | + |
| 61 | +**Note**: Daemon needs to stamp `_meta.serverTimestamp` on envelopes for |
| 62 | +this to take effect. SDK forward-compat-ready; falls back to |
| 63 | +`clientReceivedAt` until then. |
| 64 | + |
| 65 | +### 3. Listen for new event types — pick subset to render |
| 66 | + |
| 67 | +The 16 new event types (session-meta, workspace, auth) don't push transcript |
| 68 | +blocks. They are sidechannel observations. Each adapter picks which to surface: |
| 69 | + |
| 70 | +```ts |
| 71 | +// In your SSE consumer |
| 72 | +const uiEvents = normalizeDaemonEvent(envelope, { clientId, suppressOwnUserEcho: true }); |
| 73 | +store.dispatch(uiEvents); |
| 74 | + |
| 75 | +// Then in your UI side |
| 76 | +for (const event of uiEvents) { |
| 77 | + switch (event.type) { |
| 78 | + case 'session.approval_mode.changed': |
| 79 | + myApprovalModeBadge.update(event.next); |
| 80 | + break; |
| 81 | + case 'workspace.mcp.budget_warning': |
| 82 | + myToast.show(`MCP servers approaching budget: ${event.liveCount}/${event.budget}`); |
| 83 | + break; |
| 84 | + case 'auth.device_flow.started': |
| 85 | + myAuthModal.show({ |
| 86 | + deviceFlowId: event.deviceFlowId, |
| 87 | + providerId: event.providerId, |
| 88 | + expiresAt: event.expiresAt, |
| 89 | + }); |
| 90 | + break; |
| 91 | + // ... etc, opt into what your UI needs |
| 92 | + } |
| 93 | +} |
| 94 | +``` |
| 95 | + |
| 96 | +Or use selectors for state-mirrored sidechannels: |
| 97 | + |
| 98 | +```ts |
| 99 | +import { selectApprovalMode, selectCurrentTool } from '@qwen-code/sdk/daemon'; |
| 100 | + |
| 101 | +const mode = selectApprovalMode(state); // mirrored from approval_mode.changed |
| 102 | +const currentTool = selectCurrentTool(state); // current in-flight tool |
| 103 | +``` |
| 104 | + |
| 105 | +### 4. Render contract: use `daemonBlockToMarkdown` (or HTML / plainText) |
| 106 | + |
| 107 | +**Before** (each adapter does its own projection): |
| 108 | + |
| 109 | +```ts |
| 110 | +function blockToString(block: DaemonTranscriptBlock): string { |
| 111 | + switch (block.kind) { |
| 112 | + case 'user': return `You: ${block.text}`; |
| 113 | + case 'assistant': return block.text; |
| 114 | + case 'tool': return `[${block.title}]\n${block.status}`; |
| 115 | + // ... etc |
| 116 | + } |
| 117 | +} |
| 118 | +``` |
| 119 | + |
| 120 | +**After** (delegate to SDK): |
| 121 | + |
| 122 | +```ts |
| 123 | +import { daemonBlockToMarkdown } from '@qwen-code/sdk/daemon'; |
| 124 | +const md = daemonBlockToMarkdown(block); |
| 125 | +``` |
| 126 | + |
| 127 | +For HTML SSR: |
| 128 | + |
| 129 | +```ts |
| 130 | +import MarkdownIt from 'markdown-it'; |
| 131 | +import DOMPurify from 'dompurify'; |
| 132 | +const html = DOMPurify.sanitize(md.render(daemonBlockToMarkdown(block))); |
| 133 | +``` |
| 134 | + |
| 135 | +For plain text: |
| 136 | + |
| 137 | +```ts |
| 138 | +import { daemonBlockToPlainText } from '@qwen-code/sdk/daemon'; |
| 139 | +const plain = daemonBlockToPlainText(block); |
| 140 | +``` |
| 141 | + |
| 142 | +### 5. Conformance test |
| 143 | + |
| 144 | +Add to your adapter's test suite: |
| 145 | + |
| 146 | +```ts |
| 147 | +import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; |
| 148 | + |
| 149 | +it('adapter projects daemon UI corpus correctly', () => { |
| 150 | + const result = runAdapterConformanceSuite({ |
| 151 | + reduce: (events) => myReduce(events), |
| 152 | + renderToText: (state) => myRender(state), |
| 153 | + }); |
| 154 | + expect(result.failed).toEqual([]); |
| 155 | +}); |
| 156 | +``` |
| 157 | + |
| 158 | +This will run your adapter against 10 fixture scenarios and surface any |
| 159 | +projection drift before it reaches users. |
| 160 | + |
| 161 | +### 6. Tool icon dispatch via `provenance` |
| 162 | + |
| 163 | +**Before** (string match on toolName): |
| 164 | + |
| 165 | +```tsx |
| 166 | +const isMcp = toolName?.startsWith('mcp__'); |
| 167 | +const isBuiltin = ['Bash', 'Edit', 'Read'].includes(toolName); |
| 168 | +``` |
| 169 | + |
| 170 | +**After** (typed provenance from PR-A): |
| 171 | + |
| 172 | +```tsx |
| 173 | +import type { DaemonUiToolUpdateEvent } from '@qwen-code/sdk/daemon'; |
| 174 | + |
| 175 | +function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { |
| 176 | + switch (event.provenance) { |
| 177 | + case 'mcp': return <McpIcon server={event.serverId} />; |
| 178 | + case 'subagent': return <SubagentIcon />; |
| 179 | + case 'builtin': return <BuiltinIcon name={event.toolName} />; |
| 180 | + case 'unknown': |
| 181 | + default: return <GenericIcon />; |
| 182 | + } |
| 183 | +} |
| 184 | +``` |
| 185 | + |
| 186 | +SDK has a `mcp__<server>__<tool>` naming heuristic fallback — works today |
| 187 | +even when daemon doesn't explicitly stamp provenance. |
| 188 | + |
| 189 | +### 7. Error categorization via `errorKind` |
| 190 | + |
| 191 | +**Before** (regex on text): |
| 192 | + |
| 193 | +```ts |
| 194 | +if (error.text.includes('auth')) showAuthRetry(); |
| 195 | +else if (error.text.includes('file not found')) showFilePicker(); |
| 196 | +``` |
| 197 | + |
| 198 | +**After** (closed enum from PR-A): |
| 199 | + |
| 200 | +```ts |
| 201 | +import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; |
| 202 | + |
| 203 | +function errorAction(errorKind?: DaemonErrorKind): React.ReactNode { |
| 204 | + switch (errorKind) { |
| 205 | + case 'auth_env_error': return <RetryAuthButton />; |
| 206 | + case 'missing_file': return <FilePicker />; |
| 207 | + case 'blocked_egress': return <CheckProxyHint />; |
| 208 | + case 'init_timeout': return <RestartDaemonButton />; |
| 209 | + default: return null; |
| 210 | + } |
| 211 | +} |
| 212 | +``` |
| 213 | + |
| 214 | +**Note**: Daemon needs to stamp `data.errorKind` on session_died / |
| 215 | +stream_error for this to populate. SDK already reads it. |
| 216 | + |
| 217 | +### 8. Cancellation handling — already automatic |
| 218 | + |
| 219 | +In v1, cancelled prompts left in-flight tool blocks spinning forever. |
| 220 | +In v2 (PR-E), `propagateCancellationToInFlightTools` runs automatically |
| 221 | +on `assistant.done.reason === 'cancelled'`. |
| 222 | + |
| 223 | +**No adapter changes needed** — your spinners will resolve correctly. |
| 224 | + |
| 225 | +### 9. Tool preview taxonomy — pick subset to render with custom components |
| 226 | + |
| 227 | +PR-D + PR-F bring 13 preview kinds: |
| 228 | + |
| 229 | +- 4 file-shaped: `file_diff`, `file_read`, `web_fetch`, `mcp_invocation` |
| 230 | +- 5 content-shaped: `code_block`, `search`, `tabular`, `image_generation`, `subagent_delegation` |
| 231 | +- 2 control: `ask_user_question`, `command` |
| 232 | +- 2 generic: `key_value`, `generic` |
| 233 | + |
| 234 | +Each adapter dispatches on `preview.kind`: |
| 235 | + |
| 236 | +```tsx |
| 237 | +function ToolPreviewComponent({ preview }: { preview: DaemonToolPreview }) { |
| 238 | + switch (preview.kind) { |
| 239 | + case 'file_diff': return <UnifiedDiffView path={preview.path} old={preview.oldText} new={preview.newText} />; |
| 240 | + case 'mcp_invocation': return <McpCard serverId={preview.serverId} toolName={preview.toolName} />; |
| 241 | + case 'tabular': return <DataTable columns={preview.columns} rows={preview.rows} />; |
| 242 | + case 'image_generation': return <ImagePreview thumbnailUrl={preview.thumbnailUrl} prompt={preview.prompt} />; |
| 243 | + // ... or fall back to: |
| 244 | + default: return <Markdown text={daemonToolPreviewToMarkdown(preview)} />; |
| 245 | + } |
| 246 | +} |
| 247 | +``` |
| 248 | + |
| 249 | +Adapters without custom components for all 13 kinds can fall back to the |
| 250 | +SDK's `daemonToolPreviewToMarkdown` for any unhandled kind. |
| 251 | + |
| 252 | +## Backward-compat checklist |
| 253 | + |
| 254 | +| Concern | Status | |
| 255 | +|---|---| |
| 256 | +| Existing `block.createdAt` reads | ✅ still works (alias for `clientReceivedAt`) | |
| 257 | +| Existing reducer event handling | ✅ unchanged for v1 event types | |
| 258 | +| `daemonTranscriptToUnifiedMessages(blocks)` call sites | ✅ new options param is optional | |
| 259 | +| Existing `selectTranscriptBlocks` consumers | ✅ unchanged | |
| 260 | +| New event types in v1 reducer | ✅ no-op, `lastEventId` still advances | |
| 261 | + |
| 262 | +## Cross-references |
| 263 | + |
| 264 | +- [PR #4353 SUMMARY](https://github.com/QwenLM/qwen-code/pull/4353) |
| 265 | +- [Daemon UI README](./README.md) — full API reference |
| 266 | +- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with shared UI transcript layer |
0 commit comments