Skip to content

feat(daemon): add shared UI transcript layer#4328

Merged
chiga0 merged 17 commits into
QwenLM:daemon_mode_b_mainfrom
chiga0:feat/daemon-ui-core
May 22, 2026
Merged

feat(daemon): add shared UI transcript layer#4328
chiga0 merged 17 commits into
QwenLM:daemon_mode_b_mainfrom
chiga0:feat/daemon-ui-core

Conversation

@chiga0

@chiga0 chiga0 commented May 19, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • What changed: Adds a shared daemon UI layer for web chat / web terminal clients: typed daemon events are normalized into UI events, reduced into transcript blocks, exposed through a framework-free store, and optionally consumed through new React bindings in @qwen-code/webui.
  • Why it changed: Web clients should not each reimplement streaming merge, tool preview, permission state, shell output, and reconnect-friendly transcript handling on top of raw daemon SSE frames.
  • Reviewer focus: Please focus on the SDK/webui boundary, browser-safe @qwen-code/sdk/daemon export shape, and confirmation that native local TUI / ACP / channel / IDE defaults remain untouched.

Validation

  • Commands run:
    cd packages/sdk-typescript && npx vitest run test/unit/daemonUi.test.ts
    cd /Users/gawain/Documents/codebase/opensource/qwen-code-daemon-ui-core && npm run typecheck
    cd /Users/gawain/Documents/codebase/opensource/qwen-code-daemon-ui-core && npm run build
    cd packages/sdk-typescript && npx eslint src/daemon/ui test/unit/daemonUi.test.ts
    cd packages/webui && npm run lint
    cd /Users/gawain/Documents/codebase/opensource/qwen-code-daemon-ui-core && node -e "import('@qwen-code/sdk/daemon').then(m=>console.log(Boolean(m.DaemonClient), Boolean(m.normalizeDaemonEvent)))"
  • Prompts / inputs used: Unit tests cover daemon stream chunks, AskUserQuestion preview, permission request/resolution, store subscription, and terminal text sanitization.
  • Expected result: SDK UI reducer merges streaming assistant chunks, exposes semantic tool/permission/shell/status blocks, and WebUI can build while keeping the daemon SDK import browser-safe.
  • Observed result: All commands above passed. npm run build reports an existing VSCode companion lint warning in editorGroupUtils.ts but exits successfully.
  • Quickest reviewer verification path: Run the SDK unit test, root typecheck, root build, and import smoke test above.
  • Evidence: Root build completed through WebUI, SDK, and VSCode companion builds; SDK daemon subpath import printed true true.

Scope / Risk

  • Main risk or tradeoff: @qwen-code/webui now 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.
  • Not covered / not validated: This PR does not add the final daemon-served /web app or full browser UI parity. It provides the shared UI layer those clients should consume.
  • Breaking changes / migration notes: Native TUI remains on the current direct path. The deleted daemon TUI adapter was a spike-only path and is not part of the supported native TUI flow.

Testing Matrix

🍏 🪟 🐧
npm run ⚠️ ⚠️
npx ⚠️ ⚠️
Docker N/A N/A N/A
Podman N/A N/A N/A
Seatbelt N/A N/A N/A

Testing matrix notes:

  • Tested locally on macOS. This change is TypeScript SDK/WebUI code and does not add OS-specific runtime behavior.

Linked Issues / Bugs

Related to #3803 and #4175.

@github-actions

Copy link
Copy Markdown
Contributor

📋 Review Summary

This PR adds a shared daemon UI layer for web chat/terminal clients, providing typed daemon event normalization, transcript state management, and React bindings through @qwen-code/webui. The implementation consolidates UI state handling that was previously duplicated across client implementations. Overall assessment: solid architecture with good separation of concerns, but requires attention to browser-safety guarantees and a few API design refinements.

🔍 General Feedback

Positive aspects:

  • Clean separation between the SDK daemon UI layer (@qwen-code/sdk/daemon) and the WebUI React bindings
  • Framework-free store design with useSyncExternalStore compatibility is excellent for React integration
  • Good use of TypeScript types to enforce transcript block structure
  • Comprehensive unit tests covering normalization, permission tracking, and store subscription
  • Proper cleanup of spike code (deleted DaemonTuiAdapter.ts and associated test)

Architectural observations:

  • The @qwen-code/sdk/daemon subpath export is a smart way to keep browser bundles from pulling in Node-only SDK code
  • Transcript reducer pattern follows Redux-like immutability principles
  • The DaemonSessionProvider properly handles SSE stream lifecycle with abort signals

Recurring themes:

  • Several places could benefit from more defensive type guards
  • Browser-safety of the daemon subpath needs explicit verification in build config
  • Documentation updates are minimal for the amount of new API surface

🎯 Specific Feedback

