qwen-code/docs/developers/daemon-ui
Shaojin Wen b314d01f2d
Some checks are pending
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:docker - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 1/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 2/3 (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none - shard 3/3 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 1/2 (push) Waiting to run
E2E Tests / E2E Test - macOS - shard 2/2 (push) Waiting to run
E2E Tests / channel-plugin E2E (nightly) (push) Waiting to run
E2E Tests / cron-interactive E2E (nightly) (push) Waiting to run
E2E Tests / web-shell Browser Regression (push) Waiting to run
fix(web-shell): stop rendering unrecognized daemon events in transcripts (#8812)
* fix(web-shell): stop rendering unrecognized daemon events in transcripts

The daemon UI normalizer projects any frame it has no case for into a
`debug` event carrying a raw JSON dump. webui's ChatViewer drops those
blocks, but Web Shell renders `status` and `debug` together as system
info, so every event kind the daemon ships ahead of the UI surfaces as
unreadable JSON in the middle of the conversation. This has been patched
per-symptom three times now: two string-prefix suppressions inside
`isIgnoredWebShellStatus`, plus #8790 for `usage_update`.

Give the normalizer's debug events a structured `debugReason` and let
Web Shell branch on it instead of pattern-matching text:

- `unrecognized_event` / `unrecognized_session_update` — the daemon runs
  ahead of this client; developer diagnostics, not conversation content.
  Web Shell no longer renders them.
- `malformed_payload` — a frame the client does know arrived unusable.
  That is a real defect signal, so it stays visible.

Debug events dispatched by clients themselves, such as Web Shell's own
model-switch summary, carry no `debugReason` and keep rendering.

The two `(unrecognized daemon event)` prefix checks are now covered by
`debugReason` and are removed; the `Model switched: ` check stays, since
`model.changed` projects to a `status` block rather than a debug one.

* fix(sdk): classify a discriminator-less session_update as malformed

Review of #8812 caught a hole in the new classification: `session_update`
payloads such as `{}` or `{ sessionUpdate: 42 }` reach the default branch
with `kind === undefined`, and stamping them `unrecognized_session_update`
made Web Shell hide the only diagnostic a malformed frame produces.
Reserve the unrecognized reason for a real unknown string kind.

Also update the top-level default-case comment, which still pointed
adapters at the debug text prefix, and add a reducer-level test proving
`debugReason` survives the UI-event → transcript-block boundary: the
normalizer tests inspect events and the Web Shell tests build blocks by
hand, so dropping the spread in transcript.ts would leave both green.

* fix(web-shell): keep filtering legacy debug blocks, tighten the reason split

Four review findings from #8812:

- `WebShellTranscript` is a public entry point taking already-projected
  blocks, so blocks from an SDK predating `debugReason` still arrive with
  no reason and started rendering again when the prefix checks were
  removed. Fall back to the stable ` (unrecognized daemon event): ` marker
  when no reason is present — which covers every unrecognized event type,
  not just the two previously suppressed by name. The old-shape fixture is
  restored (adding `debugReason` to it had hidden this path) and a
  dedicated legacy test now pins it.
- A whitespace-only discriminator is truthy, so `sessionUpdate: ' '` was
  classified unrecognized and hidden. Gate on `trim()`, matching the
  convention `getFirstString` already uses.
- Add the mirror invariant for the reducer: a client-dispatched debug
  event must produce a block with no `debugReason`. Defaulting the field
  in `appendStatusBlock` otherwise passes every other test while tagging
  the model-switch summary unrecognized.
- Guard the outermost public re-export. A type-only guard would not hold —
  vitest erases `export type` through esbuild and this package's tsconfig
  excludes `test/` — so ship the union as `DAEMON_UI_DEBUG_REASONS`,
  matching `DAEMON_ERROR_KINDS`, and assert it at runtime.

* fix(web-shell): suppress legacy usage_update/a2ui blocks with no debugReason

Follow-up verification on #8812 pointed out the marker fallback does not
close the original report. #8790 stopped the SDK inserting new
`usage_update` blocks, but `WebShellTranscript` renders whatever blocks its
caller passes, so a transcript persisted or projected before that still
holds them and the spam returns after upgrade.

The legacy `session_update` projection is `<kind>: <json>` with no marker to
key on, so match those by kind name instead. The list is closed on purpose —
`usage_update` and `a2ui`, the two known to have leaked — and requires the
`: {` shape, because a generic `<word>: {` rule would swallow legitimate
diagnostics. Blocks the normalizer classified still win on `debugReason`,
so `malformed_payload` and client-dispatched debug blocks stay visible.

Mutation-checked in both directions: dropping the fallback fails the legacy
test, and loosening the prefix to bare `usage_update:` fails the test that
pins prose and classified blocks staying visible.

* fix(web-shell): match the legacy projection shape, not a quoted marker

The legacy fallback was too broad in two ways, both reachable. It ran for
`status` blocks as well as `debug` ones, because this helper is called from
the shared `case 'status': case 'debug':` arm, and it matched the marker as
a substring anywhere in the text.

Probed at df0b757c3c: all four of these were dropped — a status line
quoting the marker, a legacy malformed payload relaying an upstream peer's
message that contains it, a client-dispatched summary quoting it, and a
status block whose text starts with `usage_update: {`. Exactly the
diagnostics this PR promises to keep.

Scope the text match to `debug` blocks, and anchor it to the whole legacy
projection (`<event-type> (unrecognized daemon event): <json>`) instead of
the bare marker. Classified blocks still win on `debugReason` before any of
this runs.

Mutation-checked: dropping the `kind === 'debug'` guard and restoring the
substring match each fail the new negative test.

* fix(web-shell): match legacy projections with non-object payloads

The anchored legacy pattern required the payload to start with `[`, `{` or
`"`, which only holds for objects and arrays. `DaemonEvent.data` is
`unknown`, and `stringifyJson` returns strings verbatim, serializes
primitives as `42` / `true` / `null`, and yields `''` for `undefined` — so
every non-object payload bypassed the compatibility fallback and rendered.

Drop the leading-character constraint. The event-type prefix plus the fixed
phrase, anchored at the start, is specific enough on its own, and the
negative cases for quoted markers and status blocks still pass.

Regression test covers object, array, string, number, boolean, null and
empty payloads. Mutation-checked: restoring the character class fails it.

* fix(web-shell): hide debug blocks by the unrecognized_ reason category

The debugReason filter enumerated the two current `unrecognized_*` values,
but the SDK contract this PR adds names reasons by category: `unrecognized_*`
is forward-compat noise to hide, `malformed_*` a defect signal to keep
visible. A reason a newer SDK adds would compile silently against the
two-literal comparison and render raw JSON again with both suites green.

Match the category prefix instead. Also drop the now-dead marker branch in
MessageList's mid-turn hide check: every block carrying that prefix is
filtered upstream in the adapter (reason-stamped via `debugReason`, legacy
via the anchored pattern), and the dedicated normalizer case emits a status
event keyed by `source`. Document `DaemonUiDebugReason` beside the sibling
closed enums in the daemon-ui docs, whose forward-compat bullet still
described the unstamped projection.

Regression test pins both directions of the category contract with reasons
outside the current enum.

---------

Co-authored-by: qwen-code-dev-bot <qwen-code-dev@service.alibaba.com>
2026-08-10 00:51:57 +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(web-shell): stop rendering unrecognized daemon events in transcripts (#8812) 2026-08-10 00:51:57 +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 block instead of a typed event (mirrored onto DaemonStatusTranscriptBlock for transcript consumers):

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.

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