Commit e5f786a
* 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>` → `<script>`)
- 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 `` 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
- docs/developers/daemon-ui
- packages
- sdk-typescript
- src/daemon
- ui
- test/unit
- webui
- src
- daemon
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
| 218 | + | |
| 219 | + | |
| 220 | + | |
| 221 | + | |
| 222 | + | |
| 223 | + | |
| 224 | + | |
| 225 | + | |
| 226 | + | |
| 227 | + | |
| 228 | + | |
| 229 | + | |
| 230 | + | |
| 231 | + | |
| 232 | + | |
| 233 | + | |
| 234 | + | |
| 235 | + | |
| 236 | + | |
| 237 | + | |
| 238 | + | |
| 239 | + | |
| 240 | + | |
| 241 | + | |
| 242 | + | |
| 243 | + | |
| 244 | + | |
| 245 | + | |
| 246 | + | |
| 247 | + | |
| 248 | + | |
| 249 | + | |
| 250 | + | |
| 251 | + | |
| 252 | + | |
| 253 | + | |
| 254 | + | |
| 255 | + | |
| 256 | + | |
| 257 | + | |
| 258 | + | |
| 259 | + | |
| 260 | + | |
| 261 | + | |
| 262 | + | |
| 263 | + | |
| 264 | + | |
| 265 | + | |
| 266 | + | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
| 333 | + | |
| 334 | + | |
| 335 | + | |
| 336 | + | |
| 337 | + | |
| 338 | + | |
| 339 | + | |
0 commit comments