🔴 Critical

  • File: packages/sdk-typescript/src/daemon/ui/normalizer.ts:140-160 - The normalizeSessionUpdate function handles agent_message_chunk but emits both assistant.text.delta AND assistant.done events when _meta.usage is present. This could cause race conditions if the UI processes these synchronously. Recommendation: Either emit a single combined event or document the ordering guarantee explicitly.

  • File: packages/webui/src/daemon/DaemonSessionProvider.tsx:88-95 - The SSE event loop doesn't handle reconnection logic. If the SSE stream disconnects (network blip, server restart), the provider enters disconnected state but doesn't attempt to resume with lastEventId. Recommendation: Add optional auto-reconnect with exponential backoff, or at minimum document this limitation prominently.

  • File: packages/sdk-typescript/src/daemon/index.ts - The re-exported types from ./ui/index.js create a circular dependency risk if the UI module ever needs to import from the parent daemon module. Recommendation: Consider keeping the UI module completely independent or use explicit barrel imports with care.

🟡 High

  • File: packages/sdk-typescript/scripts/build.js - The build script modification adds the daemon subpath but doesn't include explicit browser-safe validation. Recommendation: Add a build-time check that verifies no Node.js-specific imports (e.g., fs, path, http) leak into the daemon UI bundle. This is critical for the stated goal of browser-safe ESM.

  • File: packages/sdk-typescript/src/daemon/ui/transcript.ts:45-60 - The reduceDaemonTranscriptEvent function uses mutable state cloning (cloneTranscriptState then direct mutation). While this is internal, it's error-prone and makes time-travel debugging harder. Recommendation: Consider using Immer or a more functional update pattern for complex state transitions.

  • File: packages/webui/src/daemon/transcriptAdapter.ts:40-50 - The daemonTranscriptToUnifiedMessages adapter assumes all tool blocks have valid toolCallId and title fields. If the daemon sends malformed data, this will crash the UI. Recommendation: Add defensive null checks or use a schema validator like Zod at the adapter boundary.

  • File: packages/sdk-typescript/src/daemon/ui/types.ts:100-120 - The NormalizeDaemonEventOptions interface has suppressOwnUserEcho which relies on clientId matching. This is subtle behavior that could cause confusing bugs if the clientId is undefined or changes. Recommendation: Add runtime warnings when suppression is enabled but clientId is missing.

🟢 Medium

  • File: packages/sdk-typescript/src/daemon/ui/utils.ts - The sanitizeTerminalText function strips OSC sequences but the regex pattern should be documented with examples of what it catches. Recommendation: Add JSDoc comments with before/after examples.

  • File: packages/sdk-typescript/src/daemon/ui/store.ts:45-55 - The dispatch method accepts single events or arrays but doesn't validate input. Recommendation: Add runtime type guards to catch developer errors early (e.g., dispatching undefined or null).

  • File: packages/webui/src/daemon/DaemonSessionProvider.tsx:145-165 - The actions object is recreated on every render due to the useMemo dependency on store. Since store is stable, this is fine, but the requireSession helper throws generically. Recommendation: Use more specific error types or error codes for better error handling upstream.

  • File: packages/sdk-typescript/test/unit/daemonUi.test.ts - Test coverage is good but doesn't include edge cases like: rapid event firing, event ID gaps, or malformed daemon events. Recommendation: Add stress tests for the reducer under high-frequency updates.

🔵 Low

  • File: docs/developers/daemon-client-adapters/web-ui.md - The new documentation is helpful but doesn't include a quickstart example showing the minimal DaemonSessionProvider wrapper. Recommendation: Add a "Getting Started" code snippet.

  • File: packages/sdk-typescript/src/daemon/ui/types.ts - The type definitions are comprehensive but lack inline JSDoc for most interfaces. Recommendation: Add brief descriptions for each event type explaining when it's emitted.

  • File: packages/webui/package.json - The @qwen-code/sdk dependency is pinned to 0.1.7. Recommendation: Consider using a range like ^0.1.7 to allow minor updates, or document the upgrade process when SDK changes.

  • File: packages/sdk-typescript/src/daemon/ui/terminal.ts - The daemonUiEventToTerminalText function is exported but doesn't have clear usage examples. Recommendation: Add a code comment showing typical terminal renderer integration.

✅ Highlights

  • Excellent React integration: The useSyncExternalStore pattern in DaemonSessionProvider is the modern best practice for framework-free stores
  • Clean event normalization: The normalizeDaemonEvent function handles the complex daemon event schema translation elegantly
  • Good test coverage: Unit tests cover the critical paths including permission tracking, streaming chunks, and store subscription
  • Proper cleanup: Removing the spike DaemonTuiAdapter code shows good discipline in not accumulating dead code
  • Type-safe transcript blocks: The discriminated union types for DaemonTranscriptBlock variants will catch many bugs at compile time

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(', ')))"

@chiga0 chiga0 requested review from wenshao and yiliang114 May 19, 2026 14:18
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from ceb2ae5 to 94d66e0 Compare May 19, 2026 14:30
@chiga0

chiga0 commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Generated by GPT-5 model.

Follow-up from the review pass:

  • Addressed: added default SSE auto-reconnect in DaemonSessionProvider; it keeps the same DaemonSessionClient, so reconnect resumes with the client cursor / last event id.
  • Addressed: stabilized createSessionRequest dependencies so inline React props do not accidentally tear down and recreate the session every render.
  • Addressed: added CJS support for @qwen-code/sdk/daemon and smoke-tested both import() and require().
  • Addressed: SDK build now checks the browser daemon ESM bundle for Node-only tokens, and the generated bundle scan is clean.
  • Addressed: added a minimal WebUI quickstart note covering provider usage and reconnect behavior.

