Skip to content

Commit e5f786a

Browse files
chiga0秦奇claude
authored andcommitted
feat(sdk/daemon-ui): unified completeness follow-up to #4328 (#4353)
* feat(sdk/daemon-ui): expand event coverage to 28+ daemon event types (PR-A) Closes the "12+ daemon events fall through to debug" gap surfaced in the PR the daemon currently emits (Stage 1 + Wave 3-4), so renderers stop having to peek at `rawEvent.data` for known event categories. Session-meta: - session.metadata.changed (from session_metadata_updated) - session.approval_mode.changed (from approval_mode_changed) - session.available_commands (from available_commands_update; upgraded from a status-text fallback to a typed event carrying the command list) Workspace state (Wave 3-4): - workspace.memory.changed - workspace.agent.changed - workspace.tool.toggled - workspace.initialized - workspace.mcp.budget_warning - workspace.mcp.child_refused - workspace.mcp.server_restarted - workspace.mcp.server_restart_refused Auth device-flow (Wave 4 OAuth, RFC 8628): - auth.device_flow.started - auth.device_flow.throttled - auth.device_flow.authorized - auth.device_flow.failed (carries DaemonAuthDeviceFlowSdkErrorKind) - auth.device_flow.cancelled - `DaemonUiErrorEvent.errorKind?: DaemonErrorKind` — closed-enum error category propagated from daemon's typed-error taxonomy. Renderers can branch on errorKind for "retry auth" vs "check file path" affordances instead of regex-matching `text`. - `DaemonUiToolUpdateEvent.provenance?: DaemonUiToolProvenance` + `.serverId?` — closed enum ('builtin' | 'mcp' | 'subagent' | 'unknown'). Falls back to the `mcp__<server>__<tool>` naming heuristic when the daemon doesn't stamp provenance explicitly. Unblocks UI namespace dispatch without string-matching toolName. Session-meta / workspace / auth events do NOT push transcript blocks. They are intentional sidechannel observations: `lastEventId` advances (monotonic invariant preserved), but the chat-stream transcript stays focused on user/assistant/tool/shell/permission content. Renderers consume them via selectors (introduced in follow-up PRs). All new event types produce short structured lines in `daemonUiEventToTerminalText` for tail-style debug consumers. Web/IDE renderers should consume the typed events directly via subscription. 40/40 tests pass. New tests verify: - All 16 new event types normalize correctly - Malformed payloads fall back to debug without leaking raw data (`secret` field never appears in fallback text) - MCP tool provenance heuristic (`mcp__github__create_issue` → provenance='mcp', serverId='github') - errorKind propagation on session_died / stream_error - Reducer is no-op on new event types; lastEventId still advances This is PR-A of the unified-renderer-layer follow-up series: - PR-A (this commit) — event coverage + closed-enum schema - PR-B — server-side timestamps + ordering refactor - PR-C — multimodal content + tool preview taxonomy - PR-D — render contract (toMarkdown / toHtml / toPlainText) + adapter conformance test framework - PR-E — reducer state machine (subagent / progress / current tool / cancellation propagation) See https://github.com/QwenLM/qwen-code/pull/4328#issuecomment-4494179724 for the full proposal. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): server timestamps + event-id-based ordering (PR-B) Closes the "时间定义不标准" gap surfaced in the PR #4328 review: - Client-side `Date.now()` drifts across clients - No daemon-authoritative timestamp propagated to UI - Out-of-order replay events get fresher `state.now` than originals, breaking `createdAt` ordering - `DaemonUiEventBase.serverTimestamp?: number` — daemon-authoritative wall-clock timestamp extracted from envelope. - `DaemonTranscriptBlockBase.serverTimestamp?: number` + `clientReceivedAt: number`. - `createdAt` preserved as `@deprecated` alias for `clientReceivedAt` (backward compat for code written before this PR). `extractServerTimestamp` looks at three candidate envelope locations: 1. `event.serverTimestamp` (preferred when daemon adds it) 2. `event._meta.serverTimestamp` (Anthropic-style metadata convention) 3. `event.data._meta.serverTimestamp` (sessionUpdate nested location) The SDK is ready to consume serverTimestamp WHEN daemon emits it, without requiring a coordinated SDK release. Undefined when daemon doesn't emit (current state) — graceful degradation to client-clock ordering. `selectTranscriptBlocksOrderedByEventId(state)` — returns blocks sorted by: 1. `eventId` (daemon-monotonic SSE cursor) — primary key 2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames 3. `clientReceivedAt` (local clock) — last resort Use this when displaying long sessions where event id 5 may arrive AFTER event id 7 (typical in SSE replay-after-reconnect). `formatBlockTimestamp(block, opts)` — formats the most authoritative timestamp on a block using `Intl.DateTimeFormat`. Prefers `serverTimestamp` over `clientReceivedAt` for cross-client consistency. Accepts locale / timeZone / dateStyle / timeStyle. Daemon needs to stamp `_meta.serverTimestamp` on every SSE envelope. This SDK PR is ready to consume it the moment the daemon ships the field; no coordination needed. - serverTimestamp extraction from all three envelope locations - Defaults undefined when envelope has none - `selectTranscriptBlocksOrderedByEventId` sorts mixed-arrival events by eventId (replay scenario) - `formatBlockTimestamp` prefers serverTimestamp; returns localized string PR-B of the unified follow-up to PR #4328 (PR-A + PR-B + PR-C + PR-D + PR-E in one branch). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): reducer state machine — currentTool / approvalMode / cancellation propagation (PR-E) Closes the "reducer state machine 设计缺漏" gap surfaced in the PR #4328 review: - No `currentTool` — UI scans `blocks[]` to find the running tool - No mirrored approval mode — UI walks events to badge "plan"/"yolo" - Cancellation does not propagate — in-flight tool blocks stuck at 'in_progress' forever when the parent prompt is cancelled ## State additions (sidechannel, no transcript blocks) `DaemonTranscriptSidechannelState`: - `currentToolCallId?: string` — toolCallId of the in-flight tool - `approvalMode?: string` — mirrored from session.approval_mode.changed - `toolProgress: Record<string, { ratio?, step? }>` — per-tool progress shape (daemon-side emission of `tool.progress` events pending) ## Reducer behavior ### `tool.update` events `IN_FLIGHT_TOOL_STATUSES` = { pending, confirming, running, in_progress } `TERMINAL_TOOL_STATUSES` = { completed, success, failed, error, canceled, cancelled } - Tool enters in-flight: set `currentToolCallId = event.toolCallId` - Tool enters terminal: clear `currentToolCallId` if it matches - Unknown status (forward-compat): leave pointer untouched This avoids the failure mode where a future daemon-emitted status like `'paused'` would silently mark unknown states as either in-flight or terminal incorrectly. ### `session.approval_mode.changed` Mirror `event.next` onto `state.approvalMode`. Renderers can render a mode badge ("plan" / "default" / "auto-edit" / "yolo") with a single selector call, no event-stream walking. ### `assistant.done` with `reason === 'cancelled'` `propagateCancellationToInFlightTools` walks every tool block whose status is still in-flight and force-sets it to 'cancelled'. The daemon does not guarantee terminal `tool_call_update` for every in-flight tool when the parent prompt is cancelled, so this propagation prevents UI spinners from spinning forever. `currentToolCallId` is also cleared in the same call. Non-cancellation `assistant.done` (e.g., `reason: 'end_turn'`) does NOT propagate — in-flight tools remain in-flight until the daemon emits their terminal update naturally. ## Selectors - `selectCurrentTool(state)` — returns the running tool block, or undefined - `selectApprovalMode(state)` — returns the mirrored approval mode - `selectToolProgress(state, toolCallId)` — per-tool progress query All exported from `@qwen-code/sdk/daemon`. ## Scope deliberately deferred Subagent nesting (`parentBlockId` / `delegationId` / `DaemonSubagentTranscriptBlock`) is NOT in this PR. The shape needs design discussion (how to project nested events; whether to bake delegation tracking into transcript or sidechannel). PR-D / PR-F follow-up. ## Test coverage (51/51 pass) - currentToolCallId set on enter, cleared on terminal - approvalMode mirrors changes - Cancellation marks in-flight tools 'cancelled', leaves completed alone - Unknown status does NOT clear currentToolCallId (forward-compat) - Non-cancellation `assistant.done` does NOT propagate ## Roadmap PR-E of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E in this branch; PR-C / PR-D pending). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): tool preview taxonomy + multimodal content extraction (PR-C) Closes two related gaps surfaced in the PR #4328 review: - `DaemonToolPreview` had only 4 kinds — UI fell back to `key_value` / `generic` for tools that deserved structured display - `getTextContent` silently dropped non-text content (image / audio / resource), so multimodal conversations vanished from the UI `DaemonToolPreview` extends from 4 to 8 variants: - `file_diff` — `{ path, oldText?, newText?, patch? }` — file edit tools (Anthropic-style `oldText/newText`, aider-style `patch`, write-style `newText` alone) - `file_read` — `{ path, range?: [start, end] }` — file read tools, with range extracted from `lineRange` tuple OR `offset/limit` pair - `web_fetch` — `{ url, method? }` — HTTP fetch tools (requires URL with scheme to avoid false positives on relative paths) - `mcp_invocation` — `{ serverId, toolName, argsSummary? }` — MCP server tool calls, identified via `mcp__<server>__<tool>` naming convention (same heuristic as PR-A `DaemonUiToolUpdateEvent.provenance`) Detector order matters — MCP wins first (most specific), then file_diff, file_read, web_fetch, then the existing command / key_value fallbacks. New helper `extractContentPart(value): DaemonUiContentPart | undefined` returns a discriminated union: ```ts type DaemonUiContentPart = | { kind: 'text'; text: string } | { kind: 'image'; mediaType: string; source: { url?, data? } } | { kind: 'audio'; mediaType: string; source: { url?, data? } } | { kind: 'resource'; uri: string; mediaType?, description? }; ``` The existing `getTextContent` is preserved for backward compat. Renderers that need to surface non-text content (web UI thumbnails, IDE attachment chips) now have a typed shape to consume. - Wiring `extractContentPart` into the normalizer / reducer so text blocks accumulate `parts: DaemonUiContentPart[]` alongside `text` (additive shape change requires render contract coordination — PR-D). - 5 additional tool preview kinds (image_generation / code_block / tabular / subagent_delegation / search) — useful but not urgent; current 8 kinds cover the typical agent flows. - file_diff detection from Anthropic / aider / write shapes - file_read with lineRange tuple AND offset+limit pair - web_fetch with method, REJECTS relative paths (no scheme) - mcp_invocation with serverId + toolName extraction - Detector priority: MCP wins over file_diff on conflicting shapes - extractContentPart for text / image (url) / audio (data) / resource - Unknown content type returns undefined (skip rather than synthesize) - Image without source returns undefined (defensive) PR-C of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E + PR-C in this branch; PR-D render contract pending). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): render contract — markdown / HTML / plain text helpers (PR-D) Closes the "render 契约只覆盖 terminal" gap surfaced in the PR #4328 review: > PR ships `daemonUiEventToTerminalText` for terminal. Web/IDE/channel > adapters each roll their own projection. No shared contract → adapter > divergence is inevitable. ## New helpers ```ts daemonBlockToMarkdown(block, opts?): string // GFM-compatible daemonBlockToHtml(block, opts?): string // conservatively escaped HTML daemonBlockToPlainText(block, opts?): string // for copy-paste / logs daemonToolPreviewToMarkdown(preview, opts?): string ``` All three respect the same `kind` discrimination so adapters can switch between them without touching call sites. ## Per-kind projection For each `DaemonTranscriptBlock['kind']`: - `user` / `assistant` / `thought` — plain text with role labels - `tool` — header with toolName + structured preview + status badge - `shell` — fenced code block, stream-discriminated (stdout vs stderr) - `permission` — title + options list + resolved/pending indicator - `status` / `debug` / `error` — semantic class / role (error → role=alert) For each `DaemonToolPreview['kind']`: - `ask_user_question` — question + options as bullet list - `command` — fenced bash with optional cwd comment - `file_diff` — unified diff in fenced code block (oldText/newText OR patch) - `file_read` — `path (lines N-M)` line - `web_fetch` — `METHOD url` line - `mcp_invocation` — `serverId::toolName` with args summary - `key_value` — bullet list - `generic` — emphasized summary ## Security - Default HTML sanitizer escapes `<`, `>`, `&`, `"`, `'` and FIRST strips ANSI/control sequences via `sanitizeTerminalText` (defense against agent-emitted escape codes in HTML output). - Custom sanitizer hook for consumers wanting markdown→HTML pipelines (markdown-it + DOMPurify, etc.). - `sanitizeUrls` option strips token-like query params (`token=`, `key=`, `x-amz-`, etc.) from URLs in `web_fetch` previews. - `maxFieldLength` truncation defaults 8192, prevents pathological rendering on huge content. ## Adapter conformance (out of scope for this commit) The conformance test framework (fixture corpus + `runAdapterConformanceSuite`) mentioned in PR-D scope is deferred to a follow-up. The render helpers here are the precondition — once stable, the conformance framework can use them as the reference projection. ## Test coverage (77/77 pass) - All 9 block kinds render in markdown (verified for user/assistant/tool/ shell/permission/error specifically) - file_diff renders as unified diff with old/new lines - mcp_invocation renders as `server::tool` format - HTML escapes XSS (`<script>` → `&lt;script&gt;`) - HTML strips terminal escape sequences before escaping - Error blocks emit `role="alert"` for screen readers - plain text drops markdown delimiters - maxFieldLength truncates with ellipsis - sanitizeUrls strips token query params - Custom sanitizer hook works ## Roadmap PR-D of the unified follow-up to PR #4328 — completes the 5-PR series (A: event coverage, B: time schema, E: state machine, C: tool preview + content extraction, D: render contract). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): 5 additional tool preview kinds — taxonomy complete (PR-F) Closes the "5 additional preview kinds" item in PR #4353's TODO §A (SDK-only work). ## New preview kinds (8 → 13) - `code_block` — `{ language?, code, origin? }` — REPL / formatter / generator output, fenced as `\`\`\`<language>` in markdown - `search` — `{ query, resultCount?, top? }` — grep / ripgrep / find / glob results with up to 5 top hits - `tabular` — `{ columns, rows, totalRows? }` — structured table output (50-row cap with `totalRows` truncation indicator); supports both `columns: string[] + rows: unknown[][]` explicit shape and legacy `data: Array<Record<>>` shape (auto-infers columns from first row) - `image_generation` — `{ prompt, thumbnailUrl?, model? }` — dall-e / diffusion / imagen / flux / sora style tools - `subagent_delegation` — `{ agentName, task, parentDelegationId? }` — Anthropic-style Task tool and similar sub-agent dispatchers ## Detector priority Order matters — most specific wins. New detectors slot in between `mcp_invocation` and `file_diff`: ``` mcp_invocation > subagent_delegation > search > image_generation > file_diff > file_read > web_fetch > code_block > tabular > command > key_value > generic ``` Rationale: subagent / search / image generation are most discriminable (distinct toolName patterns); file ops next; code_block / tabular last because their shapes (`code:`, `columns:`) can appear in other tools. ## Render projections Both `daemonToolPreviewToMarkdown` and the plain-text rendering paths extended with cases for all 5 new kinds: - code_block: fenced markdown code block with language tag - search: bold header + GFM bullet list of top results - tabular: GFM pipe table with header / separator / body / truncation hint - image_generation: bold header + blockquoted prompt + embedded markdown image (URL sanitization respected via `sanitizeUrls` opt) - subagent_delegation: bold delegate-arrow header + blockquoted task + optional parent delegation reference ## Test coverage (91/91 pass, +14 new) - Each detector with positive case - Detector priority verified: subagent_delegation wins over file_diff when toolName='Task' has both subagent + file-edit fields - Tabular row cap (50) + totalRows stamping for truncated data - Legacy data: Array<Record<>> auto-column inference - Each render projection with structural assertions (markdown table format, image embed, bullet lists) ## Roadmap PR-F of the unified follow-up to PR #4328. Brings the preview taxonomy to 13 kinds covering: file ops (3), web (1), code/data (2), media (1), agent control (2 — ask_user_question + subagent_delegation), MCP (1), search (1), generic fallbacks (2). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(sdk/daemon-ui): adapter conformance framework + fixture corpus (PR-G) Closes the "Adapter conformance test framework" item in PR #4353's TODO §A. Lets any daemon-ui adapter (TUI / web / IDE / channel / mobile) validate that it projects a fixed corpus of daemon SSE event streams to the same semantic shape — catches projection drift before it reaches users. ## API surface ```ts interface DaemonUiAdapterUnderTest { reduce(events: readonly DaemonUiEvent[]): unknown; renderToText(state: unknown): string; } interface DaemonUiConformanceFixture { name: string; description: string; envelopes: DaemonEvent[]; // raw daemon envelopes expectedContains: string[]; // phrases the rendered text MUST contain expectedAbsent?: string[]; // phrases that MUST NOT appear normalizeOptions?: { ... }; // forward-compat normalize opts } runAdapterConformanceSuite(adapter, opts?): ConformanceSuiteResult DAEMON_UI_CONFORMANCE_FIXTURES: ReadonlyArray<DaemonUiConformanceFixture> ``` ## Design **Format-agnostic assertion**: adapters can render to ANSI / HTML / markdown / JSX — the framework only inspects plain text via `renderToText`. Catches semantic divergence (missing user message, wrong tool status, leaked secret) without forcing identical formatting. **Embedded fixture corpus** (no fs reads — works in browser bundle): - `simple-chat` — user/assistant streaming flow - `tool-call-lifecycle` — running → completed transition - `file-edit-diff` — file_diff preview surfacing - `mcp-invocation` — MCP serverId/toolName extraction via heuristic - `permission-lifecycle` — request + resolved with outcome - `mcp-budget-warning` — Wave 3 event (adapter must observe but rendering is its choice) - `cancellation-propagates` — tool block status flows - `malformed-payload-redaction` — uses `includeRawEvent: true` to verify even a debug-mode adapter doesn't leak `token: secret-do-not-leak` - `auth-device-flow-success` — Wave 4 OAuth events - `available-commands-typed-event` — PR-A upgrade from status text Per-fixture `expectedContains` and `expectedAbsent` describe the content contract independently of format. ## Suite result ```ts { passed: number, failed: ConformanceFailure[], // each carries missing + leaked + excerpt total: number, } ``` **Does not throw** — caller asserts on `result.failed` so adapter test suites can produce per-fixture diagnostics rather than a single opaque exception. ## Filter options `only` / `skip` allow targeted runs during adapter development: ```ts runAdapterConformanceSuite(myAdapter, { only: ['simple-chat'] }); runAdapterConformanceSuite(myAdapter, { skip: ['cancellation-propagates'] }); ``` ## Test coverage (97/97 pass, +6 new) - SDK reference adapter (reducer + markdown render) passes all fixtures - SDK reference adapter (reducer + plainText render) also passes - Buggy adapter (empty string output) fails every fixture with non-empty `expectedContains` - Buggy adapter (raw event dump via JSON.stringify) caught by redaction fixture's `expectedAbsent` - `only` filter narrows to a single fixture - `skip` filter excludes named fixtures from the corpus ## Usage from adapter authors ```ts // In your adapter's test file import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; import { reduceForTui, renderTuiState } from './my-tui-adapter'; it('TUI adapter conforms to daemon UI corpus', () => { const result = runAdapterConformanceSuite({ reduce: reduceForTui, renderToText: renderTuiState, }); expect(result.failed).toEqual([]); }); ``` ## Roadmap PR-G of the unified follow-up to PR #4328. The corpus is intentionally small (10 fixtures) but extensible — adapter authors can submit new fixtures via additions to `DAEMON_UI_CONFORMANCE_FIXTURES` to lock in regression coverage for edge cases their adapter encountered. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * feat(webui+sdk/daemon-ui): wire transcriptAdapter to SDK render contract (PR-H) Closes the "WebUI transcriptAdapter migration" item in PR #4353's TODO §A. Validates the PR-D render contract end-to-end on the real WebUI consumer. `daemonTranscriptToUnifiedMessages(blocks, options?)` gains a new options parameter: ```ts interface DaemonTranscriptAdapterOptions { useMarkdown?: boolean; // default: false enrichToolDetailsWithPreview?: boolean; // default: false } ``` Defaults preserve legacy behavior — existing callers see no change. For `user` / `assistant` / `thought` blocks, content is projected via SDK's `daemonBlockToMarkdown` instead of raw sanitized text. The WebUI's markdown renderer (markdown-it) then gets: - `**You**\n\n<content>` for user blocks (bold "You" label) - Raw text for assistant blocks (markdown formatting in agent output passes through cleanly) - `> *thought:* <text>` blockquote for thought blocks For `tool` blocks, `rawOutput` is replaced with `daemonToolPreviewToMarkdown(block.preview)`. This lets WebUI surfaces without per-preview-kind React components still display: - `file_diff` as a fenced unified diff - `mcp_invocation` as `server::tool` with args summary - `tabular` as GFM pipe table - `search` as bullet list with match count - `image_generation` as embedded markdown image - `subagent_delegation` as delegate arrow + task quote Renderers with per-kind components should leave this opt-out. `packages/sdk-typescript/src/daemon/index.ts` was missing exports for PR-D / PR-F / PR-G / PR-B / PR-E surface — WebUI's `@qwen-code/sdk/daemon` import path uses the daemon root, not the ui/ sub-index. Added 15+ re-exports so consumers don't need to use the longer `@qwen-code/sdk/daemon/ui/index.js` path. Now exported from `@qwen-code/sdk/daemon` root: - `daemonBlockToMarkdown` / `daemonBlockToHtml` / `daemonBlockToPlainText` - `daemonToolPreviewToMarkdown` - `extractContentPart` + `DaemonUiContentPart` type - `formatBlockTimestamp` + `selectTranscriptBlocksOrderedByEventId` - `selectCurrentTool` / `selectApprovalMode` / `selectToolProgress` - `runAdapterConformanceSuite` + `DAEMON_UI_CONFORMANCE_FIXTURES` - All associated types `webui/src/daemon/transcriptAdapter.test.ts` mock blocks updated to include `clientReceivedAt` (required field added in PR-B). Mechanical change — every `createdAt: N` test fixture gets a matching `clientReceivedAt: N`. - WebUI `npm run typecheck` — clean - SDK `npm run typecheck` — clean - SDK `vitest run test/unit/daemonUi.test.ts` — 97/97 pass - WebUI transcriptAdapter test fixtures typecheck against updated DaemonTranscriptBlockBase schema PR-H of the unified follow-up to PR #4328. Closes the WebUI migration gap in TODO §A. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * docs(daemon-ui): add developer guide + migration cookbook (PR-I) Closes the final "Documentation" item in PR #4353's TODO §A. Brings the unified daemon UI surface to ~95% SDK-side completion. ## Files added - `docs/developers/daemon-ui/README.md` — full API reference - Three-layer model (normalizer → reducer → render helpers) - Quick start with idiomatic event-loop pattern - Event taxonomy (28+ types categorized: chat-stream / session-meta / workspace / auth device-flow) - Render contract cookbook (markdown / HTML / plainText) - Tool preview taxonomy (13 kinds with use cases) - State selectors (currentTool / approvalMode / toolProgress / ordering) - Cancellation propagation explanation - Time semantics (eventId > serverTimestamp > clientReceivedAt precedence) - Adapter conformance usage - ErrorKind dispatch pattern - Tool provenance dispatch pattern - Forward-compat principles - `docs/developers/daemon-ui/MIGRATION.md` — adapter author migration cookbook - Step-by-step recommended adoption order (9 steps, value-ranked) - Before/after code examples for each step - Backward-compat checklist (everything is additive — no breaking changes) - Cross-references to PR-A through PR-H commits ## Roadmap PR-I of the unified follow-up to PR #4328. Documentation-only — no code changes; no tests affected. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): address review feedback * fix(daemon-ui): address review hardening feedback * fix(daemon-ui): handle resync-required events * feat(sdk/daemon-ui): consume daemon-side subagent nesting context (PR-K) Closes the SDK-side gap for §B1 in PR #4353's TODO list. PR-E originally deferred subagent nesting because daemon-side parent-context wasn't yet stamped on tool_call events. After the rebase onto current daemon_mode_b_main, source verification confirms the daemon now emits `tool_call._meta.parentToolCallId` + `tool_call._meta.subagentType` via `SubAgentTracker.getSubagentMeta()` (core), so the SDK side is unblocked. ## Schema additions (additive, forward-compat-safe) `DaemonUiToolUpdateEvent`: - parentToolCallId?: string — toolCallId of the parent Task / delegation - subagentType?: string — sub-agent type label (e.g. 'code-reviewer') `DaemonToolTranscriptBlock`: - parentToolCallId?: string — mirror of event field - subagentType?: string — mirror of event field - parentBlockId?: string — pre-resolved by reducer when parent already in state, so renderers don't re-correlate ## Normalizer wiring `normalizeToolUpdate` checks both top-level and `_meta` for parentToolCallId + subagentType (fallback chain mirrors how provenance/serverId are read). Top-level tool calls without sub-agent context omit the fields cleanly. ## Reducer behavior - New tool block: resolves `parentBlockId` from `toolBlockByCallId` at create time. Out-of-order arrival (child before parent) leaves `parentBlockId` undefined — selectors fall back to `parentToolCallId` lookup. - Existing tool block update: adopts parent context if not yet correlated, never overwrites established correlation (handles the flow where SubAgentTracker activates after the initial tool_call). ## New public selectors - selectSubagentChildBlocks(state, parentToolCallId): returns the array of tool blocks invoked inside a given parent delegation - isSubagentChildBlock(block): type guard for "this tool block came from a sub-agent" Both exported from @qwen-code/sdk/daemon root + ui/index. ## Forward-compat properties - Top-level tool calls (no sub-agent) work identically as before - Trimmed parent blocks: child fallback to undefined parentBlockId - Daemon emits both fields together; SDK reads independently to tolerate partial future stamping ## Test coverage (129/129 pass, +5 new tests) - Extract parentToolCallId + subagentType from `_meta` - Top-level tool calls have undefined parent fields (forward-compat) - Reducer correlates parentBlockId at create time - Reducer adopts parent context on later update (out-of-order arrival) - isSubagentChildBlock discriminator ## Roadmap PR-K of the unified follow-up to PR #4353. Closes §B1 (subagent nesting) in the TODO declaration; daemon-side already shipped on `daemon_mode_b_main` via SubAgentTracker (core). Remaining TODO §B / §D items still depend on further daemon/Core work: - §B2 `tool.progress` event type (daemon emit pending) - §D MessageEmitter multimodal echo + HistoryReplayer inlineData/fileData (core change pending) Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): PR-K self-review hardening — back-fill / trim / self-ref / docs Multi-round self-review of PR-K (d8375fe46) surfaced two real bugs, a few defensive gaps, and missing docs/fixture coverage. All addressed in one commit. ## Bugs fixed ### Bug 1 — `parentBlockId` never back-filled for out-of-order arrival Original PR-K resolved `parentBlockId` only at child create time, which broke this flow: 1. Child arrives WITH parent stamp → block created with `parentToolCallId` set, `parentBlockId` undefined (parent not in state yet) 2. Parent arrives later → block created, `toolBlockByCallId` indexed 3. Subsequent child updates: existing-block branch only ran the back-fill inside `!existing.parentToolCallId`, which is false (we already adopted the stamp in step 1). `parentBlockId` stayed undefined forever. Fix: separate the two correlations. - existing-block update: independently back-fill `parentBlockId` whenever `parentToolCallId` is set and `parentBlockId` is missing - new-block create: scan existing children whose `parentToolCallId` matches the new block's `toolCallId` and back-fill their `parentBlockId`. Cheap O(n) over current blocks. ### Bug 2 — dangling `parentBlockId` after trim `trimTranscriptState` reset `toolBlockByCallId[id]` to the trimmed sentinel for evicted blocks but did NOT walk surviving children to null their `parentBlockId` references. Renderers walking `blockIndexById.get(parentBlockId)` would get undefined, with no "why" signal. Fix: post-trim, walk remaining tool blocks; if `parentBlockId` references an id not in `keptIds`, null it. `parentToolCallId` stays (survives trimming so selector-keyed queries still work). ## Defensive hardening - **Self-reference guard** (normalizer): drop `parentToolCallId === toolCallId` before it reaches the reducer. Daemon should never emit this, but defending costs nothing. - **Selector docstring**: clarify `selectSubagentChildBlocks` returns **direct** children only; document cycle / depth-cap responsibility for renderers walking up the chain. - **Cosmetic**: remove redundant `as DaemonToolTranscriptBlock` cast in `isSubagentChildBlock` (TypeScript already narrows after `block.kind === 'tool'` on the discriminated union). - **Alphabetical**: move `isSubagentChildBlock` re-export to correct position in both `daemon/index.ts` and `daemon/ui/index.ts`. ## Docs + conformance gaps closed - `README.md` — new "Sub-agent nesting (PR-K)" section with full reducer behavior, out-of-order handling note, recursive walk example, cycle-defense note. - `MIGRATION.md` — new step 8a with before/after for nested rendering. - `conformance.ts` — new `subagent-nesting` fixture covering parent + nested child via `tool_call._meta`. Markdown-safe phrases chosen (markdown escapes `-` so titles cannot be substring-matched as-is). ## Test coverage (+5 tests, 134/134 pass) - Self-reference dropped in normalizer - Back-fill on out-of-order parent arrival (child first, parent after) - Back-fill on later child update when parent now exists - Dangling `parentBlockId` nulled after parent trimmed - New `subagent-nesting` conformance fixture passes SDK reference adapter ## Side-effect verification Verified no regressions: - Cancellation propagation still cancels parent + children together (iterates `toolBlockByCallId`, which includes both) - Render contract unchanged (`daemonBlockToMarkdown` etc. project per block, no nested awareness required) - No serializer to update - `selectTranscriptBlocksOrderedByEventId` unaffected (parent-agnostic) Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): permission block trim contract — wenshao review Addresses both items from wenshao's review on PR #4353: ## Critical — resolvePermissionBlock missing TRIMMED guard The sibling `upsertPermissionBlock` (transcript.ts:544) correctly returns early when `existingId === TRIMMED_PERMISSION_BLOCK_ID`, but `resolvePermissionBlock` (transcript.ts:581) had no such guard. When `maxBlocks` trimming evicted a pending permission request, a subsequent `permission.resolved` event would: 1. Fail the `getWritableBlockById` lookup (sentinel is not a real block id) 2. Fall through and create a brand-new orphan resolution block This wasted a block slot, accelerated further trimming, and silently broke the trimmed-block contract that the request-side guard establishes. Fix: mirror the request-side guard. Read the index entry up front, return early on the sentinel. ## Suggestion — permissionBlockByRequestId grows unboundedly `trimTranscriptState` writes `TRIMMED_PERMISSION_BLOCK_ID` for evicted permission requests but never deletes those entries. Unlike the tool side (which calls `pruneTrimmedToolIndexes` post-trim), the permission index grew without bound in long sessions. Fix: add `pruneTrimmedPermissionIndexes` analogous to the tool-side helper. Caps the sentinel set at `maxBlocks` entries; older entries are deleted (any later resolution event still drops cleanly via the new Critical guard). ## Tests - Updated existing `keeps orphan permission resolutions visible after request trimming` test to encode the corrected contract (drops silently instead of creating an orphan). Test rename: "drops resolution for trimmed permission requests (wenshao Critical)". - New `Suggestion: pruneTrimmedPermissionIndexes caps the trimmed sentinel set` test verifies the cap. Total: 136/136 tests pass, SDK + WebUI typecheck green. ## Side-effect verification - `upsertPermissionBlock` already had the equivalent guard — no asymmetry remains. - `pruneTrimmedPermissionIndexes` only touches entries holding the sentinel; live permission blocks are unaffected. - Selectors over `state.blocks` (e.g. `selectPendingPermissionBlocks`) iterate the block array, not the index — unaffected by cap. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): address wenshao + doudouOUC inline reviews (2026-05-23) Addresses the 13 inline review comments from wenshao (6) and doudouOUC (7, one overlap) on the 2026-05-23 review round. ## Critical / Important ### sanitizeUrls not threaded through HTML preview path (doudouOUC) `daemonBlockToHtml` for tool blocks called `daemonToolPreviewToPlainText` which didn't accept `opts` — when callers set `sanitizeUrls: true`, the markdown path stripped auth tokens but the HTML path leaked them into the DOM. Now: helper accepts opts, threads through `web_fetch.url` and `image_generation.thumbnailUrl`. ### enrichToolDetailsWithPreview overwrote rawOutput (doudouOUC) The webui adapter replaced structured `rawOutput` with a markdown summary string when `enrichDetails: true`. Downstream `ToolCallData` consumers may branch on the shape (object vs string) and break. Plus the actual tool output was silently dropped. Fix: keep `rawOutput` verbatim, surface markdown via a new optional `previewMarkdown` field added to `ToolCallData`. ### transcriptBlockToTerminalText zero test coverage (wenshao) Added 12 tests covering each `switch` branch (user / assistant / thought / tool / shell stdout+stderr / permission unresolved+resolved / status / debug / error) plus the unknown-kind degradation path. Verified `assertNever` returns a graceful error line (does NOT throw) — wenshao's reviewer was slightly wrong on the throw claim but coverage gap was real. ### selectTranscriptBlocksOrderedByEventId no memoization (wenshao) Selector was called from React `useSyncExternalStore` and re-sorted on every dispatch — including sidechannel-only events that don't touch blocks. Added WeakMap cache keyed on `state.blocks` reference; the reducer preserves the same array reference for non-block-mutating events, so the cache hits across renders. ### selectSubagentChildBlocks O(n) per call (wenshao) Naive `state.blocks.filter()` was O(n) per call; rendering a tree with m parents made it O(n*m). Built a memoized reverse index keyed on `state.blocks` reference (WeakMap of parentToolCallId → DaemonToolTranscriptBlock[]). Each lookup now O(1) after first call. ### Test file TS errors at root tsc (wenshao) Fixed multiple TS errors in `daemonUi.test.ts` flagged by root `tsc --noEmit`: - Added `DaemonTranscriptState` + `DaemonUiEvent` imports - `block.content` access via `as Array<Record<string, unknown>>` cast - `delete` on globalThis property via narrower interface cast - `debug?.text` via `DaemonUiEvent & { text: string }` narrowing (Extract on union with `'status' | 'debug'` literal would resolve to never) - 6 occurrences of index-signature access via bracket notation - `raw: null` added to 3 `DaemonUiPermissionOption` literals (required field) - Explicit type annotations on conformance-suite `renderToText` params Note: `webui/src/daemon/transcriptAdapter.test.ts` shows residual "clientReceivedAt does not exist" errors at root tsc, but this is environmental — the resolution trace shows `@qwen-code/sdk/daemon` crossing into a sibling worktree's stale dist via shared workspace node_modules. In a single-worktree CI checkout this resolves cleanly. ## Suggestions (cleanups) ### Hoist asDaemonErrorKind double-eval (doudouOUC) `session_died` + `stream_error` cases each computed `asDaemonErrorKind` twice in the conditional spread (predicate + value). Hoisted to const, no functional change. ### renderToolHeader bypassed opts (doudouOUC) Forwarded `opts` so `maxFieldLength` is honored for tool title / toolName / toolKind. ### isSensitiveKey duplicates (doudouOUC) Removed duplicate `endsWith('accesskey')` / `endsWith('secretkey')` checks and the redundant exact-match `privatekey` (already covered by `endsWith`). ### propagateCancellationToInFlightTools iterated trimmed (wenshao) Filter `TRIMMED_TOOL_BLOCK_ID` sentinels up front. Avoids redundant index dereferences in long sessions with many historical tools. ### toolProgress shallow clone (doudouOUC + wenshao) `cloneTranscriptState` outer `...state` spread shared inner `{ ratio?, step? }` references between snapshots. Once `tool.progress` event handlers start mutating in place, the prior snapshot would leak. Deep-clone the inner records now (cost bounded by in-flight tools, small). ### isDeviceFlowErrorKind closed set (wenshao + doudouOUC) Both reviewers suggested strict validation. We INTENTIONALLY kept lenient pass-through — the public type `DaemonAuthDeviceFlowSdkErrorKind` explicitly includes `(string & {})` as a forward-compat escape hatch (existing test `keeps future auth_device_flow_failed errorKind values observable` enforces this). Now expose `KNOWN_DEVICE_FLOW_ERROR_KINDS` as documentation and explain the design in the JSDoc. ## Validation | | | |---|---| | SDK tests | 148/148 pass (+12 terminal coverage + assorted hardening) | | SDK typecheck | clean | | WebUI typecheck | clean | ## Side-effect verification - WeakMap memos invalidate correctly: reducer creates a fresh `state.blocks` reference only on block-mutating events. Sidechannel events reuse the same reference. - `previewMarkdown` is optional and additive on `ToolCallData`; consumers ignoring it are unaffected. - `sanitizeUrl` is called only when `opts.sanitizeUrls === true` in HTML path; default behavior unchanged. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): wenshao glm-5.1 review — lazy COW + lint + memo verification Addresses the 6 inline comments from wenshao's 2026-05-23 13:03 CHANGES_REQUESTED review. ## Real fix — WeakMap memoization actually works now (Suggestion #2) The earlier `sortedBlocksCache` / `childrenIndexCache` WeakMaps keyed on `state.blocks` reference, but `cloneTranscriptState` did `blocks: [...state.blocks]` eagerly — every dispatch produced a fresh array, so the caches never hit. The JSDoc claim "memoize across renders that don't touch blocks" was misleading. Fix: lazy copy-on-write. - `cloneTranscriptState` now shares `blocks` + `blockIndexById` by reference (no eager copy). - New `takeBlocksOwnership(state)` performs the array copy at the first mutation; subsequent mutations in the same dispatch are no-ops (tracked via module-level `ownedBlocks: WeakMap<State, blocks>`). - `appendBlock`, `getWritableBlockById`, and `trimTranscriptState` all take ownership before mutating. Result: sidechannel events (approval mode change, session metadata, workspace events, auth device-flow, etc.) preserve `state.blocks` identity across dispatches. The WeakMap caches actually hit now — verified by new test `selectTranscriptBlocksOrderedByEventId returns the same array reference for sidechannel-only events`. ## Lint Criticals (3) — readonly array syntax `ReadonlyArray<T>` → `readonly T[]` per `@typescript-eslint/array-type`: - `KNOWN_DEVICE_FLOW_ERROR_KINDS` satisfies clause - `EMPTY_CHILD_LIST` - `selectSubagentChildBlocks` return type ## Suggestion #1 — shallow copy from selectSubagentChildBlocks Return `[...cached]` so accidental in-place mutation (e.g., caller calling `.sort()` on the result) cannot corrupt the WeakMap-cached children index for other consumers sharing the same `state.blocks` snapshot. ## Suggestion #6 — KNOWN_DEVICE_FLOW_ERROR_KINDS sync test Added test `only contains canonical device-flow error kinds` — runtime assertion that guards against the array being silently emptied. The `as const satisfies readonly DaemonAuthDeviceFlowSdkErrorKind[]` at the declaration site already enforces type-level membership; this test adds a stable count check. ## Test coverage (+4 new tests, 152/152 pass) - `selectTranscriptBlocksOrderedByEventId` preserves array identity across sidechannel-only events (memo hit verification) - `selectSubagentChildBlocks` preserves WeakMap entry across sidechannel dispatches - `selectSubagentChildBlocks` returns shallow copy (caller mutation doesn't corrupt cache) - `KNOWN_DEVICE_FLOW_ERROR_KINDS` membership + count assertions ## Side effects - Block property mutations still leak across snapshots (pre-existing — the original eager copy was also a shallow array copy with shared block refs). Not introduced by this change; documented in `getWritableBlockById` comments. - All existing block-mutating tests pass — `takeBlocksOwnership` produces the same observable result as eager copy, just deferred to first mutation. Validation: - SDK tests: 152/152 pass - SDK typecheck: clean - WebUI typecheck: clean Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): forward opts in daemonBlockToPlainText tool case wenshao review 4350741340 (2026-05-23 13:00): the prior doudouOUC review fixed only the HTML path; the plainText tool case still called `daemonToolPreviewToPlainText(block.preview)` without `opts`, so `sanitizeUrls` + `maxFieldLength` were silently ignored when consumers used the plain-text projection (logs, clipboard, terminal mirroring). Symmetric fix to the HTML path (line 509). Added test verifying token stripping reaches `web_fetch.url` via plainText path. Validation: 153/153 SDK tests, SDK + WebUI typecheck clean. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): address wenshao 2026-05-23 reviews (3 Critical + 8 Suggestion + 1 false-positive) Walks all 22 inline comments from wenshao's 13:00-14:56 burst plus doudouOUC's APPROVED-with-suggestion. 11 real fixes applied; 1 reverted after gate-check; remaining items either already addressed in prior commits (stale) or are test-only coverage gaps now filled. ## Security / Correctness Criticals (real) ### sanitizeUrl strips Basic Auth (R2 #1) `https://user:pw@host/...` previously passed through with userinfo intact, leaking secrets into rendered markdown / HTML / plaintext. `u.username = ''; u.password = '';` before serializing. ### thumbnailUrl protocol validation always-on (R2 #2) `javascript:alert(1)` in `![image](url)` survived when sanitizeUrls was false (the default). Added `ensureSafeImageUrl(url)` — protocol whitelist (http/https/data only) that runs unconditionally for image URL renderings. `sanitizeUrls: true` still wins for query-param + Basic Auth stripping. ### permission.resolved orphan after sentinel pruned (R1 #2) The prior trim-contract fix guarded `existingId === TRIMMED_*`. After `pruneTrimmedPermissionIndexes` deleted a sentinel (long sessions), `existingId` became `undefined`, bypassed the guard, and created an orphan. Reject `undefined || TRIMMED_*` together. ## Behavior Suggestions (real) ### Selective cancellation propagation (R2 #6) `assistant.done.reason` of `stream_ended` / `reconnected` are transport-layer signals — the daemon-side tool is still running and SSE replay will deliver the real terminal status. Marking in-flight tools cancelled caused a visible spinner-to-red flash on reconnect. Scoped propagation to `cancelled` || `error` only. ### awaitingResync diagnostics (R2 #3) State-resync latch silently dropped events with no signal. Added `console.warn` describing the dropped event type + last resync trigger so a stuck UI is debuggable. Latch behavior intentionally preserved — recovery is `store.reset()` on session reconnect. ### selectSubagentChildBlocks: freeze instead of copy (R1 #8) `[...cached]` per-call defeated React.memo / useMemo identity stability (every call produced a fresh array reference). Now freeze the cached arrays at build time in `getOrBuildChildrenIndex` and return the frozen reference directly — referential stability + mutation defense (strict-mode throws on `.length = 0` etc.). ### detectSubagentDelegation regex too broad (R3 #2) `(?:^|_)task$` falsely matched `edit_task` / `list_task` / `create_task` etc. — common tool names unrelated to delegation. Anthropic's Task tool is literally named `Task` (no prefix), so restricted bare-`task` to whole-name only: `^task$`. `delegate` / `subagent` / `spawn_task` keep the `^|_` prefix. ### memoryChanged bytesWritten finite check (R3 #3) `typeof === 'number'` accepted NaN / Infinity. Use the existing `numberField` helper which calls `Number.isFinite(v)`. ### Multi-line blockquote prefix (R3 #1) `> *thought:* ${text}` only prefixed the first line; subsequent lines escaped the blockquote. Added `blockquote(raw)` helper that prefixes every line; applied to thought / debug / error renderings. ## Quality (real) ### plainText / HTML maxFieldLength parity (R1 #5/6/7, doudouOUC approve note) The tool block in markdown caps via `text()`; plaintext + HTML caps were missing on header fields, preview content, and permission block labels. Threaded `cap()` consistently across all three projections. ### isSensitiveKey dedup (R1 #10) Seven exact-match entries (`password` / `apikey` / `idtoken` / `sessiontoken` / `clientsecret` / `xapikey` / `xauthtoken`) were already subsumed by existing `endsWith` rules. Removed. ### Re-export DaemonUiStateResyncRequiredEvent (R2 #7) Other session-meta event types are exported from the daemon barrel; this one was missed. Added to both `daemon/ui/index.ts` and `daemon/index.ts`. ## Reverted after gate-check (false-positive) ### classifySelectedPermissionOption CANCELLED branch (R2 #4) Reviewer suggested adding `CANCELLED_PERMISSION_TERMS` check before the `completed` default, so `selected:cancel` would map to cancelled. This CONFLICTS WITH: - the design comment at the caller: "A selected option resolves the prompt even when the option id is a domain value like a city name or an option id containing deny/cancel" - the existing test `'cancelled-substring-permission'` with payload `'selected:abort'` expecting status `'completed'` The daemon expresses "user cancelled the prompt" via `cancelled` as the PRIMARY token (handled at the caller layer), not `selected:cancel` — the latter means "user picked an option labeled cancel", which is a successful selection. Reverted; added explanatory comment so the next review round doesn't re-flag it. ## Stale (already fixed) ### R1 #1 (daemonBlockToPlainText opts forwarding) Already fixed in d35cbb75a (2026-05-23 monitor pass for review 4350741340). No further action. ## Test coverage added - HTML web_fetch URL sanitization (sanitizeUrls + Basic Auth) - Image URL protocol validation when sanitizeUrls:false - HTML shell / permission / thought / debug / status block kinds - Trimmed-tool cancellation propagation (no throw + transport-layer no-cancel) - Late permission.resolved after sentinel prune (no orphan) - Frozen children-index identity stability + mutation guard - previewMarkdown preserves rawOutput as object (in webui adapter test file) ## Validation | | | |---|---| | SDK tests | **161/161** (was 153 → +8 new) | | WebUI tests | **9/9** (was 8 → +1 new) | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): tighten ensureSafeImageUrl to data:image/* only Audit follow-up (post-f5c54680f review pass): the previous `ensureSafeImageUrl` whitelist accepted any `data:` URI, which let `data:text/html,<script>alert(1)</script>` pass the protocol check. Modern browsers don't execute `<img src="data:text/html,...">`, but the comment claimed "never legitimate in `<img src>`" which slightly over-claimed the protection. Tighten the data: branch to require an `image/<subtype>` MIME prefix. Verified by a new test that covers: https (allow), data:image/png (allow), data:text/html (reject → '#'), javascript: (reject → '#'). Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): wenshao + doudouOUC R4 review batch Walks 6 wenshao items (delivered as 8 review submissions — 2 CHANGES_REQUESTED + 6 individual COMMENTED — but 6 distinct concerns) and 3 doudouOUC R4 nits. All 9 real issues addressed; no false-positives this round. ## Real Criticals ### awaitingResync recovery API (wenshao R4) `store.reset()` requires session-id change semantics — wrong shape for "same-session reconnect with SSE replay" recovery. Added explicit `store.clearAwaitingResync()` API. Latch is still set on receipt of `session.state_resync_required` (intentional one-way during replay window); consumers now have a clean path to clear after the replay stream drains. ### normalizeAuthDeviceFlowCancelled test coverage (wenshao R4) Coverage gap surfaced — happy path (valid deviceFlowId) and malformed fallback to debug both untested. Added 2 tests. ## Real Suggestions ### sanitizeUrl: AWS / Azure / GCP credential patterns The previous regex caught `x-amz-` and `x-goog-` headers + generic `signature` / `sig`, but missed: - `AWSAccessKeyId` (S3 presigned) - Azure SAS short codes (`sv` / `se` / `sr` / `sp` / `st` / `spr` / `sip` / `ss` / `srt` / `sig` / `skoid` / etc.) - GCP signed-URL `GoogleAccessId` + `Expires` (paired with credentials in signed URL contexts) Widened regex to include `aws|google|expires` prefixes + added explicit Azure-SAS Set check. ### detectFileDiff: `content` alias disambiguated `{ path, content }` was being classified as `file_diff` regardless of tool semantics — but the same shape is common for file_read assertions or search queries. Since detectFileDiff runs BEFORE detectFileRead in the detector chain, this caused mis-classification. Fix: restrict bare `content` to require either (a) write-intent tool name (write/create/edit/replace/save/update) OR (b) co-occurrence with `oldText`. Explicit `newText` / `new_text` / etc. still pass through unconditionally. Required adding `opts` to the `detectFileDiff` signature (callers already pass opts to siblings). ### detectFileRead: 0-based offset → 1-based range Type doc says `range: [startLine, endLine]` is 1-based inclusive. The offset+limit conversion produced 0-based output ([0, 9] for offset=0/limit=10), which displayed as "lines 0-9" — line 0 doesn't exist in 1-based. Convert at the detector: `[offset+1, offset+limit]`. Updated the matching test (which had encoded the 0-based bug as expected behavior). ### formatMissedRange — guard inverted / single-event ranges The naive `lastDeliveredId+1 .. earliestAvailableId-1` formula produced: - `gap === 0`: "missed 6-5" (inverted) - `gap === 1`: "missed 6-6" (single event shown as range) Added `formatMissedRange()` helper with explicit branches: - `last < first` → "no events lost (resync requested without gap)" - `last === first` → "missed 1 daemon event (id N)" - `last > first` → "missed daemon events X-Y" Applied in both `transcript.ts` (status block message) and `terminal.ts` (ANSI projection) — same formula was duplicated. ## doudouOUC R4 nits ### README errorKind list outdated Replaced `expired / transport / server / internal` with pointer to `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported constant — canonical list auto-stays-in-sync. ### README "10 scenarios" stale Was 10, became 11 with subagent-nesting. Removed the count and let the corpus be derived at runtime via `DAEMON_UI_CONFORMANCE_FIXTURES.length`. ### selectTranscriptBlocks danger post lazy-COW With state.blocks now shared across sidechannel snapshots, a misbehaving consumer doing `(state.blocks as DaemonTranscriptBlock[]).sort()` would poison every snapshot sharing the reference. Freeze the blocks array at the dispatch boundary in `reduceDaemonTranscriptEvents`. Internal reducer mutation goes through `takeBlocksOwnership` which copies before mutating, so the frozen reference is never modified in place. ## Validation | | | |---|---| | SDK tests | **162/162** | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): wenshao R5 review batch — Critical OAuth fragment leak + 10 more Walks 13 inline items from wenshao's 16:46-17:28 reviews. 11 fixed, 1 deduped (lint-no-console flagged in both reviews), 1 reverted/push-back (multi-part deny re-flags the same design-intent territory as R2 #4). ## Critical fixes ### sanitizeUrl: OAuth #fragment leak `sanitizeUrl` cleared query params and Basic Auth userinfo, but `u.toString()` preserved `u.hash`. OAuth 2.0 implicit grant puts `access_token=...` directly in the fragment (e.g., `https://app/#access_token=gho_xxx&token_type=bearer`); some Azure SAS variants similarly. Now `u.hash = ''` before serialize. For rendered output (markdown / HTML / plaintext), the fragment is client- state-only and dropping it removes the entire fragment-side leak surface. ### ESLint no-console on awaitingResync diagnostic Project lint forbids bare `console.*`. Added `eslint-disable-next-line no-console -- intentional diagnostic` per wenshao's suggestion. Behavior unchanged. ### normalizeAuthDeviceFlowCancelled test coverage (still missing post-R4) R4 added tests for one of the five device-flow normalizers; the `cancelled` variant was still uncovered. Added happy + malformed-payload tests. ## Behavior fixes ### Plaintext sanitizeTerminalText parity `daemonBlockToPlainText` + `daemonToolPreviewToPlainText` previously returned ANSI/bidi-control text verbatim, while markdown and HTML paths sanitized via `sanitizeTerminalText`. A daemon emitting bidi overrides survived clean to plaintext output — contradicting the "copy-paste / logs" JSDoc intent. Now routes every text field through `clean()` = `cap(sanitizeTerminalText(raw))`. ### blockquote helper applied to image_generation + subagent_delegation R3 added the helper for thought/debug/error but missed two preview markdown sites (`> ${text(preview.prompt)}` for image_generation, `> ${text(preview.task)}` for subagent_delegation). Multi-line prompts / tasks now stay inside the blockquote. ### Default unrecognized-event branch: single debug block Was emitting `status + debug` (2 blocks) per unknown event type. In long sessions where the daemon adds new types an older SDK doesn't recognize, this doubled block-consumption rate and accelerated `maxBlocks` trimming of real content. Now emit a single `debug` block that prefixes the event-type for adapters that want to pattern-match. ### writeIntent regex underscore-boundary aware R4's `content` alias gate-check used `\b` word boundaries, but `\b` doesn't match between `write` and `_` in `write_file` (both `\w`). Fixed to `(?:^|[_-])verb(?:$|[_-])` which catches the canonical `write_file` naming AND still rejects `prewrite_check`. Verb list extended per wenshao's suggestion (`overwrite`/`modify`/`patch`/`generate`). ### useDaemonPendingPermissions over-subscription Hook used `useDaemonTranscriptState()` which fires on every daemon event (text deltas, tool updates, sidechannel). Switched to `useDaemonTranscriptBlocks()` which only invalidates when the blocks array reference changes — block-mutating dispatches only, thanks to lazy COW. Same selector semantics, ~10x fewer renders in chat-heavy sessions. ### Conformance suite: try/catch adapter JSDoc promised "does not throw" but the loop wrapped adapter calls without try/catch. Buggy adapters aborted the whole suite instead of producing a structured `ConformanceFailure`. Now wrap; on throw, capture the error message in `renderedExcerpt: "[adapter threw: ...]"` and continue. ## Type / Quality fixes ### DaemonTranscriptState.blocks typed readonly Runtime contract is frozen (lazy-COW poison defense), but the type was mutable — consumers got runtime `TypeError` for in-place mutation instead of compile errors. Now `readonly DaemonTranscriptBlock[]` so mutation is caught at the type level. ### formatMissedRange exported / deduplicated Helper was duplicated inline between transcript.ts (full phrasing) and terminal.ts (terser phrasing). Exported from transcript.ts and reused in terminal.ts to prevent future drift. ## Push-back (false-positive — see reply) ### classifySelectedPermissionOption multi-part deny (`selected:deny:access_violation`) Re-flags the same `selected:X` design intent rejected in R2 #4. The caller comment explicitly states a selected option resolves the prompt even when the option id contains `deny`/`cancel`. The existing test `cancelled-substring-permission` (payload `selected:abort`, expected `completed`) codifies this. Daemon expresses true user-cancellation via the `cancelled` PRIMARY token, not `selected:cancel`. Not changing; reply directs to the same R2 #4 reasoning. ## Tests added (+10) - normalizeAuthDeviceFlowCancelled happy + malformed - sanitizeUrl OAuth fragment access_token rejected - sanitizeUrl AWS/GCP/Azure SAS credential params stripped - formatMissedRange no-gap / single-event / multi-event - detectFileDiff content alias rejected for read-like tools - detectFileDiff content alias accepted for write-like tools - writeIntent word boundaries (prewrite_check NOT matched) - conformance captures adapter throw - unrecognized event → single debug block - store.clearAwaitingResync clears latch ## Validation | | | |---|---| | SDK tests | **172/172** (was 162, +10) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): wenshao R6 — recovery flow chicken-and-egg + pending pointer Three Criticals from R6 review (4351217188) all pointing at real bugs introduced by R4/R5 work — not false positives. Fixes plus regression tests. ## Critical 1 — same-session reconnect never clears the latch When the daemon emitted `state_resync_required`, the reducer set `awaitingResync = true`. The webui provider dispatched `assistant.done { reason: 'reconnected' }` after re-attaching SSE but never called `store.clearAwaitingResync()`. Result: events flowed in on the fresh stream but every one got dropped by the `applyDaemonTranscriptEvent` passthrough guard. Transcript appeared permanently frozen with no diagnostic clue (the `console.warn` fired on each drop, but the user wouldn't necessarily check DevTools). Fix: in `DaemonSessionProvider.tsx`, after dispatching the synthetic `reconnected` `assistant.done`, check `awaitingResync` and clear it BEFORE the new SSE event loop starts. ## Critical 2 — updateCurrentToolPointer breaks on undefined status In `upsertToolBlock`, a new tool block is created with `status: event.status ?? 'pending'`. But `updateCurrentToolPointer` was called with raw `event.status` — when undefined, the function's own `if (status === undefined) return;` guard short-circuited without ever pointing at the new (visually-pending) block. Result: `selectCurrentTool` returned `undefined` for daemon events that omitted the explicit `status` field, while the block sat at "pending" in the UI — invisible to the current-tool selector. Fix: pass the EFFECTIVE status (`event.status ?? 'pending'`) so the pointer logic mirrors the actual stored status. ## Critical 3 — clearAwaitingResync flow chicken-and-egg The earlier (R4) JSDoc documented the recovery flow as: "re-subscribe with `Last-Event-ID: 0`, then call clearAwaitingResync after replay drains." But while the latch is true, EVERY non-passthrough event is dropped at `applyDaemonTranscriptEvent`. So during the replay drain, zero events made it into state, and clearing the latch afterward did nothing — transcript permanently empty. Correct flow: clear FIRST, then stream events. Updated JSDoc on both `types.ts` interface and `store.ts` impl to document this clearly. Added a regression test (`clearAwaitingResync AFTER dispatching events: events ARE dropped`) that pins the correct flow in code. ## Regression tests (+3) - `undefined status` creates pending block AND sets currentToolCallId - clear-then-dispatch ✓ events flow - dispatch-then-clear ✗ events dropped (correct flow documentation) ## Validation | | | |---|---| | SDK tests | **175/175** (was 172, +3) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | ## Note on doudouOUC heads-up #4469 (main → daemon_mode_b_main sync, 45 commits since 2026-05-19) will land soon. doudouOUC's note says rebase should be smooth (no daemon-ui surface conflicts). Will rebase on the cron's next pass after #4469 merges. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> * fix(daemon-ui): wenshao R7 — escapeMarkdownText covers `<` + details URL sanitization Two items from wenshao R7 (one inline Suggestion + one Verification-PASS finding). Both gate-checked as real; fixed. ## escapeMarkdownText: add `<` to escape set Markdown rendered through markdown-it with `html: true` would previously pass through raw `<img onerror>` / `<script>` from reviewer-untrusted metadata fields (tool title / toolKind / status / permission label / preview labels). The HTML render path already escapes via `defaultEscapeHtml`; this brings markdown to the same safety baseline. Note: `escapeMarkdownText` is only applied to metadata fields, NOT to assistant/user/thought body text (those are intentionally markdown content; escaping `<` there would mangle legitimate markdown). ## markdown tool details: sanitize URL credentials when sanitizeUrls:true `daemonBlockToMarkdown`'s `case 'tool':` branch appended `block.details` (serialized `rawInput` JSON) through `text()` which only handled ANSI/bidi. When `rawInput.url` contained credentials (Basic Auth in userinfo / OAuth in `#fragment` / signed-URL query params), the preview path correctly sanitized via `sanitizeUrl`, but the details dump leaked the raw URL. HTML + plaintext branches exclude details entirely, so they didn't leak. The asymmetry meant a consumer rendering markdown + relying on the R5 fragment-leak protection would still leak via details. Fix: added `sanitizeUrlsInText(text)` helper that regex-replaces every `https?://` URL in a string with its `sanitizeUrl(url)` form. Applied to `block.details` i…
1 parent 67216f0 commit e5f786a

21 files changed

Lines changed: 8531 additions & 110 deletions

File tree

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

0 commit comments

Comments
 (0)