feat(daemon): add shared UI transcript layer#4328
Conversation
📋 Review SummaryThis PR adds a shared daemon UI layer for web chat/terminal clients, providing typed daemon event normalization, transcript state management, and React bindings through 🔍 General FeedbackPositive aspects:
Architectural observations:
Recurring themes:
🎯 Specific Feedback🔴 Critical
🟡 High
🟢 Medium
🔵 Low
✅ Highlights
Reviewer Verification Steps: # 1. Verify SDK daemon UI tests pass
cd packages/sdk-typescript && npx vitest run test/unit/daemonUi.test.ts
# 2. Verify type checking passes
cd packages/sdk-typescript && npx tsc --noEmit
# 3. Verify WebUI builds
cd packages/webui && npm run build
# 4. Verify browser-safe export (check for Node imports in daemon UI bundle)
grep -r "require\('fs'\)\|require\('path'\)\|from 'node:" packages/sdk-typescript/src/daemon/ui/
# 5. Smoke test the daemon import
node -e "import('@qwen-code/sdk/daemon').then(m=>console.log('exports:', Object.keys(m).sort().join(', ')))" |
ceb2ae5 to
94d66e0
Compare
|
Generated by GPT-5 model. Follow-up from the review pass:
Review triage / false positives:
Validation run locally after the amend:
|
94d66e0 to
b3e102b
Compare
b3e102b to
fe77066
Compare
chiga0
left a comment
There was a problem hiding this comment.
Multi-round architectural + correctness + performance review on the shared daemon UI transcript layer. Findings as inline comments anchored to specific lines.
Organized by priority:
P0 (blocking)
normalizer.ts:173—_meta.usagepiggyback forassistant.doneis fragilenormalizer.ts:241—toolCallIdfallback toevent.idcreates phantom tool blockstranscript.ts:345—cloneTranscriptStatedoes full deep clone on every event (perf)transcript.ts:152—getBlockByIdlinear scan on streaming hot pathtranscriptAdapter.ts:139—normalizeToolStatusdefault →'failed'breaks forward-compattranscriptAdapter.ts:151—cancelled→'completed'loses informationtranscriptAdapter.ts:115— error block rendered as assistant message with[System Error]prefix is a UX antipatternsdk-typescript/src/index.ts:14— UI re-exports pollute Node-targeted main entry
P1 (strongly recommend)
DaemonSessionProvider.tsx:256— single context value causes unnecessary re-rendersstore.ts:53— dispatch immediately notifies, no microtask batchingtranscript.ts:289— silent fallback to status when permission block was trimmedwebui/package.json:47—"@qwen-code/sdk": "0.1.7"exact-pin causes monorepo skewbuild.js:131—assertBrowserSafeBundleonly string-scans, no bundle size cap
Direction is right — sinking shared UI primitives (normalizer / reducer / store / terminal sanitization) into SDK is exactly what 02-architectural-decisions.md §8 calls for. Concerns are around concrete implementation: reducer performance under streaming, UX antipatterns in the webui adapter, and main-entry pollution.
Not posted as P0 but worth flagging: zero React component tests (DaemonSessionProvider.tsx 341 LOC, 0 tests) and zero transcriptAdapter.ts tests (159 LOC mapping logic). The 5 existing unit tests cover the reducer happy paths but leave the consumer surfaces entirely uncovered. Recommend adding before next PR builds on this.
Deleted DaemonTuiAdapter.{ts,test.ts} (-1874 LOC) is the right call — spike-only, superseded by PR#4202.
Generated with assistance from Claude Opus 4.7 (claude-opus-4-7) — code references verified against PR head fe77066d.
fe77066 to
feddf8e
Compare
99f2952 to
5f48a92
Compare
|
Generated by GPT-5 model. Latest review pass handled in
Local validation run:
|
5f48a92 to
5532d6c
Compare
wenshao
left a comment
There was a problem hiding this comment.
Additional findings (no specific diff line):
- [Critical]
DaemonSessionProvider.tsx(383 lines) has zero test coverage. Handles connection lifecycle, exponential backoff reconnect, event stream loop, error dispatch, and action bindings (sendPrompt, cancel, setModel, respondToPermission). - [Critical] 7 daemon event types in
normalizer.tsuntested:model_switched,model_switch_failed,session_closed,client_evicted,slow_client_warning,stream_error, and unknown-event fallback. - [Critical]
suppressOwnUserEchoandincludeRawEventnormalizer options have zero test coverage.
— DeepSeek/deepseek-v4-pro via Qwen Code /review
PR #4328 作为"统一渲染层"的功能完备度审视跳出单点 issue,从"统一事件 + 状态层是否真的让各 render 端不再感知 provider/模型差异"这个核心目标评估。 结论先放上面:当前 PR 提供的不是"完备的统一层",而是一个 v1 的薄壳。基础事件/状态/reducer/store 都对,但 事件分类只覆盖 daemon 已有事件的 ~50%,时间语义不标准,provider 差异在协议下层已基本抹平但向上还漏 3 处。完成度估算约 55%。 一、事件类型覆盖 vs daemon 实际产出 — 覆盖率约 50%PR 的 已正确归一化
未归一化(全部走
|
| Daemon 事件 | Wave | 后果 |
|---|---|---|
mcp_budget_warning |
W3 PR 14b | 关键 UI 看不到 MCP 预算告警 |
mcp_child_refused_batch |
W3 | 关键 MCP 失败原因不可见 |
mcp_server_restarted / _restart_refused |
W4 PR 17 | UI 不更新 MCP 状态 |
agent_changed |
W4 PR 16 | 切 agent 后 UI 不刷 |
approval_mode_changed |
W4 PR 17 | 关键 mode badge 不更新 |
memory_changed |
W4 PR 16 | memory dialog 不刷新 |
tool_toggled |
W4 PR 17 | 工具开关 UI 不同步 |
workspace_initialized |
W4 PR 17 | 初始化完成无信号 |
session_metadata_updated |
W2.5 PR 11 | session title 不更新 |
auth_device_flow_started/_throttled/_authorized/_failed/_cancelled |
W4 PR 21 | 关键 5 个 OAuth 设备码事件全丢到 debug |
available_commands_update |
Stage 1 | 关键 TUI 命令补全不能动态更新 |
这 12+ 类事件对 UI 当前是不透明 JSON 字符串。 任何渲染层想用都要跳过抽象层去看原始 rawEvent.data,统一层就形同虚设。
二、事件 schema 标准化程度
PR 的归一化做了形状统一,信息维度还差:
| 维度 | 当前 | 缺什么 |
|---|---|---|
tool.update.toolName |
自由 string | 没有 namespace — mcp:<server>:<tool> vs 内置工具 vs subagent 工具混在一起 |
tool.update.toolKind |
自由 string | 没有 closed enum;UI 要 dispatch file.edit/bash/search/web.fetch 全靠 string match |
tool.update.status |
自由 string | wenshao 的 DL8cA / DNHW4 评论指出 cancelled 处理就是这种自由文本带来的问题 |
permission.resolved.outcome |
自由 string selected:allow |
不是 typed 结构 |
error.text + recoverable |
free string + boolean | 丢失 daemon 自带的 7 值 errorKind 闭枚举(missing_binary/blocked_egress/auth_env_error/init_timeout/protocol_error/missing_file/parse_error)— UI 没法分类型显示"重试 auth"按钮 vs "检查路径"按钮 |
assistant.done.reason |
free string | provider 各自的 stopReason(end_turn/max_tokens/cancelled/tool_use)直接透传字符串 |
| Token usage / cost | 完全没有事件 | UI 无法显示 per-turn 成本 |
Progress(长任务进度) |
完全没有 | tool 跑 10 分钟,UI 只有"in_progress",没有进度 |
Citation / file reference |
完全没有 | 模型说"参考 docs/foo.md",UI 没法做 clickable |
Subagent 嵌套 |
完全没有 | agent 调 subagent,事件平铺在 transcript 里,无父子结构 |
三、时间定义是否标准 — 不标准,有具体风险
当前实现(transcript.ts)
createdAt: state.now,
updatedAt: state.now,其中 state.now = opts.now ?? Date.now(),在 cloneTranscriptState 时确定。
三个具体问题
- 客户端时钟 —
Date.now()在浏览器/CLI 上是本地时钟,跨客户端会漂移。同一个 session 两个客户端看,"5 分钟前"可能差几十秒到几分钟。 - 不是 daemon 权威时间 — daemon 那边的 SSE envelope 应该携带 server-side 时间戳,PR 没用。
- 没有 monotonic 保证 —
eventId是 daemon 单调的(Math.max守护),但createdAt不是。lateral reconnect 拿到 replay 事件时,replay 的state.now比原始事件晚几秒,UI 时间轴出现"未来的事件比现在还新",createdAtordering 不可靠。
应该长这样
interface DaemonTranscriptBlockBase {
id: string;
kind: ...;
eventId?: number; // 已有,daemon 单调 cursor — 用这个排序
serverTimestamp?: number; // 缺 — daemon 权威时间,从 SSE envelope 取
clientReceivedAt: number; // = 当前的 createdAt;用于"X 分钟前"展示但不参与排序
updatedAt: number; // 客户端时钟,允许 drift
}修复路径:
- daemon 侧 SSE envelope 加
_meta.serverTimestamp(需要 daemon PR) - SDK normalizer 提取到
DaemonUiEventBase.serverTimestamp - reducer 同时存
serverTimestamp(权威) 和clientReceivedAt(本地);ordering 用eventId优先,fallbackserverTimestamp - UI 时间展示用
serverTimestamp+ 客户端时区调整
当前 PR 的 state.now 模型对单客户端够用,对多客户端协作会出 bug。
四、Provider / 模型兼容性 — 大部分在 daemon 下层抹平,但向上还漏 3 处
已经抹平的(qwen --acp child 内做的)
| 差异点 | 抹平方式 |
|---|---|
OpenAI delta.content 字符串 vs Anthropic content 数组 |
都拆成 ACP agent_message_chunk + text content block |
OpenAI tool_calls 带 JSON-string arguments vs Anthropic tool_use 带对象 |
都转成 ACP tool_call 带结构化 rawInput |
Gemini parts: [Part] 多模态 |
ACP content blocks(text / image / audio / pdf / resource) |
| 各家 stop_reason 名字不同 | ACP stopReason 字符串收敛 |
| Tool result 各家返回形态不同 | ACP tool_call_update.rawOutput |
向上没抹平、UI 需要再处理的 3 处
1. 思考 / reasoning 信息 lossy
- Anthropic thinking blocks:有
thinkingtext +signature(密码学签名)+ 支持redacted_thinking - DeepSeek
reasoning_content:单独字段 - OpenAI o1 reasoning:hidden,只暴露 token count
- Qwen native:类 Anthropic
PR 全部归一化成 thought.text.delta 纯 text 累积。丢失 signature、redacted 标记、多 thinking 块的边界。
后果:做 chat history 上传给后续 turn 时,签名丢了,Anthropic 会拒绝(signature is required)。所以 UI 不能直接用 thought block 反向构造 API 调用 — 必须靠 daemon 内部 ChatRecordingService 保管完整原始。这条强约束需要在文档里写清楚。
2. Tool 命名空间不区分
ACP tool_call.name 是平的 string。UI 拿到:
Bash(内置)Edit(内置)mcp__github__create_issue(MCP server 拼名)Read(内置)但某 MCP 也可能提供同名Read
冲突时 UI 没办法区分。应该 daemon 侧在 tool_call event 里加 provenance: 'builtin' | 'mcp' | 'subagent' + serverId?: string。
3. 多模态 content 只取 text
utils.ts:getTextContent:
const text = value['text'];
return typeof text === 'string' ? text : '';只看 text 字段。如果 daemon emit 的 chunk 是 {type: 'image', source: {...}} 或 {type: 'resource', uri: '...'},直接被 normalizer 丢弃。多模态对话从 UI 视角看就消失了。
修复:getContent 应返回 discriminated union {kind: 'text'|'image'|'audio'|'resource', ...},reducer 把多模态块追加到 assistant block 的 parts: ContentPart[] 而不是 text: string。
五、Reducer / 状态机的设计缺漏
| 维度 | 当前 | 缺什么 |
|---|---|---|
| Tool ↔ shell 关联 | shell output 单独成 block | tool 调了 bash 产生 shell output 这一对应关系在 transcript 上断开 — UI 显示两个独立 block,视觉不连贯 |
| In-flight 状态 | 无 currentTool 字段 |
UI 想做"正在运行 X tool" header,要自己扫 blocks |
| Subagent 嵌套 | 平铺 | 主 agent 派 subagent 跑 5 个工具,5 个工具事件混在主 transcript |
| Plan / 多步任务 | 无 | Anthropic plan mode 的 step 1/2/3,没有 step kind |
| Cancellation propagation | assistant.done.reason = 'cancelled' |
被 cancel 时正在跑的 tool block status 不一定收到 'cancelled' update — 可能永远停在 'in_progress' |
| Token usage | 无 event | 见上文,完全缺失 |
| Progress | 无 event | 长任务无进度反馈 |
| Active client typing(多 client 协作) | 无 | 04-deployment-and-client.md 提到 P1 拓扑的"X is typing"没实现 |
六、Render 侧契约 — 只覆盖 terminal,web/IDE 是空的
PR 只提供:
daemonUiEventToTerminalText(event) // 单事件 → ANSI 文本
transcriptBlockToTerminalText(block) // 单块 → ANSI 文本缺:
daemonBlockToMarkdown(block)— web markdown render 共用daemonBlockToHtml(block, opts)— 直接用于 SSRdaemonBlockToPlainText(block)— copy-paste / 日志daemonToolPreviewToReactElement(preview)— React 组件层共享
后果:transcriptAdapter.ts 159 行的 daemonTranscriptToUnifiedMessages 是 webui 自己写的;TUI 又会单独写一遍;IDE 又写一遍。conformance 没保证。
这正是我前面 review #3803/#4175 时反复提的 adapter conformance test 缺位的根因 — 共享的 render-layer contract 还没下沉。
七、Tool preview taxonomy 极度不足
当前 4 种 preview kind:
type DaemonToolPreview =
| { kind: 'ask_user_question'; questions: ... }
| { kind: 'command'; command: string; cwd?: string }
| { kind: 'key_value'; rows: ... }
| { kind: 'generic'; summary?: string };实际生态需要的至少还有:
file_diff— edit before/after,带 patchfile_read— path + line rangeweb_fetch— url + status + content-typesearch— query + result count + top resultsmcp_invocation— server name + tool + structured argsimage_generation— preview thumbnailcode_block— syntax-highlighted code(为 markdown 渲染省心)tabular— 结构化 table 结果subagent_delegation— 派给哪个 agent + 任务
没有这些,UI 落到 key_value 或 generic 兜底,渲染粗糙;或者 UI 自己解析 rawInput 又重新引入 provider 差异感知 — 跟"统一层"目标矛盾。
八、综合评估
完备度评分
| 维度 | 评分 | 关键缺口 |
|---|---|---|
| 事件 type 覆盖率 | C+ | 12+ daemon 事件未归一化,全落到 debug |
| 事件 schema 信息度 | C | tool/error 没 closed enum;errorKind 闭枚举没穿透到 UI |
| 时间语义 | D | 客户端时钟 + 无 server timestamp;ordering 不稳 |
| Provider 差异隐藏 | B- | 协议下层抹了大头;reasoning signature / 多模态 / tool namespace 三处漏 |
| Reducer 状态机 | B | 基础对,缺 in-flight / subagent 嵌套 / progress / token usage |
| Render 契约 | D | 只 terminal 有 helper,web/IDE 各自写 |
| Tool preview 分类 | D+ | 4 种远不够,缺 diff / search / mcp / 多模态 |
"UI 端不再需要感知 provider 和模型差异" 这个目标 — 当前 PR 完成度 ~55%
需要 daemon 侧补
- SSE envelope 加
_meta.serverTimestamp - Tool call event 加
provenance+serverId - 现有 12+ 事件正式 emit 到 SDK(可能已经 emit,只是 UI 端没归一化)
需要 SDK UI 侧补
- 12+ daemon event 归一化(尤其
available_commands_update/mcp_*/approval_mode_changed/auth_device_flow_*) - 多模态 content 不再丢弃,改 discriminated union
- Tool preview 至少加 5 种(diff / search / mcp / file_read / web_fetch)
- ErrorKind 闭枚举穿透到
DaemonUiErrorEvent - 时间 schema 改
eventId排序 +serverTimestamp显示 daemonBlockToMarkdown/...ToHtml/...ToPlainText三个 helper(render 契约)
需要 reducer 补
- Tool ↔ shell output 关联(同 toolCallId)
currentTool/currentSubagent顶层指针- Cancellation 时 in-flight tool block 强制改
cancelled状态 - 嵌套 transcript(subagent)
九、建议:拆 5 个 follow-up PR
不建议在本 PR 内做完(已经 +2674/-1973 + 21 个 wenshao 评论)。建议拆:
| Follow-up | 范围 | 估算 LOC |
|---|---|---|
| PR-A | 补 12+ event 归一化 + errorKind 穿透 + tool provenance + available_commands_update typed event |
~600 |
| PR-B | 时间 schema 改造(daemon + SDK 配合) | ~200 |
| PR-C | 多模态 content + tool preview taxonomy 扩展 | ~500 |
| PR-D | render 契约下沉(...ToMarkdown / ...ToHtml / ...ToPlainText)+ adapter conformance test |
~800 |
| PR-E | reducer 高级状态机(subagent / progress / current tool / cancellation propagation) | ~600 |
当前 PR 当 v1 ship 是合理的(只要修完 wenshao 的 [Critical]),完整"统一层"的承诺通过 PR-A 到 PR-E 兑现。
Generated with assistance from Claude Opus 4.7 (claude-opus-4-7) — analysis cross-referenced against daemon_mode_b_main HEAD 066cab229 (daemon event taxonomy in events.ts) and PR #4328 HEAD 5f48a92e3.
5532d6c to
ba44e08
Compare
ba44e08 to
f338454
Compare
|
Generated by GPT-5 model. Full comment sweep update for #4328:
Validation after the latest push:
|
Maintainer test report — PR #4328Built and validated locally in a dedicated Heads up on PR base: this PR targets Environment
Results
Browser-safety verification (the PR's central architectural claim)$ node -e "import('@qwen-code/sdk/daemon').then(m => console.log(Object.keys(m).sort().join(', ')))"Resolves to a single-file ESM at Byte-level scan for Native TUI defaults — confirmed untouchedThe PR removes
Lint tooling issue — pre-existing, not a code defect
Root cause: nested Risk assessmentLow-medium risk for the feature-branch merge into
Mergeability statusGitHub reports Not covered locally
Reproducegit fetch origin pull/4328/head:pr-4328 && git checkout pr-4328
npm install # adds @qwen-code/sdk dep + daemon subpath
npm run build && npm run typecheck # all 4 workspaces clean
cd packages/sdk-typescript && npx vitest run --no-coverage # 493/493
cd ../webui && npx vitest run --no-coverage # 53/53
cd ../cli && npx vitest run --no-coverage # 6932 pass / 9 skip / 0 fail
cd ../..
node -e "import('@qwen-code/sdk/daemon').then(m => console.log(typeof m.DaemonClient, typeof m.normalizeDaemonEvent))"
grep -c "node:" packages/sdk-typescript/dist/daemon/index.{js,cjs} # both 0
grep -rc DaemonTuiAdapter dist/cli.js dist/chunks/*.js | grep -v ':0$' # emptyRecommendation: safe to merge into 🇨🇳 中文版本(点击展开)维护者测试报告 — PR #4328在专属 关于 PR base 的注意事项: 这条 PR 的 base 是 环境
结果
浏览器安全验证(PR 的核心架构主张)$ node -e "import('@qwen-code/sdk/daemon').then(m => console.log(Object.keys(m).sort().join(', ')))"解析到一个单文件 ESM 对 Native TUI 默认行为 — 已确认未受影响PR 删了
Lint 工具问题 — 预先存在,不是代码缺陷
根因:嵌套的 风险评估合入
Mergeability 状态GitHub 报 本地未覆盖
复现命令git fetch origin pull/4328/head:pr-4328 && git checkout pr-4328
npm install # 加 @qwen-code/sdk 依赖 + daemon 子路径
npm run build && npm run typecheck # 4 个 workspace 都干净
cd packages/sdk-typescript && npx vitest run --no-coverage # 493/493
cd ../webui && npx vitest run --no-coverage # 53/53
cd ../cli && npx vitest run --no-coverage # 6932 pass / 9 skip / 0 fail
cd ../..
node -e "import('@qwen-code/sdk/daemon').then(m => console.log(typeof m.DaemonClient, typeof m.normalizeDaemonEvent))"
grep -c "node:" packages/sdk-typescript/dist/daemon/index.{js,cjs} # 两个都 0
grep -rc DaemonTuiAdapter dist/cli.js dist/chunks/*.js | grep -v ':0$' # 空结论:合入 |
| ...(rawInput !== undefined | ||
| ? { details: capDetails(stringifyRedactedJson(rawInput)) } | ||
| : rawOutput !== undefined | ||
| ? { details: capDetails(stringifyRedactedJson(rawOutput)) } |
There was a problem hiding this comment.
[Suggestion] The details field for tool-output-only updates changed from getOutputText(rawOutput) (which extracts .text/.stdout/.stderr as human-readable text) to stringifyRedactedJson(rawOutput) (which JSON-stringifies the entire redacted object). For structured tool output like {text: "OK", status: 200}, the old details would show "OK" while the new code produces '{\n "text": "OK",\n "status": 200\n}' — a display quality regression for UI panels rendering the details preview.
Additionally, rawOutput is already the result of redactSensitiveFields(rawOutputSource) at line ~258, so stringifyRedactedJson performs a redundant second redactSensitiveFields traversal on already-redacted data.
| ? { details: capDetails(stringifyRedactedJson(rawOutput)) } | |
| ? { details: capDetails(stringifyJson(rawInput)) } | |
| : rawOutput !== undefined | |
| ? { details: capDetails(getOutputText(rawOutput)) } |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| await flushPromises(); | ||
| }); | ||
|
|
||
| // 410 path: SHOULD have retried at least once (so connect succeeded on |
There was a problem hiding this comment.
[Suggestion] This test is now parameterized over [404, 410] (line 776), but the comment still says "410 path". When the test runs with status=404, this comment is misleading. Consider updating to "session-not-found path" or "404/410 path".
| // 410 path: SHOULD have retried at least once (so connect succeeded on | |
| // session-not-found path: SHOULD have retried at least once (so connect succeeded on |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| ...(rawInput !== undefined | ||
| ? { details: capDetails(stringifyRedactedJson(rawInput)) } | ||
| : rawOutput !== undefined | ||
| ? { details: capDetails(stringifyRedactedJson(rawOutput)) } |
There was a problem hiding this comment.
[Suggestion] The details field for tool-output-only updates changed from getOutputText(rawOutput) (which extracts .text/.stdout/.stderr as human-readable text) to stringifyRedactedJson(rawOutput) (which JSON-stringifies the entire redacted object). For structured tool output like {text: "OK", status: 200}, the old details would show "OK" while the new code produces '{\n "text": "OK",\n "status": 200\n}' — a display quality regression for UI panels rendering the details preview.
Additionally, rawOutput is already the result of redactSensitiveFields(rawOutputSource) at line ~258, so stringifyRedactedJson performs a redundant second redactSensitiveFields traversal on already-redacted data.
| ? { details: capDetails(stringifyRedactedJson(rawOutput)) } | |
| ? { details: capDetails(stringifyJson(rawInput)) } | |
| : rawOutput !== undefined | |
| ? { details: capDetails(getOutputText(rawOutput)) } |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
| await flushPromises(); | ||
| }); | ||
|
|
||
| // 410 path: SHOULD have retried at least once (so connect succeeded on |
There was a problem hiding this comment.
[Suggestion] This test is now parameterized over [404, 410] (line 776), but the comment still says "410 path". When the test runs with status=404, this comment is misleading. Consider updating to "session-not-found path" or "404/410 path".
| // 410 path: SHOULD have retried at least once (so connect succeeded on | |
| // session-not-found path: SHOULD have retried at least once (so connect succeeded on |
— qwen-latest-series-invite-beta-v34 via Qwen Code /review
|
ignore [Suggestion] comment to avoid endless update and make pr too big. Can you please approve this pr, thank you. @wenshao @yiliang114 @doudouOUC |
…(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 QwenLM#4328 (comment)
for the full proposal.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes the "时间定义不标准" gap surfaced in the PR QwenLM#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 QwenLM#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>
…de / cancellation propagation (PR-E) Closes the "reducer state machine 设计缺漏" gap surfaced in the PR QwenLM#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 QwenLM#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>
…ction (PR-C) Closes two related gaps surfaced in the PR QwenLM#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 QwenLM#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>
…elpers (PR-D) Closes the "render 契约只覆盖 terminal" gap surfaced in the PR QwenLM#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 QwenLM#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>
…ete (PR-F) Closes the "5 additional preview kinds" item in PR QwenLM#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 QwenLM#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>
…PR-G) Closes the "Adapter conformance test framework" item in PR QwenLM#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 QwenLM#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>
…act (PR-H) Closes the "WebUI transcriptAdapter migration" item in PR QwenLM#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 QwenLM#4328. Closes the WebUI migration gap in TODO §A. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes the final "Documentation" item in PR QwenLM#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 QwenLM#4328. Documentation-only — no code changes; no tests affected. Generated with AI Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
…(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 QwenLM#4328 (comment)
for the full proposal.
Generated with AI
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Closes the "时间定义不标准" gap surfaced in the PR QwenLM#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 QwenLM#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>
…de / cancellation propagation (PR-E) Closes the "reducer state machine 设计缺漏" gap surfaced in the PR QwenLM#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 QwenLM#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>
…ction (PR-C) Closes two related gaps surfaced in the PR QwenLM#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 QwenLM#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>
…elpers (PR-D) Closes the "render 契约只覆盖 terminal" gap surfaced in the PR QwenLM#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 QwenLM#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>
…ete (PR-F) Closes the "5 additional preview kinds" item in PR QwenLM#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 QwenLM#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>
…PR-G) Closes the "Adapter conformance test framework" item in PR QwenLM#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 QwenLM#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>
Summary
@qwen-code/webui.@qwen-code/sdk/daemonexport shape, and confirmation that native local TUI / ACP / channel / IDE defaults remain untouched.Validation
npm run buildreports an existing VSCode companion lint warning ineditorGroupUtils.tsbut exits successfully.true true.Scope / Risk
@qwen-code/webuinow has an optional dependency on the SDK daemon subpath; the subpath is built as browser-safe ESM to avoid pulling Node-only SDK query/transport code into browser bundles./webapp or full browser UI parity. It provides the shared UI layer those clients should consume.Testing Matrix
Testing matrix notes:
Linked Issues / Bugs
Related to #3803 and #4175.