Review triage / false positives:

  • assistant.text.delta followed by assistant.done is intentional ordered reducer input: the delta is applied first, then done only marks the active assistant block non-streaming. No combined event is needed for this reducer.
  • The daemon barrel re-export does not create a circular runtime dependency: UI modules import daemon wire types/utilities directly, and the parent daemon/index.ts only re-exports them.
  • Tool transcript fields are required by the normalized transcript type; malformed daemon tool events get fallback ids/titles or degrade to debug/status events before WebUI mapping.
  • The internal reducer clones state before mutation; callers only receive the replaced snapshot, so this keeps the framework-free store simple without adding Immer to a browser-facing SDK surface.

Validation run locally after the amend:

  • cd packages/sdk-typescript && npx vitest run test/unit/daemonUi.test.ts
  • cd packages/sdk-typescript && ../../node_modules/.bin/eslint src/daemon/ui test/unit/daemonUi.test.ts
  • cd packages/webui && npm run lint
  • cd packages/webui && npm run typecheck
  • cd packages/webui && npm run build
  • cd packages/sdk-typescript && npm run build
  • root npm run typecheck
  • root npm run build (completed with the existing vscode-ide-companion curly warning, no errors)
  • ESM/CJS smoke: import('@qwen-code/sdk/daemon') and require('@qwen-code/sdk/daemon') both returned the expected daemon exports.

Comment thread packages/sdk-typescript/src/daemon/ui/utils.ts
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
Comment thread packages/webui/src/daemon/transcriptAdapter.ts
Comment thread packages/webui/src/daemon/transcriptAdapter.ts Outdated
Comment thread packages/webui/src/daemon/transcriptAdapter.ts
Comment thread packages/sdk-typescript/src/daemon/ui/toolPreview.ts
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from 94d66e0 to b3e102b Compare May 19, 2026 14:41
@chiga0 chiga0 requested a review from wenshao May 19, 2026 14:43
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from b3e102b to fe77066 Compare May 19, 2026 14:44

@chiga0 chiga0 left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.usage piggyback for assistant.done is fragile
  • normalizer.ts:241toolCallId fallback to event.id creates phantom tool blocks
  • transcript.ts:345cloneTranscriptState does full deep clone on every event (perf)
  • transcript.ts:152getBlockById linear scan on streaming hot path
  • transcriptAdapter.ts:139normalizeToolStatus default → 'failed' breaks forward-compat
  • transcriptAdapter.ts:151cancelled'completed' loses information
  • transcriptAdapter.ts:115 — error block rendered as assistant message with [System Error] prefix is a UX antipattern
  • sdk-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-renders
  • store.ts:53 — dispatch immediately notifies, no microtask batching
  • transcript.ts:289 — silent fallback to status when permission block was trimmed
  • webui/package.json:47"@qwen-code/sdk": "0.1.7" exact-pin causes monorepo skew
  • build.js:131assertBrowserSafeBundle only 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.

Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/index.ts Outdated
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/store.ts Outdated
Comment thread packages/webui/package.json Outdated
Comment thread packages/sdk-typescript/scripts/build.js
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from fe77066 to feddf8e Compare May 19, 2026 15:28
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/utils.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/transcriptAdapter.ts
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch 2 times, most recently from 99f2952 to 5f48a92 Compare May 19, 2026 15:37
@chiga0 chiga0 requested a review from wenshao May 19, 2026 15:40
@chiga0

chiga0 commented May 19, 2026

Copy link
Copy Markdown
Collaborator Author

Generated by GPT-5 model.

Latest review pass handled in 5f48a92e3:

  • Removed fragile _meta.usage completion and missing-tool-id synthesis.
  • Reworked transcript reducer hot paths with block indexing, structural sharing, batched reduction, and batched store notifications.
  • Preserved tool preview/status on partial updates, fixed thought block lifecycle, permission orphan visibility, output recursion depth, and redacted fallback error text.
  • Kept UI-only daemon transcript APIs scoped to @qwen-code/sdk/daemon; top-level SDK smoke checks confirm they are not exported from @qwen-code/sdk.
  • Split WebUI daemon provider contexts and added adapter/reducer coverage for the reviewed edge cases.

Local validation run:

  • cd packages/sdk-typescript && npx vitest run test/unit/daemonUi.test.ts test/unit/daemon-public-surface.test.ts
  • cd packages/webui && npx vitest run src/daemon/transcriptAdapter.test.ts
  • cd packages/webui && npm run lint
  • cd packages/webui && npm run typecheck
  • cd packages/sdk-typescript && npm run build
  • cd packages/webui && npm run build
  • npm run typecheck
  • SDK daemon ESM/CJS smoke checks

Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from 5f48a92 to 5532d6c Compare May 20, 2026 02:57

@wenshao wenshao left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.ts untested: model_switched, model_switch_failed, session_closed, client_evicted, slow_client_warning, stream_error, and unknown-event fallback.
  • [Critical] suppressOwnUserEcho and includeRawEvent normalizer options have zero test coverage.

