qwen-code/docs/developers/daemon-ui
易良 b5577b7d11
fix(sdk): route unrecognized diagnostics onto a bounded transcript sidechannel (#9202)
* fix(sdk): route unrecognized diagnostics onto a bounded transcript sidechannel

Normalizer-classified unrecognized_event / unrecognized_session_update debug events no longer enter transcript blocks[]: they are mirrored onto a capped unrecognizedDiagnostics sidechannel instead. This stops them from finalizing a streaming assistant/thought block (which dropped a following assistant.usage frame) and from consuming the maxBlocks budget (which let repeated noise evict real conversation content). malformed_payload diagnostics and client-dispatched debug events keep their existing block semantics.

* fix(sdk): align browser bundle budget

* fix(sdk): close the sidechannel review round (#8823)

- export the sidechannel API through the daemon barrel
  (selectUnrecognizedDiagnostics, UNRECOGNIZED_DIAGNOSTICS_LIMIT,
  DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS + types) and pin the
  reachability in daemon-public-surface.test.ts
- restore the MAX_TEXT_BLOCK_LENGTH cap on sidechannel text, mirroring
  truncateText exactly (suffix fits within the cap)
- ship the unrecognized reason subset as a runtime const array and route
  by membership, so a new reason cannot fall through to appendStatusBlock
- copy the correlation fields createBase stamps (promptId, sourceRecordIds,
  branchRecordId, originatorClientId) onto sidechannel entries; drop the
  dead source/data switches
- un-fuse the budget-history comment chain in scripts/build.js
- update docs/developers/daemon-ui for the split routing
- tests: full entry shape, text cap, block-path debugReason counterpart,
  and a webui malformed_payload interleave sibling so the #7012
  flush-before-guard keeps a discriminating stimulus

* fix(sdk): address round-2 sidechannel review for #8823

- build.js: bump daemon browser bundle budget 191KB -> 192KB
  (195,591 bytes measured > 195,584 cap; build failed at head)
- webui: narrow the observer-mode debug guard so unrecognized_*
  diagnostics reach the reducer sidechannel; only block-path debug
  events are dropped
- webui: merge history-store unrecognizedDiagnostics in
  applyTranscriptHistory so paged-back sessions keep diagnostics
- transcript: extract truncateTextAtLimit shared by the block and
  sidechannel truncation paths
- transcript: reset unrecognizedDiagnostics on rewind alongside the
  sibling per-turn state resets
- types: rename DaemonUnrecognizedDiagnostic.receivedAt to
  clientReceivedAt (matches the sibling block projection)
- tests: reason-prefix conformance pin, rewind reset, narrowed guard,
  history pagination merge

* fix(webui): avoid flushing sidechannel diagnostics

* fix(sdk): preserve diagnostics across rewind

* fix(webui): dedupe sidechannel history records

* fix(webui): align the paging sidechannel test with the normalizer keys

The paging test added in e6b40e5c failed deterministically (webui
suite red, CI Test job red) for two reasons:

1. The fixtures stamped only _meta['qwen.session.recordId'], but the
   SDK normalizer's extractSourceRecordIds reads
   _meta.qwenTranscript.sourceRecordIds — no sidechannel entry ever
   carried sourceRecordIds, so the dedupe assertion could not pass and
   the new displayedRecordIds loop was never exercised by a passing
   test. Stamp BOTH keys, matching production replay frames
   (acp-bridge buildUpdateMeta) and the sibling dedupe test.
2. Cap arithmetic: LIMIT-1 live entries + 2 fresh history entries =
   LIMIT+1, so the newest-wins slice evicted record-old-1 which the
   test asserted present. Emit LIMIT-2 live events so the post-merge
   total lands exactly on the cap.

Also correct the post-merge index assertions: history entries come
first (old-1, old-2), then the deduped-once live overlap, then the
first live mystery event. Suite 506/506, eslint + prettier clean.

* fix(sdk): raise diagnostic sidechannel bundle budget

* fix(sdk): raise the daemon browser bundle budget to 198KB and pin the diagnostics selector

- The sidechannel routing + selector cost ~1037 B over the 197KB cap
  (bundle measured 201893 B), failing the browser-bundle size gate; bump
  MAX_DAEMON_BROWSER_BUNDLE_BYTES to 198 * 1024.
- Fold the rebase-residue 190→191→192 KB ledger entries into the accurate
  190→195→196→197→198 lineage so the next bump has one canonical history.
- Add a behavioral pin for selectUnrecognizedDiagnostics: it must return
  the routed sidechannel itself (toBe), discriminating a `return []` or
  shallow-copy regression that the typeof-only surface test cannot see;
  flip-verified.

* fix(sdk): reset the user pointer on sidechanneled diagnostics, share the routing predicate

appendUnrecognizedDiagnostic left activeUserBlockId untouched while the
replaced appendStatusBlock path reset it for every non-user block; a
later mergeable user.text.delta with no promptId stamp (e.g. a peer
client's $ <cmd> echo) then appended onto the earlier user block
across the diagnostic, collapsing two user turns into one and skewing
rewindTranscriptToUserTurn's kind==='user' turn indexing. Keep the
reset (assistant/thought pointers stay untouched, the point of the
sidechannel); witness test flip-verified red without the one-line reset.

Also export isUnrecognizedDiagnosticReason from types.ts next to
DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS and call it at all three
routing-guard sites (reducer, provider flush condition, provider drop
filter) so the #7012/#8823 guard pair classifies every debug event
against one source instead of three hand-written copies.

* fix(ci): prevent bite harness SIGPIPE

---------

Co-authored-by: yiliang114 <yiliang114@users.noreply.github.com>
2026-08-19 14:38:00 +00:00
..
MIGRATION.md feat(daemon): merge daemon-mode feature batch into main (#4490) 2026-06-12 00:34:49 +08:00
README.md fix(sdk): route unrecognized diagnostics onto a bounded transcript sidechannel (#9202) 2026-08-19 14:38:00 +00:00
sidebar-customization.md feat(web-shell): Add sidebar customization API for branding, navigation, session actions, and footer (#7379) 2026-07-21 11:20:51 +00:00

Daemon UI SDK — Developer Guide

The @qwen-code/sdk/daemon subpath ships shared UI primitives for daemon clients. The current adoption target is web chat and web terminal; native local TUI, channel, and IDE integrations keep their existing default paths while the daemon UI contract stabilizes. This guide covers the API surface introduced by PR #4353 (the unified follow-up to PR #4328's shared UI transcript layer).

Three-layer model

Daemon SSE wire (NDJSON envelopes)
   │
   ▼
normalizeDaemonEvent(envelope) → DaemonUiEvent[]
   │
   ▼
reduceDaemonTranscriptEvents(state, events) → DaemonTranscriptState
   │                                            { blocks, currentToolCallId,
   │                                              approvalMode, toolProgress, ... }
   ▼
daemonBlockToMarkdown(block) / ToHtml / ToPlainText  ← your renderer plugs here
  • Normalizer: takes raw daemon SSE envelopes, returns typed UI events
  • Reducer: accumulates events into a transcript state machine
  • Render helpers: project state blocks to renderable strings

Quick start

import {
  DaemonSessionClient,
  createDaemonTranscriptStore,
  normalizeDaemonEvent,
  daemonBlockToMarkdown,
  selectCurrentTool,
  selectApprovalMode,
} from '@qwen-code/sdk/daemon';

const session = await DaemonSessionClient.createOrAttach(client, {
  workspaceCwd,
});
const store = createDaemonTranscriptStore();

for await (const envelope of session.events({ signal })) {
  const events = normalizeDaemonEvent(envelope, {
    clientId: session.clientId,
    suppressOwnUserEcho: true,
  });
  store.dispatch(events);
}

// Read state from any subscriber
store.subscribe(() => {
  const state = store.getSnapshot();
  const currentTool = selectCurrentTool(state);
  const mode = selectApprovalMode(state);
  const markdown = state.blocks.map(daemonBlockToMarkdown).join('\n\n');
  myRenderer.render({ markdown, currentTool, mode });
});

Event taxonomy (28+ types)

DaemonUiEvent is a discriminated union of all UI-facing events:

Chat-stream events

Event When
user.text.delta User message chunk arrives from daemon
assistant.text.delta Assistant streaming chunk
assistant.done Prompt completion (from sendPrompt resolve)
thought.text.delta Agent reasoning chunk
tool.update Tool call lifecycle (running / completed / cancelled)
shell.output Shell tool stdout/stderr chunk
permission.request Tool needs user authorization
permission.resolved Permission decision arrived
model.changed Session model switched
status / debug / error Status / debug / error blocks

Session-meta events (PR-A)

Event When
session.metadata.changed Session title / display name updated
session.approval_mode.changed Mode toggled (plan / default / yolo / auto-edit)
session.available_commands Slash command list refreshed

Workspace events (PR-A, Wave 3-4)

Event When
workspace.memory.changed QWEN.md / memory file modified
workspace.agent.changed Sub-agent created / updated / deleted
workspace.tool.toggled Builtin tool enabled / disabled
workspace.initialized qwen init completed
workspace.mcp.budget_warning MCP child count approaching cap
workspace.mcp.child_refused MCP server refused due to budget
workspace.mcp.server_restarted Manual MCP restart succeeded
workspace.mcp.server_restart_refused Manual restart blocked

Auth device-flow events (PR-A, Wave 4 OAuth)

auth.device_flow.{started,throttled,authorized,failed,cancelled}

Each carries the daemon's deviceFlowId. Failed events carry a closed-enum errorKind (closed enum — see KNOWN_DEVICE_FLOW_ERROR_KINDS exported from @qwen-code/sdk/daemon for the canonical list, currently: expired_token / access_denied / invalid_grant / upstream_error / persist_failed / not_found_or_evicted).

Render contract (PR-D)

Three projection helpers, one preview helper. All discriminate on block.kind or preview.kind:

daemonBlockToMarkdown(block, { sanitizeUrls?, maxFieldLength?, locale? })
daemonBlockToHtml(block, { sanitizer?, ...renderOpts })
daemonBlockToPlainText(block, renderOpts)
daemonToolPreviewToMarkdown(preview, renderOpts)

Cookbook: render a transcript to markdown

const markdown = state.blocks
  .map((b) => daemonBlockToMarkdown(b, { sanitizeUrls: true }))
  .join('\n\n');

Cookbook: render to sanitized HTML for SSR

import DOMPurify from 'dompurify';
import MarkdownIt from 'markdown-it';
const md = new MarkdownIt();

const html = state.blocks
  .map((b) => {
    // Two-stage pipeline: markdown → HTML → DOMPurify
    const rawHtml = md.render(daemonBlockToMarkdown(b));
    return DOMPurify.sanitize(rawHtml);
  })
  .join('\n');

Or use the built-in conservative HTML renderer (no markdown parsing, just HTML escape):

const html = state.blocks
  .map((b) => daemonBlockToHtml(b, { sanitizer: DOMPurify.sanitize }))
  .join('\n');

Cookbook: copy-paste plain text

const plain = state.blocks.map(daemonBlockToPlainText).join('\n');
navigator.clipboard.writeText(plain);

Tool preview taxonomy (13 kinds)

Kind Surface
ask_user_question Multi-choice question with options
command Bash-style command + cwd
file_diff File edit with oldText/newText or patch
file_read Path + optional line range
web_fetch URL + HTTP method
mcp_invocation MCP server + tool + args summary
code_block Language-tagged code snippet
search Query + result count + top results
tabular Columns + rows (capped at 50, truncation flagged)
image_generation Prompt + optional thumbnail URL
subagent_delegation Agent name + task
key_value Generic label/value rows
generic Fallback summary

Each has a daemonToolPreviewToMarkdown projection. Custom renderers can dispatch on preview.kind for rich per-type display (file diff with syntax highlighting, MCP server badge, image thumbnail, etc.).

State selectors (PR-E)

selectCurrentTool(state); // → DaemonToolTranscriptBlock | undefined
selectApprovalMode(state); // → 'plan' | 'default' | 'auto-edit' | 'yolo' | undefined
selectToolProgress(state, toolCallId); // → { ratio?, step? } | undefined
selectPendingPermissionBlocks(state); // → ReadonlyArray<DaemonPermissionTranscriptBlock>
selectTranscriptBlocks(state); // → ReadonlyArray<DaemonTranscriptBlock>
selectTranscriptBlocksOrderedByEventId(state); // sorted by daemon-monotonic id

// PR-K — sub-agent nesting
selectSubagentChildBlocks(state, parentToolCallId); // direct children only
isSubagentChildBlock(block); // type guard: was this tool invoked inside a sub-agent?

currentToolCallId is automatically maintained by the reducer:

  • Set when a tool enters in-flight status (running / in_progress / pending / confirming)
  • Cleared when tool enters terminal status (completed / failed / cancelled / etc.)
  • Unknown statuses leave it untouched (forward-compat)

Cancellation propagation (PR-E)

When assistant.done.reason === 'cancelled', the reducer walks every in-flight tool block and force-sets its status to 'cancelled'. Daemon does not guarantee a terminal tool_call_update for every in-flight tool when the parent prompt is cancelled — this propagation prevents UI spinners from spinning forever.

Sub-agent children are cancelled together with their parent because cancellation iterates every in-flight tool block in toolBlockByCallId, not just the current pointer.

Sub-agent nesting (PR-K)

When the main agent delegates to a sub-agent (the Task tool, or equivalent), the daemon stamps parentToolCallId and subagentType on the child tool calls via tool_call._meta. The reducer reads both and:

  • Mirrors parentToolCallId + subagentType onto DaemonToolTranscriptBlock
  • Resolves parentBlockId (the parent's transcript block id) when the parent block is already in state; otherwise leaves it undefined and back-fills when the parent block later appears

Out-of-order arrival (child before parent) is handled transparently. A child whose parent gets trimmed by maxBlocks keeps parentToolCallId for selector queries, but parentBlockId is nulled (the dangling id would no longer resolve via blockIndexById).

import {
  selectSubagentChildBlocks,
  isSubagentChildBlock,
} from '@qwen-code/sdk/daemon';

// Render a parent tool block, then walk children:
function renderToolBlock(state, block) {
  if (block.kind !== 'tool') return renderOther(block);
  const children = selectSubagentChildBlocks(state, block.toolCallId);
  return (
    <ToolBlock block={block}>
      {children.length > 0 && (
        <Indent>
          {children.map((c) => renderToolBlock(state, c))}
        </Indent>
      )}
    </ToolBlock>
  );
}

// Or filter top-level vs. nested at render time:
const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b));

selectSubagentChildBlocks returns direct children only. Walk recursively to render nested sub-agents (a sub-agent inside a sub-agent). Daemon does not emit cycles, but renderers walking up via parentBlockId should still detect them defensively (e.g., depth cap or visited set).

Self-references (parentToolCallId === toolCallId) are dropped by the normalizer before reaching the reducer.

Time semantics (PR-B)

interface DaemonTranscriptBlockBase {
  eventId?: number; // PRIMARY sort key — daemon-monotonic
  serverTimestamp?: number; // PREFERRED display — daemon-authoritative
  clientReceivedAt: number; // FALLBACK — local clock
  createdAt: number; // @deprecated alias for clientReceivedAt
}

Always sort by eventId (use selectTranscriptBlocksOrderedByEventId) when displaying long sessions. The daemon-monotonic cursor is preserved across SSE replay-after-reconnect; client clocks are not.

Always format display timestamps from serverTimestamp (with fallback to clientReceivedAt). Multiple clients viewing the same session see the same "5 minutes ago" only when both read from the daemon clock.

import { formatBlockTimestamp } from '@qwen-code/sdk/daemon';

const label = formatBlockTimestamp(block, {
  locale: 'zh-CN',
  timeZone: 'Asia/Shanghai',
  timeStyle: 'short',
});

Adapter conformance (PR-G)

Validate your adapter projects the SDK's reference corpus to semantically equivalent output:

import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon';

it('my adapter conforms to daemon UI corpus', () => {
  const result = runAdapterConformanceSuite({
    reduce: (events) => myReducer(events),
    renderToText: (state) => myRenderer(state),
  });
  expect(result.failed).toEqual([]);
});

The fixture corpus (DAEMON_UI_CONFORMANCE_FIXTURES) covers chat, tool lifecycle, file edits, MCP, permissions, MCP budget warning, cancellation, malformed payload redaction, OAuth, command updates, and sub-agent nesting. (Count is derivable at runtime — read DAEMON_UI_CONFORMANCE_FIXTURES.length.)

Format-agnostic — your adapter can render to ANSI / HTML / markdown / JSX; the framework only checks semantic content via expectedContains and expectedAbsent.

Error categorization (PR-A)

DaemonUiErrorEvent.errorKind is a closed-enum propagated from the daemon's typed-error taxonomy (when the daemon stamps it):

import type { DaemonErrorKind } from '@qwen-code/sdk/daemon';
// 'missing_binary' | 'blocked_egress' | 'auth_env_error' | 'init_timeout' | 'restore_timeout'
// | 'protocol_error' | 'missing_file' | 'parse_error' | 'budget_exhausted'

Renderers should branch on errorKind for actionable affordances:

function errorAffordance(errorKind?: DaemonErrorKind): React.ReactNode {
  switch (errorKind) {
    case 'auth_env_error': return <button>Re-authenticate</button>;
    case 'missing_file':   return <button>Choose file</button>;
    case 'blocked_egress': return <span>Network blocked  check proxy</span>;
    default:               return null;
  }
}

Tool provenance dispatch (PR-A)

DaemonUiToolUpdateEvent.provenance is a closed-enum (builtin / mcp / subagent / unknown). With serverId?: string when mcp. Use it for icon dispatch and badging:

function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode {
  switch (event.provenance) {
    case 'mcp':      return <McpIcon server={event.serverId} />;
    case 'subagent': return <SubagentIcon />;
    case 'builtin':  return <BuiltinIcon name={event.toolName} />;
    default:         return <GenericIcon />;
  }
}

The SDK has a mcp__<server>__<tool> naming heuristic fallback — even when daemon doesn't explicitly stamp provenance, MCP tools are detectable.

Debug reason categorization

DaemonUiStatusEvent.debugReason is a closed-enum the normalizer stamps when it projects a debug event instead of a typed event:

import type { DaemonUiDebugReason } from '@qwen-code/sdk/daemon';
// 'unrecognized_event' | 'unrecognized_session_update' | 'malformed_payload'

The canonical list is exported as DAEMON_UI_DEBUG_REASONS. Reason names are wildcard-named categories: unrecognized_* means the daemon sent a frame this SDK version has no case for — forward-compat noise, developer diagnostics rather than conversation content. malformed_* means a frame the SDK does know arrived with an unusable payload — a real defect signal.

Routing differs by category. unrecognized_* diagnostics are routed to the bounded unrecognizedDiagnostics sidechannel and never enter blocks[] (so they cannot finalize a streaming assistant/thought block or consume the maxBlocks budget). Read them with selectUnrecognizedDiagnostics; the cap is UNRECOGNIZED_DIAGNOSTICS_LIMIT and the routed subset is DAEMON_UI_UNRECOGNIZED_DIAGNOSTIC_REASONS. malformed_* diagnostics — and legacy blocks persisted before this split — stay in the transcript as DaemonStatusTranscriptBlocks, so block-level debugReason handling now applies to those only.

Renderers filtering blocks should branch on debugReason, not the debug text — the text prefix is diagnostic wording and changes without notice:

function hideDebugBlock(reason?: DaemonUiDebugReason): boolean {
  // Hide forward-compat noise by category so reasons a newer SDK adds are
  // covered automatically. Defect signals and client-dispatched debug
  // events (which carry no reason) keep rendering.
  return reason?.startsWith('unrecognized_') ?? false;
}

status events never carry a debugReason, and neither do debug events dispatched by clients themselves (e.g. Web Shell's model-switch summary) — both must keep rendering.

Forward-compat principles

Every layer in the daemon UI SDK follows the forward-compat principle: unknown values do NOT throw; they degrade gracefully.

  • Unknown daemon event types → debug event with the raw type name, stamped with an unrecognized_* debugReason and routed to the bounded unrecognizedDiagnostics sidechannel (see above)
  • Unknown tool status → currentToolCallId left untouched (no clear)
  • Unknown error kind → errorKind undefined (renderer falls back to text)
  • Missing serverTimestamp → falls back to clientReceivedAt
  • Unrecognized preview shape → generic kind with summary

This means SDK can ship ahead of daemon emission. PR-A's tool provenance heuristic, PR-B's three-location timestamp extraction, and PR-E's unknown-status preservation are all examples of "ready when daemon sends; safe when it doesn't."

Cross-references

  • PR #4328 — base PR with the shared UI transcript layer
  • PR #4353 — this PR (unified completeness follow-up)
  • Issue #3803 — daemon mode proposal
  • Issue #4175 — Mode B v0.16 implementation tracker