— DeepSeek/deepseek-v4-pro via Qwen Code /review

Comment thread packages/webui/src/daemon/transcriptAdapter.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/sdk-typescript/src/daemon/ui/terminal.ts
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
@chiga0

chiga0 commented May 20, 2026

Copy link
Copy Markdown
Collaborator Author

PR #4328 作为"统一渲染层"的功能完备度审视

跳出单点 issue,从"统一事件 + 状态层是否真的让各 render 端不再感知 provider/模型差异"这个核心目标评估。

结论先放上面:当前 PR 提供的不是"完备的统一层",而是一个 v1 的薄壳。基础事件/状态/reducer/store 都对,但 事件分类只覆盖 daemon 已有事件的 ~50%,时间语义不标准,provider 差异在协议下层已基本抹平但向上还漏 3 处。完成度估算约 55%


一、事件类型覆盖 vs daemon 实际产出 — 覆盖率约 50%

PR 的 DaemonUiEvent 只定义了 13 种 type,而 daemon daemon_mode_b_main 当前已经 emit 28+ 种事件(看 events.tsDaemonKnownEventType 的完整 union)。

已正确归一化

ACP / Daemon 事件 UI 事件
user_message_chunk user.text.delta
agent_message_chunk assistant.text.delta
agent_thought_chunk thought.text.delta
tool_call / tool_call_update tool.update
shell_output / tool_output shell.output
permission_request / permission_resolved / permission_already_resolved permission.request / .resolved
model_switched model.changed
session_died / client_evicted / stream_error / model_switch_failed error
session_closed / slow_client_warning status

未归一化(全部走 debug fallback)— 严重缺漏

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 没有 namespacemcp:<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 时确定。

三个具体问题

  1. 客户端时钟Date.now() 在浏览器/CLI 上是本地时钟,跨客户端会漂移。同一个 session 两个客户端看,"5 分钟前"可能差几十秒到几分钟。
  2. 不是 daemon 权威时间 — daemon 那边的 SSE envelope 应该携带 server-side 时间戳,PR 没用。
  3. 没有 monotonic 保证eventId 是 daemon 单调的(Math.max 守护),但 createdAt 不是。lateral reconnect 拿到 replay 事件时,replay 的 state.now 比原始事件几秒,UI 时间轴出现"未来的事件比现在还新",createdAt ordering 不可靠。

应该长这样

interface DaemonTranscriptBlockBase {
  id: string;
  kind: ...;
  eventId?: number;           // 已有,daemon 单调 cursor — 用这个排序
  serverTimestamp?: number;   // 缺 — daemon 权威时间,从 SSE envelope 取
  clientReceivedAt: number;   // = 当前的 createdAt;用于"X 分钟前"展示但不参与排序
  updatedAt: number;          // 客户端时钟,允许 drift
}

修复路径:

  1. daemon 侧 SSE envelope 加 _meta.serverTimestamp(需要 daemon PR)
  2. SDK normalizer 提取到 DaemonUiEventBase.serverTimestamp
  3. reducer 同时存 serverTimestamp(权威) 和 clientReceivedAt(本地);ordering 用 eventId 优先,fallback serverTimestamp
  4. 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:有 thinking text + 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) — 直接用于 SSR
  • daemonBlockToPlainText(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,带 patch
  • file_read — path + line range
  • web_fetch — url + status + content-type
  • search — query + result count + top results
  • mcp_invocation — server name + tool + structured args
  • image_generation — preview thumbnail
  • code_block — syntax-highlighted code(为 markdown 渲染省心)
  • tabular — 结构化 table 结果
  • subagent_delegation — 派给哪个 agent + 任务

没有这些,UI 落到 key_valuegeneric 兜底,渲染粗糙;或者 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 兑现。

cc @wenshao @doudouOUC


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.

Comment thread packages/webui/vite.config.ts Outdated
Comment thread packages/webui/src/daemon/transcriptAdapter.ts Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts Outdated
@chiga0 chiga0 force-pushed the feat/daemon-ui-core branch from ba44e08 to f338454 Compare May 20, 2026 03:55
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/transcript.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/webui/src/components/toolcalls/ThinkToolCall.tsx Outdated
Comment thread packages/sdk-typescript/src/daemon/ui/normalizer.ts
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx
Comment thread packages/webui/src/daemon/DaemonSessionProvider.tsx Outdated
Comment thread packages/webui/src/daemon/transcriptAdapter.ts Outdated
@chiga0

chiga0 commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

Generated by GPT-5 model.

Full comment sweep update for #4328:

  • Verified all 179 review threads; unresolved review conversations are now 0.
  • Addressed the unanchored normalizer.ts / transcriptAdapter.ts redaction review body with f374920d9 and 905a436fb: tool details, raw event diagnostics, structured content / locations, permission toolCall, and WebUI adapter values now go through key-based redaction boundaries, with regression coverage.
  • Replied to the remaining unresolved inline conversations individually. Two suggestions were intentionally not changed: sendPrompt is long-lived by protocol and should not get a plain control-action timeout in this PR, and render-path console.warn diagnostics would conflict with the app no-console policy / purity of the adapter.

Validation after the latest push:

  • packages/sdk-typescript: npx vitest run test/unit/daemonUi.test.ts --reporter dot (39 passed)
  • packages/sdk-typescript: npm run typecheck
  • packages/sdk-typescript: ../../node_modules/.bin/eslint src/daemon/ui test/unit/daemonUi.test.ts
  • packages/webui: npx vitest run src/daemon/DaemonSessionProvider.test.tsx src/daemon/transcriptAdapter.test.ts --reporter dot (28 passed)
  • packages/webui: npm run typecheck
  • packages/webui: npm run lint

@wenshao

wenshao commented May 21, 2026

Copy link
Copy Markdown
Collaborator

Maintainer test report — PR #4328

Built and validated locally in a dedicated tmux session (pr4328) on the pr-4328 fetch branch (head 905a436 as of the PR's latest push, 17 commits ahead of merge-base a60c1c5).

Heads up on PR base: this PR targets daemon_mode_b_main (the F4 daemon work integration branch), not main. Diff stats below are vs the correct base.

Environment

  • macOS 26.4.1 (arm64)
  • Node v22.17.0 / npm 11.8.0
  • Branch: pull/4328/head → local pr-4328
  • Worktree: /Users/wenshao/git/qwen-code-x6
  • Scope vs daemon_mode_b_main: 33 files, +6103/-1993 (newer than the +5768/-1993 in the PR metadata because of post-open commits — tests grew most: daemonUi.test.ts 1437 LOC, DaemonSessionProvider.test.tsx 1086 LOC, transcriptAdapter.test.ts 318 LOC)

Results

Stage Result Notes
npm install PASS added 39, removed 7, changed 20 — required because the PR adds @qwen-code/sdk as a webui dep and exposes the ./daemon subpath
npm run build (workspaces) PASS webui, sdk-typescript, cli, vscode-companion all build; only the 1 unrelated vscode-ide-companion/editorGroupUtils.ts curly warning
Focused new tests 67/67 pass daemonUi.test.ts 39 + DaemonSessionProvider.test.tsx 20 + transcriptAdapter.test.ts 8
Full packages/sdk-typescript vitest 493/493 pass 14 files. daemonUi.test.ts is the new one; existing DaemonClient.test.ts (121), Query.test.ts (54), DaemonAuthFlow.test.ts (14) etc. all green. The two [ERROR] lines in the log are intentional error-path validation, not failures
Full packages/webui vitest 53/53 pass 7 files including the 2 new ones and the existing ChatViewer, MessageMeta, routing tests
Full packages/cli vitest 6932/6932 pass / 9 skipped / 0 failed 388 files. Notably clean — no flakes despite the PR deleting DaemonTuiAdapter.{ts,test.ts} (905 + 969 LOC). Other PRs I've tested today saw 2–18 flaky cli failures across runs; this one ran fully green
Root npm run typecheck PASS all 4 workspaces (qwen-code root, core, sdk, webui) typecheck clean
Lint via npm run lint in packages/sdk-typescript tooling crash (NOT a code issue) TypeError: Cannot read properties of undefined (reading 'allowShortCircuit') from @typescript-eslint/no-unused-expressions. Caused by an upstream nested-eslint version mismatch (packages/sdk-typescript/node_modules/eslint@8.57.1 vs root eslint@9.29.0 + @typescript-eslint/eslint-plugin@8.35.0). PR does not touch devDependencies — issue pre-exists on daemon_mode_b_main
Lint via root eslint directly on PR-touched SDK files PASS running root eslint (9.29.0) on packages/sdk-typescript/src/daemon test/unit/daemonUi.test.ts — zero issues. The lint logic is fine, only the in-package eslint launcher is broken
Lint packages/webui (npm run lint) PASS clean
Browser-safe @qwen-code/sdk/daemon subpath PASS imports resolve and exports are present (DaemonClient, normalizeDaemonEvent, plus 30+ daemon-UI helpers). Zero node:* imports in dist/daemon/index.js and index.cjs — the browser-safety claim is verified at the bundle byte level
Bundle proof in root binary DaemonTuiAdapter has 0 references in dist/cli.js and all 11 dist/chunks/*.js — the deletion took effect end-to-end
Bundle proof in webui dist DaemonSessionProvider, normalizeDaemonEvent, createDaemonTranscriptStore, DaemonClient all present in webui's dist/index.js and index.cjs

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 packages/sdk-typescript/dist/daemon/index.js (57 KB), CJS at index.cjs (59 KB). Exports include:

DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS, DAEMON_PLAN_TOOL_CALL_ID,
DEVICE_FLOW_EXPIRY_GRACE_MS, DaemonAuthFlow, DaemonCapabilityMissingError,
DaemonClient, DaemonHttpError, DaemonSessionClient, SseFramingError,
appendLocalUserTranscriptMessage, asKnownDaemonEvent,
createDaemonAuthState, createDaemonSessionViewState,
createDaemonToolPreview, createDaemonTranscriptState,
createDaemonTranscriptStore, daemonUiEventToTerminalText,
getDaemonUiOutputText, getSessionUpdatePayload,
isDaemonContentHash, isDaemonEventType, isDaemonUiSensitiveKey,
isKnownDaemonEvent, normalizeDaemonEvent, parseSseStream,
rebuildDaemonTranscriptBlockIndex, redactDaemonUiSensitiveFields,
reduceDaemonAuthEvent, reduceDaemonAuthEvents, reduceDaemonSessionEvent,
reduceDaemonSessionEvents, reduceDaemonTranscriptEvents,
requireWorkspaceCwd, sanitizeDaemonTerminalText,
selectPendingPermissionBlocks, selectTranscriptBlocks,
stringifyDaemonUiJson, stripDaemonOscSequences,
transcriptBlockToTerminalText

Byte-level scan for node:*, undici, node-pty, node:fs, node:tty, node:child_process in both index.js and index.cjs: zero matches. The browser can pull this subpath into its bundle without dragging Node-only modules in.

Native TUI defaults — confirmed untouched

The PR removes packages/cli/src/ui/daemon/DaemonTuiAdapter.{ts,test.ts} (spike-only path the PR description flags as not part of supported native TUI flow). Verifications:

  • Source grep for DaemonTuiAdapter in packages/**/*.{ts,tsx}0 hits (the 3 stale matches in packages/cli/dist/src/ui/daemon/DaemonTuiAdapter.d.ts are pre-build leftovers and don't reach the bundle)
  • Root binary scan for DaemonTuiAdapter across dist/cli.js + 11 dist/chunks/*.js0 hits total
  • Full CLI suite (6,932 tests across 388 files) — passes; no test was importing the deleted adapter

Lint tooling issue — pre-existing, not a code defect

npm run lint inside packages/sdk-typescript crashes with:

TypeError: Error while loading rule '@typescript-eslint/no-unused-expressions':
  Cannot read properties of undefined (reading 'allowShortCircuit')

Root cause: nested packages/sdk-typescript/node_modules/eslint@8.57.1 collides with root @typescript-eslint/eslint-plugin@8.35.0, which assumes ESLint 9's rule-context shape. The PR does not change any devDependencies. When I bypass the nested launcher and run the root eslint@9.29.0 directly against the same files (src/daemon test/unit/daemonUi.test.ts), lint is clean. This deserves its own infrastructure fix on daemon_mode_b_main but is not a blocker for this PR.

Risk assessment

Low-medium risk for the feature-branch merge into daemon_mode_b_main.

  • Large surface (33 files / +6103/-1993) but tightly bounded: SDK daemon subpath is additive, webui daemon module is additive, CLI deletion is the spike adapter the PR author explicitly identifies as non-load-bearing.
  • The two architectural claims of the PR — (a) browser-safe SDK subpath, (b) native TUI / ACP / IDE paths untouched — are both verified at bundle-byte level (zero node:* imports in the daemon subpath; zero DaemonTuiAdapter references in the CLI binary).
  • Test coverage is dense: 67 dedicated new tests for the daemon UI layer, all green.
  • The single CLI test-suite run was unusually clean (no flakes), but you've seen the cli suite be flaky on other branches today — I wouldn't read into this as PR-induced stability.

Mergeability status

GitHub reports mergeable_state: blocked with reviewDecision: CHANGES_REQUESTED. The author posted a comment two hours ago summarizing how each of the latest 7 review items was triaged (2 critical fixed, 5 deferred with reasoning). The blockage is review-workflow state — code/test/build state is clean.

Not covered locally

  • No live daemon end-to-end (no running qwen serve instance + browser client) — out of scope for a static maintainer pass; the unit + integration tests cover the SSE → UI event → transcript reducer pipeline.
  • Windows/Linux — macOS only, matching the PR's matrix.

Reproduce

git 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$' # empty

Recommendation: safe to merge into daemon_mode_b_main. Code/test/build state is clean across all 4 workspaces, the browser-safety contract is verified at bundle bytes, the deleted spike adapter is fully removed from the binary, and the new daemon UI layer is tightly tested. The CHANGES_REQUESTED status is workflow housekeeping (author already addressed the 2 critical items two hours ago). The nested-eslint launcher crash should be tracked as an independent daemon_mode_b_main tooling fix.


🇨🇳 中文版本(点击展开)

维护者测试报告 — PR #4328

在专属 tmux 会话(pr4328)里本地构建并验证了 pr-4328 拉取分支(HEAD 905a436,是 PR 最新一次推送的提交,比 merge-base a60c1c5 多 17 个 commit)。

关于 PR base 的注意事项: 这条 PR 的 base 是 daemon_mode_b_main(F4 daemon 工作的整合分支),不是 main。下面的 diff 统计是相对正确的 base 计算的。

环境

  • macOS 26.4.1(arm64)
  • Node v22.17.0 / npm 11.8.0
  • 分支:pull/4328/head → 本地 pr-4328
  • Worktree:/Users/wenshao/git/qwen-code-x6
  • vs daemon_mode_b_main 的范围:33 文件,+6103/-1993(比 PR 元数据里的 +5768/-1993 还多,因为开 PR 之后又有提交 — 主要是测试涨了:daemonUi.test.ts 1437 行、DaemonSessionProvider.test.tsx 1086 行、transcriptAdapter.test.ts 318 行)

结果

阶段 结果 备注
npm install PASS added 39, removed 7, changed 20 — 必须跑,因为 PR 给 webui 加了 @qwen-code/sdk 依赖并暴露 ./daemon 子路径
npm run build(workspaces) PASS webui、sdk-typescript、cli、vscode-companion 都成功;只有 1 条无关的 vscode-ide-companion/editorGroupUtils.ts 大括号 warning
重点新测试 67/67 通过 daemonUi.test.ts 39 + DaemonSessionProvider.test.tsx 20 + transcriptAdapter.test.ts 8
packages/sdk-typescript 全量 vitest 493/493 通过 14 个文件。daemonUi.test.ts 是新的;既有的 DaemonClient.test.ts(121)、Query.test.ts(54)、DaemonAuthFlow.test.ts(14)等都绿。日志里那两条 [ERROR] 是故意触发的错误路径校验,不是失败
packages/webui 全量 vitest 53/53 通过 7 个文件,包含 2 个新加的和既有的 ChatViewerMessageMeta、路由测试
packages/cli 全量 vitest 6932/6932 通过 / 9 skipped / 0 失败 388 个文件。异常干净 — 无 flake,尽管 PR 删了 DaemonTuiAdapter.{ts,test.ts}(905 + 969 行)。我今天测的其它 PR 在 cli 全量上有 2–18 个 flaky 失败,这次完全绿
npm run typecheck PASS 4 个 workspace(qwen-code 根、core、sdk、webui)typecheck 全干净
packages/sdk-typescriptnpm run lint 工具崩溃(不是代码问题 TypeError: Cannot read properties of undefined (reading 'allowShortCircuit'),来自 @typescript-eslint/no-unused-expressions。根因是嵌套 eslint 版本冲突(packages/sdk-typescript/node_modules/eslint@8.57.1 vs 根 eslint@9.29.0 + @typescript-eslint/eslint-plugin@8.35.0)。PR 没改 devDependencies — 这个问题在 daemon_mode_b_main 上就已存在
用根 eslint 直接 lint PR 改动的 SDK 文件 PASS 用根 eslint(9.29.0)跑 packages/sdk-typescript/src/daemon test/unit/daemonUi.test.ts — 零问题。lint 逻辑没问题,只是包内的 eslint launcher 坏了
packages/webui lint(npm run lint PASS 干净
浏览器安全的 @qwen-code/sdk/daemon 子路径 PASS 导入解析正常,导出都在(DaemonClientnormalizeDaemonEvent,加上 30+ 个 daemon-UI 助手)。dist/daemon/index.jsindex.cjsnode:* 引用 — 浏览器安全主张在 bundle 字节级别得证
根二进制 bundle 验证 DaemonTuiAdapter 在 dist/cli.js 和 11 个 dist/chunks/*.js引用为 0 — 删除已彻底落到端到端
webui dist bundle 验证 DaemonSessionProvidernormalizeDaemonEventcreateDaemonTranscriptStoreDaemonClient 都出现在 webui 的 dist/index.jsindex.cjs

浏览器安全验证(PR 的核心架构主张)

$ node -e "import('@qwen-code/sdk/daemon').then(m => console.log(Object.keys(m).sort().join(', ')))"

解析到一个单文件 ESM packages/sdk-typescript/dist/daemon/index.js(57 KB)、CJS index.cjs(59 KB)。导出包括:

DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS, DAEMON_PLAN_TOOL_CALL_ID,
DEVICE_FLOW_EXPIRY_GRACE_MS, DaemonAuthFlow, DaemonCapabilityMissingError,
DaemonClient, DaemonHttpError, DaemonSessionClient, SseFramingError,
appendLocalUserTranscriptMessage, asKnownDaemonEvent,
createDaemonAuthState, createDaemonSessionViewState,
createDaemonToolPreview, createDaemonTranscriptState,
createDaemonTranscriptStore, daemonUiEventToTerminalText,
getDaemonUiOutputText, getSessionUpdatePayload,
isDaemonContentHash, isDaemonEventType, isDaemonUiSensitiveKey,
isKnownDaemonEvent, normalizeDaemonEvent, parseSseStream,
rebuildDaemonTranscriptBlockIndex, redactDaemonUiSensitiveFields,
reduceDaemonAuthEvent, reduceDaemonAuthEvents, reduceDaemonSessionEvent,
reduceDaemonSessionEvents, reduceDaemonTranscriptEvents,
requireWorkspaceCwd, sanitizeDaemonTerminalText,
selectPendingPermissionBlocks, selectTranscriptBlocks,
stringifyDaemonUiJson, stripDaemonOscSequences,
transcriptBlockToTerminalText

index.jsindex.cjs 做字节级扫描 node:*undicinode-ptynode:fsnode:ttynode:child_process零命中。浏览器可以把这个子路径拉进自己的 bundle,不会把 Node-only 模块也拉进去。

Native TUI 默认行为 — 已确认未受影响

PR 删了 packages/cli/src/ui/daemon/DaemonTuiAdapter.{ts,test.ts}(PR 描述里指明是 spike-only 路径、不在受支持的 native TUI 流程里)。验证:

  • packages/**/*.{ts,tsx} 里搜源码 DaemonTuiAdapter0 命中packages/cli/dist/src/ui/daemon/DaemonTuiAdapter.d.ts 里那 3 处陈旧匹配是构建前残留,不会进 bundle)
  • 根二进制扫 DaemonTuiAdapter,跨 dist/cli.js + 11 个 dist/chunks/*.js总计 0 命中
  • 全量 CLI suite(388 文件 6932 个测试)— 通过;没有测试在引用被删除的 adapter

Lint 工具问题 — 预先存在,不是代码缺陷

packages/sdk-typescriptnpm run lint 崩溃,错误:

TypeError: Error while loading rule '@typescript-eslint/no-unused-expressions':
  Cannot read properties of undefined (reading 'allowShortCircuit')

根因:嵌套的 packages/sdk-typescript/node_modules/eslint@8.57.1 与根 @typescript-eslint/eslint-plugin@8.35.0 冲突,后者假设的是 ESLint 9 的 rule-context 形状。PR 没改任何 devDependencies。当我绕过嵌套 launcher、直接用根 eslint@9.29.0 跑同一批文件(src/daemon test/unit/daemonUi.test.ts)时,lint 干净。这个问题应该单独在 daemon_mode_b_main 上修基建,但不是本 PR 的 blocker

风险评估

合入 daemon_mode_b_main 的特性分支,风险 低-中

  • 体量大(33 文件 / +6103/-1993)但边界清晰:SDK daemon 子路径是新增、webui daemon 模块是新增、CLI 删的是 PR 作者明确标记为不承载流量的 spike adapter
  • PR 的两个核心架构主张 ——(a)浏览器安全的 SDK 子路径、(b)native TUI / ACP / IDE 路径不受影响 —— 都在 bundle 字节级别得到验证(daemon 子路径里 node:* 引用为 0;CLI 二进制里 DaemonTuiAdapter 引用为 0)
  • 测试覆盖密集:67 个针对 daemon UI 层的新测试,全绿
  • 这一次 CLI 全量异常干净(无 flake),但我今天看其它分支 cli 是会 flake 的 —— 不要把这次的稳定归功于 PR

Mergeability 状态

GitHub 报 mergeable_state: blockedreviewDecision: CHANGES_REQUESTED。作者两小时前发了评论,说明最新 7 条 review 各自如何处理(2 条 critical 已修、5 条带理由不在本 PR 修)。这个 block 是 review 流程态 — 代码/测试/构建态本身是干净的。

本地未覆盖

  • 没有 daemon 端到端(没起 qwen serve 实例 + 浏览器客户端)—— 不在维护者静态过一遍的范围里;单元 + 集成测试已经覆盖了 SSE → UI 事件 → transcript reducer 的链路
  • Windows/Linux — 仅 macOS,匹配 PR 自己的矩阵

复现命令

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$' #

结论:合入 daemon_mode_b_main 安全。4 个 workspace 的代码/测试/构建都干净,浏览器安全契约在 bundle 字节级别可证,删掉的 spike adapter 在二进制里彻底清除,新的 daemon UI 层测试紧实。CHANGES_REQUESTED 是流程行政(作者两小时前已经回应了 2 条 critical)。嵌套 eslint launcher 崩溃应该作为 daemon_mode_b_main 独立的基建修复来跟踪。

...(rawInput !== undefined
? { details: capDetails(stringifyRedactedJson(rawInput)) }
: rawOutput !== undefined
? { details: capDetails(stringifyRedactedJson(rawOutput)) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
? { 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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".

Suggested change
// 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)) }

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Suggested change
? { 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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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".

Suggested change
// 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

@chiga0

chiga0 commented May 21, 2026

Copy link
Copy Markdown
Collaborator Author

ignore [Suggestion] comment to avoid endless update and make pr too big. Can you please approve this pr, thank you. @wenshao @yiliang114 @doudouOUC

@chiga0 chiga0 enabled auto-merge (squash) May 21, 2026 16:06
@chiga0 chiga0 merged commit d0563ec into QwenLM:daemon_mode_b_main May 22, 2026
36 checks passed
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…(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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>` → `&lt;script&gt;`)
- HTML strips terminal escape sequences before escaping
- Error blocks emit `role="alert"` for screen readers
- plain text drops markdown delimiters
- maxFieldLength truncates with ellipsis
- sanitizeUrls strips token query params
- Custom sanitizer hook works

## Roadmap

PR-D of the unified follow-up to PR 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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 22, 2026
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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…(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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…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>` → `&lt;script&gt;`)
- HTML strips terminal escape sequences before escaping
- Error blocks emit `role="alert"` for screen readers
- plain text drops markdown delimiters
- maxFieldLength truncates with ellipsis
- sanitizeUrls strips token query params
- Custom sanitizer hook works

## Roadmap

PR-D of the unified follow-up to PR 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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…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>
chiga0 pushed a commit to chiga0/qwen-code that referenced this pull request May 23, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants