diff --git a/docs/developers/daemon-ui/MIGRATION.md b/docs/developers/daemon-ui/MIGRATION.md new file mode 100644 index 0000000000..9afe450ce0 --- /dev/null +++ b/docs/developers/daemon-ui/MIGRATION.md @@ -0,0 +1,339 @@ +# Migrating to `@qwen-code/sdk/daemon` v2 + +PR #4328 shipped the v1 daemon UI layer. PR #4353 (this PR) ships v2 with +seven additive feature commits. This guide walks through the changes for web +chat and web terminal adapter authors first. Native local TUI, channel, and IDE +maintainers can reuse the same primitives later, but those default product paths +are not migrated by this PR. + +## TL;DR for existing consumers + +**No breaking changes.** Every commit in this PR is additive: + +- v1 fields still work (`createdAt` preserved as `@deprecated` alias for + `clientReceivedAt`) +- v1 normalizer still maps the same 13 event types the same way +- v1 reducer still produces the same blocks for chat events +- New API is opt-in via additional parameters and helpers + +The PR is safe to merge without any consumer changes. **Adoption of the +new features is incremental.** + +## Recommended adoption order + +For each adapter, in order of effort/value ratio: + +### 1. Ordering: switch sort key from `createdAt` to `eventId` + +**Before:** + +```ts +const ordered = [...state.blocks].sort((a, b) => a.createdAt - b.createdAt); +``` + +**After:** + +```ts +import { selectTranscriptBlocksOrderedByEventId } from '@qwen-code/sdk/daemon'; +const ordered = selectTranscriptBlocksOrderedByEventId(state); +``` + +**Why**: `eventId` is daemon-monotonic; survives SSE replay-after-reconnect. +`createdAt` is client clock and shifts under replay. + +### 2. Display: switch `createdAt` to `serverTimestamp ?? clientReceivedAt` + +**Before:** + +```tsx + +``` + +**After:** + +```tsx +import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; +; +``` + +**Why**: Multiple clients see consistent "X minutes ago" only when both +read daemon clock. Renderer plus `formatBlockTimestamp` handles tz + +locale. + +**Note**: Daemon needs to stamp `_meta.serverTimestamp` on envelopes for +this to take effect. SDK forward-compat-ready; falls back to +`clientReceivedAt` until then. + +### 3. Listen for new event types — pick subset to render + +The 16 new event types (session-meta, workspace, auth) don't push transcript +blocks. They are sidechannel observations. Each adapter picks which to surface: + +```ts +// In your SSE consumer +const uiEvents = normalizeDaemonEvent(envelope, { + clientId, + suppressOwnUserEcho: true, +}); +store.dispatch(uiEvents); + +// Then in your UI side +for (const event of uiEvents) { + switch (event.type) { + case 'session.approval_mode.changed': + myApprovalModeBadge.update(event.next); + break; + case 'workspace.mcp.budget_warning': + myToast.show( + `MCP servers approaching budget: ${event.liveCount}/${event.budget}`, + ); + break; + case 'auth.device_flow.started': + myAuthModal.show({ + deviceFlowId: event.deviceFlowId, + providerId: event.providerId, + expiresAt: event.expiresAt, + }); + break; + // ... etc, opt into what your UI needs + } +} +``` + +Or use selectors for state-mirrored sidechannels: + +```ts +import { selectApprovalMode, selectCurrentTool } from '@qwen-code/sdk/daemon'; + +const mode = selectApprovalMode(state); // mirrored from approval_mode.changed +const currentTool = selectCurrentTool(state); // current in-flight tool +``` + +### 4. Render contract: use `daemonBlockToMarkdown` (or HTML / plainText) + +**Before** (each adapter does its own projection): + +```ts +function blockToString(block: DaemonTranscriptBlock): string { + switch (block.kind) { + case 'user': + return `You: ${block.text}`; + case 'assistant': + return block.text; + case 'tool': + return `[${block.title}]\n${block.status}`; + // ... etc + } +} +``` + +**After** (delegate to SDK): + +```ts +import { daemonBlockToMarkdown } from '@qwen-code/sdk/daemon'; +const md = daemonBlockToMarkdown(block); +``` + +For HTML SSR: + +```ts +import MarkdownIt from 'markdown-it'; +import DOMPurify from 'dompurify'; +const html = DOMPurify.sanitize(md.render(daemonBlockToMarkdown(block))); +``` + +For plain text: + +```ts +import { daemonBlockToPlainText } from '@qwen-code/sdk/daemon'; +const plain = daemonBlockToPlainText(block); +``` + +### 5. Conformance test + +Add to your adapter's test suite: + +```ts +import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; + +it('adapter projects daemon UI corpus correctly', () => { + const result = runAdapterConformanceSuite({ + reduce: (events) => myReduce(events), + renderToText: (state) => myRender(state), + }); + expect(result.failed).toEqual([]); +}); +``` + +This will run your adapter against 10 fixture scenarios and surface any +projection drift before it reaches users. + +### 6. Tool icon dispatch via `provenance` + +**Before** (string match on toolName): + +```tsx +const isMcp = toolName?.startsWith('mcp__'); +const isBuiltin = ['Bash', 'Edit', 'Read'].includes(toolName); +``` + +**After** (typed provenance from PR-A): + +```tsx +import type { DaemonUiToolUpdateEvent } from '@qwen-code/sdk/daemon'; + +function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { + switch (event.provenance) { + case 'mcp': + return ; + case 'subagent': + return ; + case 'builtin': + return ; + case 'unknown': + default: + return ; + } +} +``` + +SDK has a `mcp____` naming heuristic fallback — works today +even when daemon doesn't explicitly stamp provenance. + +### 7. Error categorization via `errorKind` + +**Before** (regex on text): + +```ts +if (error.text.includes('auth')) showAuthRetry(); +else if (error.text.includes('file not found')) showFilePicker(); +``` + +**After** (closed enum from PR-A): + +```ts +import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; + +function errorAction(errorKind?: DaemonErrorKind): React.ReactNode { + switch (errorKind) { + case 'auth_env_error': return ; + case 'missing_file': return ; + case 'blocked_egress': return ; + case 'init_timeout': return ; + default: return null; + } +} +``` + +**Note**: Daemon needs to stamp `data.errorKind` on session_died / +stream_error for this to populate. SDK already reads it. + +### 8. Cancellation handling — already automatic + +In v1, cancelled prompts left in-flight tool blocks spinning forever. +In v2 (PR-E), `propagateCancellationToInFlightTools` runs automatically +on `assistant.done.reason === 'cancelled'`. Sub-agent children are +cancelled together with their parent. + +**No adapter changes needed** — your spinners will resolve correctly. + +### 8a. Sub-agent nesting — opt in to nested rendering (PR-K) + +Tool blocks invoked inside a sub-agent delegation now carry +`parentToolCallId`, `subagentType`, and (when the parent is in state) +`parentBlockId`. Adapters can opt in to nested rendering: + +**Before** (flat list, sub-agent calls visually indistinguishable from +top-level): + +```tsx +state.blocks.map((b) => ); +``` + +**After** (recursive nested rendering): + +```tsx +import { + selectSubagentChildBlocks, + isSubagentChildBlock, +} from '@qwen-code/sdk/daemon'; + +function renderTool(block) { + const children = selectSubagentChildBlocks(state, block.toolCallId); + return ( + + {block.subagentType && } + {children.length > 0 && ( + {children.map(renderTool)} + )} + + ); +} + +const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b)); +return topLevel.map(renderTool); +``` + +**No adapter changes needed if you prefer the flat view** — the new +fields are additive and ignored by code that doesn't read them. + +### 9. Tool preview taxonomy — pick subset to render with custom components + +PR-D + PR-F bring 13 preview kinds: + +- 4 file-shaped: `file_diff`, `file_read`, `web_fetch`, `mcp_invocation` +- 5 content-shaped: `code_block`, `search`, `tabular`, `image_generation`, `subagent_delegation` +- 2 control: `ask_user_question`, `command` +- 2 generic: `key_value`, `generic` + +Each adapter dispatches on `preview.kind`: + +```tsx +function ToolPreviewComponent({ preview }: { preview: DaemonToolPreview }) { + switch (preview.kind) { + case 'file_diff': + return ( + + ); + case 'mcp_invocation': + return ( + + ); + case 'tabular': + return ; + case 'image_generation': + return ( + + ); + // ... or fall back to: + default: + return ; + } +} +``` + +Adapters without custom components for all 13 kinds can fall back to the +SDK's `daemonToolPreviewToMarkdown` for any unhandled kind. + +## Backward-compat checklist + +| Concern | Status | +| ------------------------------------------------------ | --------------------------------------------- | +| Existing `block.createdAt` reads | ✅ still works (alias for `clientReceivedAt`) | +| Existing reducer event handling | ✅ unchanged for v1 event types | +| `daemonTranscriptToUnifiedMessages(blocks)` call sites | ✅ new options param is optional | +| Existing `selectTranscriptBlocks` consumers | ✅ unchanged | +| New event types in v1 reducer | ✅ no-op, `lastEventId` still advances | + +## Cross-references + +- [PR #4353 SUMMARY](https://github.com/QwenLM/qwen-code/pull/4353) +- [Daemon UI README](./README.md) — full API reference +- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with shared UI transcript layer diff --git a/docs/developers/daemon-ui/README.md b/docs/developers/daemon-ui/README.md new file mode 100644 index 0000000000..808f96a26b --- /dev/null +++ b/docs/developers/daemon-ui/README.md @@ -0,0 +1,391 @@ +# Daemon UI SDK — Developer Guide + +The `@qwen-code/sdk/daemon` subpath ships shared UI primitives for daemon +clients. The current adoption target is web chat and web terminal; native local +TUI, channel, and IDE integrations keep their existing default paths while the +daemon UI contract stabilizes. This guide covers the API surface introduced by +PR #4353 (the unified follow-up to PR #4328's shared UI transcript layer). + +## Three-layer model + +``` +Daemon SSE wire (NDJSON envelopes) + │ + ▼ +normalizeDaemonEvent(envelope) → DaemonUiEvent[] + │ + ▼ +reduceDaemonTranscriptEvents(state, events) → DaemonTranscriptState + │ { blocks, currentToolCallId, + │ approvalMode, toolProgress, ... } + ▼ +daemonBlockToMarkdown(block) / ToHtml / ToPlainText ← your renderer plugs here +``` + +- **Normalizer**: takes raw daemon SSE envelopes, returns typed UI events +- **Reducer**: accumulates events into a transcript state machine +- **Render helpers**: project state blocks to renderable strings + +## Quick start + +```ts +import { + DaemonSessionClient, + createDaemonTranscriptStore, + normalizeDaemonEvent, + daemonBlockToMarkdown, + selectCurrentTool, + selectApprovalMode, +} from '@qwen-code/sdk/daemon'; + +const session = await DaemonSessionClient.createOrAttach(client, { + workspaceCwd, +}); +const store = createDaemonTranscriptStore(); + +for await (const envelope of session.events({ signal })) { + const events = normalizeDaemonEvent(envelope, { + clientId: session.clientId, + suppressOwnUserEcho: true, + }); + store.dispatch(events); +} + +// Read state from any subscriber +store.subscribe(() => { + const state = store.getSnapshot(); + const currentTool = selectCurrentTool(state); + const mode = selectApprovalMode(state); + const markdown = state.blocks.map(daemonBlockToMarkdown).join('\n\n'); + myRenderer.render({ markdown, currentTool, mode }); +}); +``` + +## Event taxonomy (28+ types) + +`DaemonUiEvent` is a discriminated union of all UI-facing events: + +### Chat-stream events + +| Event | When | +| ---------------------------- | ----------------------------------------------------- | +| `user.text.delta` | User message chunk arrives from daemon | +| `assistant.text.delta` | Assistant streaming chunk | +| `assistant.done` | Prompt completion (from sendPrompt resolve) | +| `thought.text.delta` | Agent reasoning chunk | +| `tool.update` | Tool call lifecycle (running / completed / cancelled) | +| `shell.output` | Shell tool stdout/stderr chunk | +| `permission.request` | Tool needs user authorization | +| `permission.resolved` | Permission decision arrived | +| `model.changed` | Session model switched | +| `status` / `debug` / `error` | Status / debug / error blocks | + +### Session-meta events (PR-A) + +| Event | When | +| ------------------------------- | ------------------------------------------------ | +| `session.metadata.changed` | Session title / display name updated | +| `session.approval_mode.changed` | Mode toggled (plan / default / yolo / auto-edit) | +| `session.available_commands` | Slash command list refreshed | + +### Workspace events (PR-A, Wave 3-4) + +| Event | When | +| -------------------------------------- | ------------------------------------- | +| `workspace.memory.changed` | QWEN.md / memory file modified | +| `workspace.agent.changed` | Sub-agent created / updated / deleted | +| `workspace.tool.toggled` | Builtin tool enabled / disabled | +| `workspace.initialized` | `qwen init` completed | +| `workspace.mcp.budget_warning` | MCP child count approaching cap | +| `workspace.mcp.child_refused` | MCP server refused due to budget | +| `workspace.mcp.server_restarted` | Manual MCP restart succeeded | +| `workspace.mcp.server_restart_refused` | Manual restart blocked | + +### Auth device-flow events (PR-A, Wave 4 OAuth) + +`auth.device_flow.{started,throttled,authorized,failed,cancelled}` + +Each carries the daemon's `deviceFlowId`. Failed events carry a closed-enum +`errorKind` (closed enum — see `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported from `@qwen-code/sdk/daemon` for the canonical list, currently: `expired_token` / `access_denied` / `invalid_grant` / `upstream_error` / `persist_failed` / `not_found_or_evicted`). + +## Render contract (PR-D) + +Three projection helpers, one preview helper. All discriminate on `block.kind` +or `preview.kind`: + +```ts +daemonBlockToMarkdown(block, { sanitizeUrls?, maxFieldLength?, locale? }) +daemonBlockToHtml(block, { sanitizer?, ...renderOpts }) +daemonBlockToPlainText(block, renderOpts) +daemonToolPreviewToMarkdown(preview, renderOpts) +``` + +### Cookbook: render a transcript to markdown + +```ts +const markdown = state.blocks + .map((b) => daemonBlockToMarkdown(b, { sanitizeUrls: true })) + .join('\n\n'); +``` + +### Cookbook: render to sanitized HTML for SSR + +```ts +import DOMPurify from 'dompurify'; +import MarkdownIt from 'markdown-it'; +const md = new MarkdownIt(); + +const html = state.blocks + .map((b) => { + // Two-stage pipeline: markdown → HTML → DOMPurify + const rawHtml = md.render(daemonBlockToMarkdown(b)); + return DOMPurify.sanitize(rawHtml); + }) + .join('\n'); +``` + +Or use the built-in conservative HTML renderer (no markdown parsing, just +HTML escape): + +```ts +const html = state.blocks + .map((b) => daemonBlockToHtml(b, { sanitizer: DOMPurify.sanitize })) + .join('\n'); +``` + +### Cookbook: copy-paste plain text + +```ts +const plain = state.blocks.map(daemonBlockToPlainText).join('\n'); +navigator.clipboard.writeText(plain); +``` + +## Tool preview taxonomy (13 kinds) + +| Kind | Surface | +| --------------------- | ------------------------------------------------- | +| `ask_user_question` | Multi-choice question with options | +| `command` | Bash-style command + cwd | +| `file_diff` | File edit with oldText/newText or patch | +| `file_read` | Path + optional line range | +| `web_fetch` | URL + HTTP method | +| `mcp_invocation` | MCP server + tool + args summary | +| `code_block` | Language-tagged code snippet | +| `search` | Query + result count + top results | +| `tabular` | Columns + rows (capped at 50, truncation flagged) | +| `image_generation` | Prompt + optional thumbnail URL | +| `subagent_delegation` | Agent name + task | +| `key_value` | Generic label/value rows | +| `generic` | Fallback summary | + +Each has a `daemonToolPreviewToMarkdown` projection. Custom renderers can +dispatch on `preview.kind` for rich per-type display (file diff with +syntax highlighting, MCP server badge, image thumbnail, etc.). + +## State selectors (PR-E) + +```ts +selectCurrentTool(state); // → DaemonToolTranscriptBlock | undefined +selectApprovalMode(state); // → 'plan' | 'default' | 'auto-edit' | 'yolo' | undefined +selectToolProgress(state, toolCallId); // → { ratio?, step? } | undefined +selectPendingPermissionBlocks(state); // → ReadonlyArray +selectTranscriptBlocks(state); // → ReadonlyArray +selectTranscriptBlocksOrderedByEventId(state); // sorted by daemon-monotonic id + +// PR-K — sub-agent nesting +selectSubagentChildBlocks(state, parentToolCallId); // direct children only +isSubagentChildBlock(block); // type guard: was this tool invoked inside a sub-agent? +``` + +`currentToolCallId` is automatically maintained by the reducer: + +- Set when a tool enters in-flight status (`running` / `in_progress` / `pending` / `confirming`) +- Cleared when tool enters terminal status (`completed` / `failed` / `cancelled` / etc.) +- Unknown statuses leave it untouched (forward-compat) + +## Cancellation propagation (PR-E) + +When `assistant.done.reason === 'cancelled'`, the reducer walks every +in-flight tool block and force-sets its status to `'cancelled'`. Daemon +does not guarantee a terminal `tool_call_update` for every in-flight +tool when the parent prompt is cancelled — this propagation prevents UI +spinners from spinning forever. + +Sub-agent children are cancelled together with their parent because +cancellation iterates every in-flight tool block in `toolBlockByCallId`, +not just the current pointer. + +## Sub-agent nesting (PR-K) + +When the main agent delegates to a sub-agent (the `Task` tool, or +equivalent), the daemon stamps `parentToolCallId` and `subagentType` on +the **child** tool calls via `tool_call._meta`. The reducer reads both +and: + +- Mirrors `parentToolCallId` + `subagentType` onto + `DaemonToolTranscriptBlock` +- Resolves `parentBlockId` (the parent's transcript block `id`) when the + parent block is already in state; otherwise leaves it `undefined` and + back-fills when the parent block later appears + +Out-of-order arrival (child before parent) is handled transparently. A +child whose parent gets trimmed by `maxBlocks` keeps `parentToolCallId` +for selector queries, but `parentBlockId` is nulled (the dangling id +would no longer resolve via `blockIndexById`). + +```ts +import { + selectSubagentChildBlocks, + isSubagentChildBlock, +} from '@qwen-code/sdk/daemon'; + +// Render a parent tool block, then walk children: +function renderToolBlock(state, block) { + if (block.kind !== 'tool') return renderOther(block); + const children = selectSubagentChildBlocks(state, block.toolCallId); + return ( + + {children.length > 0 && ( + + {children.map((c) => renderToolBlock(state, c))} + + )} + + ); +} + +// Or filter top-level vs. nested at render time: +const topLevel = state.blocks.filter((b) => !isSubagentChildBlock(b)); +``` + +`selectSubagentChildBlocks` returns **direct** children only. Walk +recursively to render nested sub-agents (a sub-agent inside a +sub-agent). Daemon does not emit cycles, but renderers walking up via +`parentBlockId` should still detect them defensively (e.g., depth cap or +visited set). + +Self-references (`parentToolCallId === toolCallId`) are dropped by the +normalizer before reaching the reducer. + +## Time semantics (PR-B) + +```ts +interface DaemonTranscriptBlockBase { + eventId?: number; // PRIMARY sort key — daemon-monotonic + serverTimestamp?: number; // PREFERRED display — daemon-authoritative + clientReceivedAt: number; // FALLBACK — local clock + createdAt: number; // @deprecated alias for clientReceivedAt +} +``` + +**Always sort by `eventId`** (use `selectTranscriptBlocksOrderedByEventId`) +when displaying long sessions. The daemon-monotonic cursor is preserved +across SSE replay-after-reconnect; client clocks are not. + +**Always format display timestamps from `serverTimestamp`** (with +fallback to `clientReceivedAt`). Multiple clients viewing the same session +see the same "5 minutes ago" only when both read from the daemon clock. + +```ts +import { formatBlockTimestamp } from '@qwen-code/sdk/daemon'; + +const label = formatBlockTimestamp(block, { + locale: 'zh-CN', + timeZone: 'Asia/Shanghai', + timeStyle: 'short', +}); +``` + +## Adapter conformance (PR-G) + +Validate your adapter projects the SDK's reference corpus to semantically +equivalent output: + +```ts +import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; + +it('my adapter conforms to daemon UI corpus', () => { + const result = runAdapterConformanceSuite({ + reduce: (events) => myReducer(events), + renderToText: (state) => myRenderer(state), + }); + expect(result.failed).toEqual([]); +}); +``` + +The fixture corpus (`DAEMON_UI_CONFORMANCE_FIXTURES`) covers chat, tool +lifecycle, file edits, MCP, permissions, MCP budget warning, cancellation, +malformed payload redaction, OAuth, command updates, and sub-agent +nesting. (Count is derivable at runtime — read +`DAEMON_UI_CONFORMANCE_FIXTURES.length`.) + +**Format-agnostic** — your adapter can render to ANSI / HTML / markdown / +JSX; the framework only checks semantic content via `expectedContains` and +`expectedAbsent`. + +## Error categorization (PR-A) + +`DaemonUiErrorEvent.errorKind` is a closed-enum propagated from the +daemon's typed-error taxonomy (when the daemon stamps it): + +```ts +import type { DaemonErrorKind } from '@qwen-code/sdk/daemon'; +// 'missing_binary' | 'blocked_egress' | 'auth_env_error' | 'init_timeout' +// | 'protocol_error' | 'missing_file' | 'parse_error' | 'budget_exhausted' +``` + +Renderers should branch on `errorKind` for actionable affordances: + +```ts +function errorAffordance(errorKind?: DaemonErrorKind): React.ReactNode { + switch (errorKind) { + case 'auth_env_error': return ; + case 'missing_file': return ; + case 'blocked_egress': return Network blocked — check proxy; + default: return null; + } +} +``` + +## Tool provenance dispatch (PR-A) + +`DaemonUiToolUpdateEvent.provenance` is a closed-enum (`builtin` / `mcp` / +`subagent` / `unknown`). With `serverId?: string` when `mcp`. Use it for +icon dispatch and badging: + +```ts +function toolIcon(event: DaemonUiToolUpdateEvent): React.ReactNode { + switch (event.provenance) { + case 'mcp': return ; + case 'subagent': return ; + case 'builtin': return ; + default: return ; + } +} +``` + +The SDK has a `mcp____` naming heuristic fallback — even +when daemon doesn't explicitly stamp provenance, MCP tools are detectable. + +## Forward-compat principles + +Every layer in the daemon UI SDK follows the **forward-compat principle**: +unknown values do NOT throw; they degrade gracefully. + +- Unknown daemon event types → `debug` event with the raw type name +- Unknown tool status → `currentToolCallId` left untouched (no clear) +- Unknown error kind → `errorKind` undefined (renderer falls back to text) +- Missing serverTimestamp → falls back to `clientReceivedAt` +- Unrecognized preview shape → `generic` kind with `summary` + +This means **SDK can ship ahead of daemon emission**. PR-A's tool +provenance heuristic, PR-B's three-location timestamp extraction, and +PR-E's unknown-status preservation are all examples of "ready when daemon +sends; safe when it doesn't." + +## Cross-references + +- [PR #4328](https://github.com/QwenLM/qwen-code/pull/4328) — base PR with the shared UI transcript layer +- [PR #4353](https://github.com/QwenLM/qwen-code/pull/4353) — this PR (unified completeness follow-up) +- [Issue #3803](https://github.com/QwenLM/qwen-code/issues/3803) — daemon mode proposal +- [Issue #4175](https://github.com/QwenLM/qwen-code/issues/4175) — Mode B v0.16 implementation tracker diff --git a/package-lock.json b/package-lock.json index b626c4ef4d..393de2a629 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18650,7 +18650,7 @@ }, "packages/sdk-typescript": { "name": "@qwen-code/sdk", - "version": "0.1.7", + "version": "0.1.8", "license": "Apache-2.0", "dependencies": { "@modelcontextprotocol/sdk": "^1.25.1", @@ -22264,7 +22264,7 @@ "version": "0.15.11", "license": "MIT", "dependencies": { - "@qwen-code/sdk": "~0.1.7", + "@qwen-code/sdk": "~0.1.8", "markdown-it": "^14.1.0" }, "devDependencies": { diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index 07931f73bc..bf65473767 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -1,6 +1,6 @@ { "name": "@qwen-code/sdk", - "version": "0.1.7", + "version": "0.1.8", "description": "TypeScript SDK for programmatic access to qwen-code CLI", "main": "./dist/index.cjs", "module": "./dist/index.mjs", diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index d71c26700d..3474b34f2f 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -50,20 +50,44 @@ export { createDaemonTranscriptState, createDaemonTranscriptStore, DAEMON_PLAN_TOOL_CALL_ID, + daemonBlockToHtml, + daemonBlockToMarkdown, + daemonBlockToPlainText, + daemonToolPreviewToMarkdown, daemonUiEventToTerminalText, + extractContentPart, + formatBlockTimestamp, getOutputText as getDaemonUiOutputText, getSessionUpdatePayload, isDaemonUiSensitiveKey, + isSubagentChildBlock, normalizeDaemonEvent, redactDaemonUiSensitiveFields, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, + runAdapterConformanceSuite, sanitizeTerminalText as sanitizeDaemonTerminalText, + selectApprovalMode, + selectCurrentTool, selectPendingPermissionBlocks, + selectSubagentChildBlocks, + selectToolProgress, selectTranscriptBlocks, + selectTranscriptBlocksOrderedByEventId, stringifyJson as stringifyDaemonUiJson, stripOscSequences as stripDaemonOscSequences, transcriptBlockToTerminalText, + DAEMON_UI_CONFORMANCE_FIXTURES, +} from './ui/index.js'; +export type { + DaemonRenderOptions, + DaemonHtmlRenderOptions, + DaemonUiContentPart, + DaemonUiAdapterUnderTest, + DaemonUiConformanceFixture, + ConformanceFailure, + ConformanceSuiteResult, + RunConformanceOptions, } from './ui/index.js'; export type { DaemonShellTranscriptBlock, @@ -76,22 +100,42 @@ export type { DaemonTranscriptQuestion, DaemonTranscriptQuestionOption, DaemonTranscriptReducerOptions, + DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, DaemonUiAssistantDoneEvent, + DaemonUiAuthDeviceFlowAuthorizedEvent, + DaemonUiAuthDeviceFlowCancelledEvent, + DaemonUiAuthDeviceFlowEvent, + DaemonUiAuthDeviceFlowFailedEvent, + DaemonUiAuthDeviceFlowStartedEvent, + DaemonUiAuthDeviceFlowThrottledEvent, DaemonUiErrorEvent, DaemonUiEvent, DaemonUiEventBase, DaemonUiEventType, + DaemonUiMcpBudgetWarningEvent, + DaemonUiMcpChildRefusedEvent, + DaemonUiMcpServerRestartedEvent, + DaemonUiMcpServerRestartRefusedEvent, DaemonUiModelChangedEvent, DaemonUiPermissionOption, DaemonUiPermissionRequestEvent, DaemonUiPermissionResolvedEvent, DaemonUiSessionActions, + DaemonUiSessionApprovalModeChangedEvent, + DaemonUiSessionAvailableCommandsEvent, + DaemonUiSessionMetadataChangedEvent, DaemonUiShellOutputEvent, + DaemonUiStateResyncRequiredEvent, DaemonUiStatusEvent, DaemonUiTextEvent, + DaemonUiToolProvenance, DaemonUiToolUpdateEvent, + DaemonUiWorkspaceAgentChangedEvent, + DaemonUiWorkspaceInitializedEvent, + DaemonUiWorkspaceMemoryChangedEvent, + DaemonUiWorkspaceToolToggledEvent, NormalizeDaemonEventOptions, } from './ui/index.js'; export { diff --git a/packages/sdk-typescript/src/daemon/ui/conformance.ts b/packages/sdk-typescript/src/daemon/ui/conformance.ts new file mode 100644 index 0000000000..feaca56912 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/conformance.ts @@ -0,0 +1,559 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PR-G — Adapter conformance framework. + * + * 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 drift early — when adapter authors implement + * `reduce` + `render` themselves, this framework asserts the result matches + * the SDK's reference projection. + * + * ## Adapter contract + * + * Implement `DaemonUiAdapterUnderTest`: + * + * - `reduce(events)`: take a list of normalized UI events and produce + * adapter-specific state (any shape). + * - `renderToText(state)`: collapse that state to a plain-text string + * for semantic comparison. **Format-agnostic** — assertion is on text + * content, not on HTML / ANSI / markdown specifics. + * + * Adapters are free to use richer outputs (HTML, ANSI, JSX) — the test + * framework only checks that the *semantic content* matches the reference. + * + * ## Usage (in adapter test file) + * + * ```ts + * import { runAdapterConformanceSuite } from '@qwen-code/sdk/daemon'; + * import { reduceForTui, renderTuiState } from './my-tui-adapter'; + * + * const result = runAdapterConformanceSuite({ + * reduce: reduceForTui, + * renderToText: renderTuiState, + * }); + * expect(result.failed).toEqual([]); + * ``` + * + * Or run a single fixture: + * + * ```ts + * const fx = DAEMON_UI_CONFORMANCE_FIXTURES.find((f) => f.name === 'simple-chat'); + * const out = adapter.renderToText(adapter.reduce(fx.events)); + * for (const phrase of fx.expectedContains) expect(out).toContain(phrase); + * for (const phrase of fx.expectedAbsent ?? []) expect(out).not.toContain(phrase); + * ``` + */ + +import type { DaemonUiEvent } from './types.js'; +import { normalizeDaemonEvent } from './normalizer.js'; + +export interface DaemonUiAdapterUnderTest { + /** + * Reduce a sequence of normalized UI events into adapter-specific state. + * The state shape is opaque to the framework — only `renderToText` is + * inspected. + */ + reduce(events: readonly DaemonUiEvent[]): unknown; + /** + * Project the reduced state to a single plain-text string for semantic + * comparison. **Implementation choices**: + * + * - Strip ANSI / HTML / markdown delimiters so assertions are + * format-agnostic + * - Concatenate blocks with reasonable separators (e.g., `\n\n`) + * - Include tool titles, status, permission outcomes, error text + * - Skip debug / status blocks if your renderer hides them + */ + renderToText(state: unknown): string; +} + +/** + * One fixture: a recorded sequence of daemon envelopes paired with the + * semantic content any conforming adapter must surface (and optionally + * content it MUST NOT surface, for forward-compat guard fixtures). + */ +export interface DaemonUiConformanceFixture { + /** Human-readable name for test output. */ + name: string; + /** + * One-line description — what scenario the fixture exercises. + */ + description: string; + /** + * Raw daemon envelopes. These get fed through `normalizeDaemonEvent` to + * produce the `DaemonUiEvent[]` passed to the adapter's `reduce`. + */ + envelopes: ReadonlyArray<{ + id?: number; + v: 1; + type: string; + data: unknown; + originatorClientId?: string; + _meta?: Record; + }>; + /** + * Substrings the rendered output MUST contain. Each is asserted + * independently; partial matches are OK. Use these for content-level + * assertions ("transcript shows 'hello world'", "tool block shows + * 'completed'"). + */ + expectedContains: readonly string[]; + /** + * Substrings the rendered output MUST NOT contain. Use for guard + * fixtures: "secret token must not leak", "raw event data must not + * be dumped on malformed payload". + */ + expectedAbsent?: readonly string[]; + /** + * Optional normalization options forwarded to `normalizeDaemonEvent`. + */ + normalizeOptions?: { + clientId?: string; + suppressOwnUserEcho?: boolean; + includeRawEvent?: boolean; + }; +} + +export interface ConformanceFailure { + fixture: string; + missingPhrases: readonly string[]; + leakedPhrases: readonly string[]; + /** Truncated rendered output for diagnosis. */ + renderedExcerpt: string; +} + +export interface ConformanceSuiteResult { + passed: number; + failed: ConformanceFailure[]; + total: number; +} + +export interface RunConformanceOptions { + /** Specific fixtures to run; omitted = all. */ + only?: readonly string[]; + /** Skip these fixture names. */ + skip?: readonly string[]; +} + +/** + * Run the built-in fixture corpus against an adapter and return per-fixture + * pass/fail. **Does not throw** — caller asserts on `result.failed`. + */ +export function runAdapterConformanceSuite( + adapter: DaemonUiAdapterUnderTest, + opts: RunConformanceOptions = {}, +): ConformanceSuiteResult { + const fixtures = filterFixtures(DAEMON_UI_CONFORMANCE_FIXTURES, opts); + const failed: ConformanceFailure[] = []; + let passed = 0; + for (const fx of fixtures) { + // wenshao R5 (qwen3.7-max): wrap adapter calls in try/catch so an + // adapter throw is reported as a fixture failure (with the error + // captured in `renderedExcerpt`) instead of aborting the whole + // suite. JSDoc promises "does not throw"; without the wrapper the + // promise was broken by adapter authors writing buggy reducers. + let rendered: string; + try { + const events = fx.envelopes.flatMap((env) => + normalizeDaemonEvent(env as never, fx.normalizeOptions ?? {}), + ); + const state = adapter.reduce(events); + rendered = adapter.renderToText(state); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + failed.push({ + fixture: fx.name, + missingPhrases: fx.expectedContains, + leakedPhrases: [], + renderedExcerpt: `[adapter threw: ${msg.slice(0, 360)}]`, + }); + continue; + } + const missing = fx.expectedContains.filter( + (phrase) => !rendered.includes(phrase), + ); + const leaked = (fx.expectedAbsent ?? []).filter((phrase) => + rendered.includes(phrase), + ); + if (missing.length === 0 && leaked.length === 0) { + passed += 1; + } else { + failed.push({ + fixture: fx.name, + missingPhrases: missing, + leakedPhrases: leaked, + renderedExcerpt: + rendered.length > 400 ? `${rendered.slice(0, 400)}…` : rendered, + }); + } + } + return { passed, failed, total: fixtures.length }; +} + +function filterFixtures( + fixtures: readonly DaemonUiConformanceFixture[], + opts: RunConformanceOptions, +): readonly DaemonUiConformanceFixture[] { + let out = fixtures; + if (opts.only && opts.only.length > 0) { + const set = new Set(opts.only); + out = out.filter((fx) => set.has(fx.name)); + } + if (opts.skip && opts.skip.length > 0) { + const set = new Set(opts.skip); + out = out.filter((fx) => !set.has(fx.name)); + } + return out; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Fixture corpus — embedded in source for portability (browser-safe; no fs). + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Built-in conformance fixtures. Adapter authors run these against their + * `reduce` + `renderToText` to catch projection drift before it reaches + * users. + * + * Categorized: + * - **chat**: basic user/assistant/thought flow + * - **tool**: tool call lifecycle with preview projection + * - **permission**: permission request + resolution + * - **mcp**: MCP-specific events (budget warning, restart) + * - **auth**: device-flow lifecycle + * - **multimodal-text-only**: forward-compat hint — multimodal not yet + * wired (see PR #4353 TODO §D) + * - **trim**: long-session block trim behavior + * - **redaction**: malformed payloads must not leak raw fields + */ +export const DAEMON_UI_CONFORMANCE_FIXTURES: readonly DaemonUiConformanceFixture[] = + [ + { + name: 'simple-chat', + description: + 'User says hello, assistant streams a two-chunk response, marks done.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello world' }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi ' }, + }, + }, + }, + { + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'there' }, + }, + }, + }, + ], + expectedContains: ['hello world', 'hi there'], + }, + { + name: 'tool-call-lifecycle', + description: + 'Tool runs, completes; preview surfaces command, status shows completed.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 't1', + title: 'Run npm test', + status: 'running', + rawInput: { command: 'npm test', cwd: '/work' }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 't1', + status: 'completed', + rawOutput: 'all tests pass', + }, + }, + }, + ], + expectedContains: ['Run npm test', 'npm test', 'completed'], + }, + { + name: 'file-edit-diff', + description: + 'File edit tool produces file_diff preview surfaceable as unified diff.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'edit-1', + title: 'Edit auth.ts', + status: 'completed', + rawInput: { + path: '/work/auth.ts', + oldText: 'function login() { /* TODO */ }', + newText: 'function login() { return token; }', + }, + }, + }, + }, + ], + expectedContains: ['/work/auth.ts', 'return token'], + }, + { + name: 'mcp-invocation', + description: + 'MCP tool call surfaces serverId + toolName via heuristic naming.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'mcp-1', + title: 'Create issue', + status: 'completed', + name: 'mcp__github__create_issue', + rawInput: { repo: 'qwen-code', title: 'Bug' }, + }, + }, + }, + ], + expectedContains: ['github', 'create_issue'], + }, + { + name: 'permission-lifecycle', + description: + 'Permission requested, then resolved with `selected:allow` outcome.', + envelopes: [ + { + id: 1, + v: 1, + type: 'permission_request', + data: { + requestId: 'perm-1', + sessionId: 'sess-1', + toolCall: { name: 'Bash', command: 'rm -rf /tmp/cache' }, + options: [ + { optionId: 'allow', label: 'Allow once' }, + { optionId: 'deny', label: 'Deny' }, + ], + }, + }, + { + id: 2, + v: 1, + type: 'permission_resolved', + data: { + requestId: 'perm-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + }, + ], + expectedContains: ['Allow once', 'selected:allow'], + }, + { + name: 'mcp-budget-warning', + description: + 'MCP budget warning event surfaces threshold + counts (PR-A coverage).', + envelopes: [ + { + id: 1, + v: 1, + type: 'mcp_budget_warning', + data: { + liveCount: 6, + reservedCount: 2, + budget: 8, + thresholdRatio: 0.75, + mode: 'warn', + }, + }, + ], + // No expectedContains — depending on adapter, this event may surface + // as a status banner or be hidden. The contract is: the adapter MUST + // observe the event (lastEventId advances) but can choose its + // rendering. Fixture exists to verify the adapter does not throw. + expectedContains: [], + }, + { + name: 'cancellation-propagates', + description: + 'Cancelled assistant turn marks in-flight tool blocks as cancelled.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'long-task', + title: 'Long task', + status: 'running', + }, + }, + }, + ], + // Stream the assistant.done(cancelled) via a synthetic envelope: + // since this is a derived UI event not a daemon event, the conformance + // suite uses an out-of-band marker — adapters must propagate from + // any 'assistant.done' event with reason=cancelled. (Fixture limited + // by daemon envelope shape; see real integration tests for full + // cancellation flow.) + expectedContains: ['Long task'], + }, + { + name: 'malformed-payload-redaction', + description: + 'Known event type with malformed payload falls back to debug. Even with `includeRawEvent: true` a conforming adapter must not dump the raw payload into rendered text. Uses a non-sensitive field name so SDK normalizer redaction (which auto-cleans `token`/`secret`/`apiKey`/etc.) does NOT pre-empt the test — the conformance framework itself catches the leak.', + envelopes: [ + { + id: 1, + v: 1, + type: 'mcp_budget_warning', + data: { notes: 'must-not-leak-malformed-payload', random: 'junk' }, + }, + ], + normalizeOptions: { includeRawEvent: true }, + expectedContains: [], + expectedAbsent: ['must-not-leak-malformed-payload'], + }, + { + name: 'auth-device-flow-success', + description: + 'OAuth device-flow lifecycle (started → authorized) renders provider + status.', + envelopes: [ + { + id: 1, + v: 1, + type: 'auth_device_flow_started', + data: { + deviceFlowId: 'df-1', + providerId: 'qwen', + expiresAt: 1_900_000_000_000, + }, + }, + { + id: 2, + v: 1, + type: 'auth_device_flow_authorized', + data: { + deviceFlowId: 'df-1', + providerId: 'qwen', + accountAlias: 'alice', + }, + }, + ], + expectedContains: [], + }, + { + name: 'available-commands-typed-event', + description: + 'available_commands_update upgraded from status text to typed event (PR-A); not a status block.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'memory' }, + { name: 'mcp' }, + { name: 'agents' }, + ], + }, + }, + }, + ], + expectedContains: [], + expectedAbsent: ['Available commands updated'], + }, + { + name: 'subagent-nesting', + description: + 'PR-K: tool calls invoked inside a sub-agent delegation carry parentToolCallId + subagentType via tool_call._meta. The parent Task tool call lands first, then a grep tool call from inside the sub-agent. Adapters must render both blocks without throwing; nested-aware adapters should be able to identify the sub-agent child via parentToolCallId. Order-resilient: the child arrives after the parent.', + envelopes: [ + { + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'task-1', + title: 'Delegate to code-reviewer', + status: 'running', + name: 'Task', + rawInput: { + subagent_type: 'code-reviewer', + prompt: 'review the diff', + }, + }, + }, + }, + { + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'grep-1', + title: 'grep -r TODO src/', + status: 'completed', + rawInput: { pattern: 'TODO', path: 'src/' }, + _meta: { + parentToolCallId: 'task-1', + subagentType: 'code-reviewer', + }, + }, + }, + }, + ], + // Phrases chosen to be markdown-safe: backslash escaping of `-` in + // titles means we cannot rely on substrings containing hyphens. + // Sub-agent type token appears in backticks (unescaped). `TODO` is + // a clean substring from the child's rawInput. + expectedContains: ['code-reviewer', 'review the diff', 'TODO'], + }, + ]; diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 1628e8e589..4477d608a7 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -9,10 +9,17 @@ export { createDaemonToolPreview } from './toolPreview.js'; export { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + formatBlockTimestamp, + isSubagentChildBlock, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, + selectApprovalMode, + selectCurrentTool, selectPendingPermissionBlocks, + selectSubagentChildBlocks, + selectToolProgress, selectTranscriptBlocks, + selectTranscriptBlocksOrderedByEventId, } from './transcript.js'; export { createDaemonTranscriptStore } from './store.js'; export { @@ -20,6 +27,25 @@ export { transcriptBlockToTerminalText, } from './terminal.js'; export { + daemonBlockToHtml, + daemonBlockToMarkdown, + daemonBlockToPlainText, + daemonToolPreviewToMarkdown, +} from './render.js'; +export type { DaemonHtmlRenderOptions, DaemonRenderOptions } from './render.js'; +export { + DAEMON_UI_CONFORMANCE_FIXTURES, + runAdapterConformanceSuite, +} from './conformance.js'; +export type { + ConformanceFailure, + ConformanceSuiteResult, + DaemonUiAdapterUnderTest, + DaemonUiConformanceFixture, + RunConformanceOptions, +} from './conformance.js'; +export { + extractContentPart, getOutputText, isSensitiveKey as isDaemonUiSensitiveKey, redactSensitiveFields as redactDaemonUiSensitiveFields, @@ -28,6 +54,7 @@ export { stripOscSequences, } from './utils.js'; export { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; +export type { DaemonUiContentPart } from './utils.js'; export type { DaemonShellTranscriptBlock, DaemonStatusTranscriptBlock, @@ -39,8 +66,10 @@ export type { DaemonTranscriptQuestion, DaemonTranscriptQuestionOption, DaemonTranscriptReducerOptions, + DaemonTranscriptSidechannelState, DaemonTranscriptState, DaemonTranscriptStore, + // Chat-stream events DaemonUiAssistantDoneEvent, DaemonUiErrorEvent, DaemonUiEvent, @@ -55,5 +84,27 @@ export type { DaemonUiStatusEvent, DaemonUiTextEvent, DaemonUiToolUpdateEvent, + DaemonUiToolProvenance, + // Session-meta events + DaemonUiSessionMetadataChangedEvent, + DaemonUiSessionApprovalModeChangedEvent, + DaemonUiSessionAvailableCommandsEvent, + DaemonUiStateResyncRequiredEvent, + // Workspace events + DaemonUiWorkspaceMemoryChangedEvent, + DaemonUiWorkspaceAgentChangedEvent, + DaemonUiWorkspaceToolToggledEvent, + DaemonUiWorkspaceInitializedEvent, + DaemonUiMcpBudgetWarningEvent, + DaemonUiMcpChildRefusedEvent, + DaemonUiMcpServerRestartedEvent, + DaemonUiMcpServerRestartRefusedEvent, + // Auth device-flow events + DaemonUiAuthDeviceFlowEvent, + DaemonUiAuthDeviceFlowStartedEvent, + DaemonUiAuthDeviceFlowThrottledEvent, + DaemonUiAuthDeviceFlowAuthorizedEvent, + DaemonUiAuthDeviceFlowFailedEvent, + DaemonUiAuthDeviceFlowCancelledEvent, NormalizeDaemonEventOptions, } from './types.js'; diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 4e028ea01a..86dd77c98f 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -4,10 +4,17 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { DaemonEvent } from '../types.js'; +import type { + DaemonAuthDeviceFlowSdkErrorKind, + DaemonAuthProviderId, + DaemonErrorKind, + DaemonEvent, +} from '../types.js'; +import { DAEMON_ERROR_KINDS } from '../types.js'; import type { DaemonUiEvent, DaemonUiPermissionOption, + DaemonUiToolProvenance, NormalizeDaemonEventOptions, } from './types.js'; import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; @@ -22,6 +29,24 @@ import { stringifyRedactedJson, } from './utils.js'; +/** + * Common base fields stamped on every normalized UI event. Centralized as a + * type alias so adding new envelope fields (e.g., `serverTimestamp` in PR-B, + * `traceId` in future) doesn't require touching every normalizer helper. + */ +type NormalizedEventBase = Pick< + DaemonUiEvent, + 'eventId' | 'serverTimestamp' | 'originatorClientId' | 'rawEvent' +>; + +const DAEMON_ERROR_KIND_SET = new Set(DAEMON_ERROR_KINDS); +const DEVICE_FLOW_PROVIDER_SET = new Set(['qwen', 'qwen-oauth']); +const MCP_RESTART_REFUSED_REASONS = new Set([ + 'in_flight', + 'disabled', + 'budget_would_exceed', +]); + const MAX_DETAILS_LENGTH = 4096; export function normalizeDaemonEvent( @@ -70,17 +95,22 @@ export function normalizeDaemonEvent( 'Model switch failed (no details available)', }, ]; - case 'session_died': + case 'session_died': { + // doudouOUC review: hoist `asDaemonErrorKind` to a const — original + // double-eval walked the record + Set twice per event. + const errorKind = asDaemonErrorKind(getString(event.data, 'errorKind')); return [ { ...base, type: 'error', recoverable: false, + ...(errorKind ? { errorKind } : {}), text: getString(event.data, 'reason') ?? 'Session died (no details available)', }, ]; + } case 'session_closed': return [ { @@ -108,39 +138,127 @@ export function normalizeDaemonEvent( text: 'SSE stream is lagging', }, ]; - case 'stream_error': + case 'stream_error': { + const errorKind = asDaemonErrorKind(getString(event.data, 'errorKind')); return [ { ...base, type: 'error', recoverable: true, + ...(errorKind ? { errorKind } : {}), text: getString(event.data, 'error') ?? 'SSE stream error (no details available)', }, ]; + } + case 'state_resync_required': + return normalizeStateResyncRequired(event, base); + + // ── Session-meta events ────────────────────────────────────────────── + case 'session_metadata_updated': + return normalizeSessionMetadataUpdated(event, base); + + case 'approval_mode_changed': + return normalizeApprovalModeChanged(event, base); + + // ── Workspace events (Wave 3-4) ────────────────────────────────────── + case 'memory_changed': + return normalizeMemoryChanged(event, base); + + case 'agent_changed': + return normalizeAgentChanged(event, base); + + case 'tool_toggled': + return normalizeToolToggled(event, base); + + case 'workspace_initialized': + return normalizeWorkspaceInitialized(event, base); + + case 'mcp_budget_warning': + return normalizeMcpBudgetWarning(event, base); + + case 'mcp_child_refused_batch': + return normalizeMcpChildRefused(event, base); + + case 'mcp_server_restarted': + return normalizeMcpServerRestarted(event, base); + + case 'mcp_server_restart_refused': + return normalizeMcpServerRestartRefused(event, base); + + // ── Auth device-flow events (Wave 4 OAuth, RFC 8628) ───────────────── + case 'auth_device_flow_started': + return normalizeAuthDeviceFlowStarted(event, base); + + case 'auth_device_flow_throttled': + return normalizeAuthDeviceFlowThrottled(event, base); + + case 'auth_device_flow_authorized': + return normalizeAuthDeviceFlowAuthorized(event, base); + + case 'auth_device_flow_failed': + return normalizeAuthDeviceFlowFailed(event, base); + + case 'auth_device_flow_cancelled': + return normalizeAuthDeviceFlowCancelled(event, base); + default: + // wenshao R5 (qwen3.7-max): emit a single `debug` block instead + // of `status + debug`. In long sessions where the daemon adds + // unknown event types, the doubled block-consumption rate + // accelerated `maxBlocks` trimming of real content. The `debug` + // shape already carries the event-type as a prefix, so the + // status block was redundant. Adapters that want a user-visible + // banner can pattern-match on `event.type === 'debug'` and the + // text prefix. return [ - { - ...base, - type: 'status', - text: `${event.type} (unrecognized daemon event)`, - }, { ...base, type: 'debug', - text: `${event.type}: ${stringifyRedactedJson(event.data)}`, + text: `${event.type} (unrecognized daemon event): ${stringifyRedactedJson(event.data)}`, }, ]; } } +function normalizeStateResyncRequired( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const reason = getString(event.data, 'reason'); + const lastDeliveredId = numberField(event.data, 'lastDeliveredId'); + const earliestAvailableId = numberField(event.data, 'earliestAvailableId'); + if ( + !reason || + lastDeliveredId === undefined || + earliestAvailableId === undefined + ) { + return fallbackDebug( + event, + base, + 'malformed state_resync_required payload', + ); + } + return [ + { + ...base, + type: 'session.state_resync_required', + reason, + lastDeliveredId, + earliestAvailableId, + }, + ]; +} + function createBase( event: DaemonEvent, opts: NormalizeDaemonEventOptions, -): Pick { +): NormalizedEventBase { + const serverTimestamp = extractServerTimestamp(event); return { ...(event.id !== undefined ? { eventId: event.id } : {}), + ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), ...(event.originatorClientId ? { originatorClientId: event.originatorClientId } : {}), @@ -150,9 +268,39 @@ function createBase( }; } +/** + * Extract daemon-authoritative timestamp from envelope. Looks at three + * candidate locations in order: + * + * 1. `event.serverTimestamp` — top-level, preferred when daemon adds it + * 2. `event._meta.serverTimestamp` — Anthropic-style metadata convention + * 3. `event.data._meta.serverTimestamp` — sessionUpdate nested location + * + * Returns undefined when none of them are present or all are non-finite. + * Forward-compat: SDK reads whichever location the daemon eventually emits + * without requiring a coordinated SDK release. + */ +function extractServerTimestamp(event: DaemonEvent): number | undefined { + const direct = (event as { serverTimestamp?: unknown }).serverTimestamp; + if (typeof direct === 'number' && Number.isFinite(direct)) return direct; + const envelopeMeta = (event as { _meta?: unknown })._meta; + if (isRecord(envelopeMeta)) { + const ts = envelopeMeta['serverTimestamp']; + if (typeof ts === 'number' && Number.isFinite(ts)) return ts; + } + if (isRecord(event.data)) { + const dataMeta = (event.data as Record)['_meta']; + if (isRecord(dataMeta)) { + const ts = dataMeta['serverTimestamp']; + if (typeof ts === 'number' && Number.isFinite(ts)) return ts; + } + } + return undefined; +} + function normalizeSessionUpdate( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, opts: NormalizeDaemonEventOptions, ): DaemonUiEvent[] { const update = getSessionUpdatePayload(event.data); @@ -206,14 +354,18 @@ function normalizeSessionUpdate( : []; } case 'available_commands_update': { - const commands = Array.isArray(update['availableCommands']) + const rawCommands = Array.isArray(update['availableCommands']) ? update['availableCommands'] : []; + const commands = rawCommands.filter(isRecord) as ReadonlyArray< + Record + >; return [ { ...base, - type: 'status', - text: `Available commands updated (${commands.length})`, + type: 'session.available_commands', + count: commands.length, + commands, }, ]; } @@ -232,7 +384,7 @@ function normalizeSessionUpdate( function normalizeToolUpdate( update: Record, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent { const metadata = isRecord(update['_meta']) ? update['_meta'] : undefined; const toolName = @@ -275,6 +427,27 @@ function normalizeToolUpdate( text: `Tool update missing toolCallId${title ? ` (${title})` : ''}`, }; } + const { provenance, serverId } = extractToolProvenance(update, toolName); + // PR-K (post-rebase): daemon stamps `parentToolCallId` + `subagentType` in + // `tool_call._meta` when the call was invoked inside a sub-agent + // delegation (see core's `SubAgentTracker.getSubagentMeta()`). Forward + // these into the typed UI event so the reducer can correlate sub-agent + // blocks under their parent for nested rendering. Both undefined for + // top-level (non-sub-agent) tool calls. + // + // Self-reference guard: defensively drop `parentToolCallId === toolCallId`. + // The daemon should never emit this, but accepting it would make the + // block its own parent — selectors loop, renderers cycle. + const rawParentToolCallId = + getString(update, 'parentToolCallId') ?? + (metadata ? getString(metadata, 'parentToolCallId') : undefined); + const parentToolCallId = + rawParentToolCallId && rawParentToolCallId !== toolCallId + ? rawParentToolCallId + : undefined; + const subagentType = + getString(update, 'subagentType') ?? + (metadata ? getString(metadata, 'subagentType') : undefined); return { ...base, type: 'tool.update', @@ -285,6 +458,10 @@ function normalizeToolUpdate( ...(toolKind ? { toolKind } : {}), ...(content !== undefined ? { content } : {}), ...(locations !== undefined ? { locations } : {}), + ...(provenance ? { provenance } : {}), + ...(serverId ? { serverId } : {}), + ...(parentToolCallId ? { parentToolCallId } : {}), + ...(subagentType ? { subagentType } : {}), ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined @@ -342,6 +519,49 @@ function getPlanEntryMarker(status: string | undefined): string { } } +/** + * Pull `provenance` + `serverId` from the tool update payload, falling back + * to the `mcp____` naming convention when the daemon + * doesn't stamp the fields explicitly. Returns `undefined` for both when + * provenance is genuinely unknown — UI defaults to `'unknown'` in that case. + */ +function extractToolProvenance( + update: Record, + toolName: string | undefined, +): { + provenance?: DaemonUiToolProvenance; + serverId?: string; +} { + const explicit = getString(update, 'provenance'); + const explicitServerId = getString(update, 'serverId'); + if (explicit === 'builtin' || explicit === 'mcp' || explicit === 'subagent') { + return { + provenance: explicit, + ...(explicit === 'mcp' && explicitServerId + ? { serverId: explicitServerId } + : {}), + }; + } + // Heuristic fallback: MCP server tools follow `mcp____`. + if (toolName && toolName.startsWith('mcp__')) { + const rest = toolName.slice('mcp__'.length); + const sep = rest.indexOf('__'); + if (sep > 0) { + return { provenance: 'mcp', serverId: rest.slice(0, sep) }; + } + } + return {}; +} + +function asDaemonErrorKind( + value: string | undefined, +): DaemonErrorKind | undefined { + if (!value) return undefined; + return DAEMON_ERROR_KIND_SET.has(value) + ? (value as DaemonErrorKind) + : undefined; +} + function capDetails(details: string): string { if (details.length <= MAX_DETAILS_LENGTH) return details; return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; @@ -349,7 +569,7 @@ function capDetails(details: string): string { function normalizePermissionRequest( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { if (!isRecord(event.data)) { return [ @@ -391,7 +611,7 @@ function normalizePermissionRequest( function normalizePermissionResolved( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const requestId = getString(event.data, 'requestId'); if (!requestId) { @@ -473,3 +693,471 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { const stream = getString(value, 'stream'); return stream === 'stdout' || stream === 'stderr' ? stream : undefined; } + +/* ────────────────────────────────────────────────────────────────────────── + * Session-meta + workspace + auth normalizers (Wave 3-4 coverage) + * + * Each daemon event with a closed-shape `data` interface in `events.ts` gets + * its own normalizer that validates required fields and emits a typed UI + * event. Events with invalid payloads fall through to a `debug` text — UI + * never silently drops a known event type, but malformed data is surfaced + * for operator triage. + * ──────────────────────────────────────────────────────────────────────── */ + +function fallbackDebug( + event: DaemonEvent, + base: NormalizedEventBase, + reason: string, +): DaemonUiEvent[] { + return [ + { + ...base, + type: 'debug', + text: `${event.type}: ${reason}`, + }, + ]; +} + +function normalizeSessionMetadataUpdated( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const sessionId = getString(event.data, 'sessionId'); + if (!sessionId) return fallbackDebug(event, base, 'missing sessionId'); + const displayName = getString(event.data, 'displayName'); + return [ + { + ...base, + type: 'session.metadata.changed', + sessionId, + ...(displayName !== undefined ? { displayName } : {}), + }, + ]; +} + +function normalizeApprovalModeChanged( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const sessionId = getString(event.data, 'sessionId'); + const previous = getString(event.data, 'previous'); + const next = getString(event.data, 'next'); + if (!sessionId || !previous || !next) { + return fallbackDebug(event, base, 'missing sessionId / previous / next'); + } + const persisted = + isRecord(event.data) && typeof event.data['persisted'] === 'boolean' + ? (event.data['persisted'] as boolean) + : false; + return [ + { + ...base, + type: 'session.approval_mode.changed', + sessionId, + previous, + next, + persisted, + }, + ]; +} + +function normalizeMemoryChanged( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const scope = getString(event.data, 'scope'); + const filePath = getString(event.data, 'filePath'); + const mode = getString(event.data, 'mode'); + // wenshao R3 (claude-opus-4-7): use the `numberField` helper so NaN / + // Infinity are rejected — every other numeric field in the normalizer + // already routes through it. A daemon emitting `bytesWritten: NaN` + // would otherwise propagate to renderers as `+NaNb`. + const bytesWritten = numberField( + isRecord(event.data) ? event.data : undefined, + 'bytesWritten', + ); + if ( + (scope !== 'workspace' && scope !== 'global') || + !filePath || + (mode !== 'append' && mode !== 'replace') || + bytesWritten === undefined + ) { + return fallbackDebug(event, base, 'malformed memory_changed payload'); + } + return [ + { + ...base, + type: 'workspace.memory.changed', + scope, + filePath, + mode, + bytesWritten, + }, + ]; +} + +function normalizeAgentChanged( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const change = getString(event.data, 'change'); + const name = getString(event.data, 'name'); + const level = getString(event.data, 'level'); + if ( + (change !== 'created' && change !== 'updated' && change !== 'deleted') || + !name || + (level !== 'project' && level !== 'user') + ) { + return fallbackDebug(event, base, 'malformed agent_changed payload'); + } + return [ + { + ...base, + type: 'workspace.agent.changed', + change, + name, + level, + }, + ]; +} + +function normalizeToolToggled( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const toolName = getString(event.data, 'toolName'); + const enabled = + isRecord(event.data) && typeof event.data['enabled'] === 'boolean' + ? (event.data['enabled'] as boolean) + : undefined; + if (!toolName || enabled === undefined) { + return fallbackDebug(event, base, 'malformed tool_toggled payload'); + } + return [ + { + ...base, + type: 'workspace.tool.toggled', + toolName, + enabled, + }, + ]; +} + +function normalizeWorkspaceInitialized( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const path = getString(event.data, 'path'); + const action = getString(event.data, 'action'); + if ( + !path || + (action !== 'created' && action !== 'overwrote' && action !== 'noop') + ) { + return fallbackDebug( + event, + base, + 'malformed workspace_initialized payload', + ); + } + return [{ ...base, type: 'workspace.initialized', path, action }]; +} + +function normalizeMcpBudgetWarning( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + if (!isRecord(event.data)) { + return fallbackDebug(event, base, 'non-object payload'); + } + const liveCount = numberField(event.data, 'liveCount'); + const reservedCount = numberField(event.data, 'reservedCount'); + const budget = numberField(event.data, 'budget'); + const thresholdRatio = numberField(event.data, 'thresholdRatio'); + const mode = getString(event.data, 'mode'); + if ( + liveCount === undefined || + reservedCount === undefined || + budget === undefined || + thresholdRatio === undefined || + (mode !== 'warn' && mode !== 'enforce') + ) { + return fallbackDebug(event, base, 'malformed mcp_budget_warning payload'); + } + return [ + { + ...base, + type: 'workspace.mcp.budget_warning', + liveCount, + reservedCount, + budget, + thresholdRatio, + mode, + }, + ]; +} + +function normalizeMcpChildRefused( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + if (!isRecord(event.data)) { + return fallbackDebug(event, base, 'non-object payload'); + } + const refusedServers = Array.isArray(event.data['refusedServers']) + ? (event.data['refusedServers'] as unknown[]) + .filter(isRecord) + .map((s) => { + const name = getString(s, 'name'); + const transport = getString(s, 'transport'); + const reason = getString(s, 'reason'); + if (!name || !transport || reason !== 'budget_exhausted') return null; + return { + name, + transport, + reason: 'budget_exhausted' as const, + }; + }) + .filter( + ( + v, + ): v is { + name: string; + transport: string; + reason: 'budget_exhausted'; + } => v !== null, + ) + : []; + const budget = numberField(event.data, 'budget'); + const liveCount = numberField(event.data, 'liveCount'); + const reservedCount = numberField(event.data, 'reservedCount'); + if ( + refusedServers.length === 0 || + budget === undefined || + liveCount === undefined || + reservedCount === undefined + ) { + return fallbackDebug( + event, + base, + 'malformed mcp_child_refused_batch payload', + ); + } + return [ + { + ...base, + type: 'workspace.mcp.child_refused', + refusedServers, + budget, + liveCount, + reservedCount, + }, + ]; +} + +function normalizeMcpServerRestarted( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const serverName = getString(event.data, 'serverName'); + const durationMs = numberField(event.data, 'durationMs'); + if (!serverName || durationMs === undefined) { + return fallbackDebug(event, base, 'malformed mcp_server_restarted payload'); + } + return [ + { + ...base, + type: 'workspace.mcp.server_restarted', + serverName, + durationMs, + }, + ]; +} + +function normalizeMcpServerRestartRefused( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const serverName = getString(event.data, 'serverName'); + const reason = getString(event.data, 'reason'); + if (!serverName || !reason || !MCP_RESTART_REFUSED_REASONS.has(reason)) { + return fallbackDebug( + event, + base, + 'malformed mcp_server_restart_refused payload', + ); + } + return [ + { + ...base, + type: 'workspace.mcp.server_restart_refused', + serverName, + reason: reason as 'in_flight' | 'disabled' | 'budget_would_exceed', + }, + ]; +} + +function normalizeAuthDeviceFlowStarted( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + const providerId = getString(event.data, 'providerId'); + const expiresAt = numberField(event.data, 'expiresAt'); + if ( + !deviceFlowId || + !providerId || + !DEVICE_FLOW_PROVIDER_SET.has(providerId) || + expiresAt === undefined + ) { + return fallbackDebug( + event, + base, + 'malformed auth_device_flow_started payload', + ); + } + return [ + { + ...base, + type: 'auth.device_flow.started', + deviceFlowId, + providerId: providerId as DaemonAuthProviderId, + expiresAt, + }, + ]; +} + +function normalizeAuthDeviceFlowThrottled( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + const intervalMs = numberField(event.data, 'intervalMs'); + if (!deviceFlowId || intervalMs === undefined) { + return fallbackDebug( + event, + base, + 'malformed auth_device_flow_throttled payload', + ); + } + return [ + { + ...base, + type: 'auth.device_flow.throttled', + deviceFlowId, + intervalMs, + }, + ]; +} + +function normalizeAuthDeviceFlowAuthorized( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + const providerId = getString(event.data, 'providerId'); + if ( + !deviceFlowId || + !providerId || + !DEVICE_FLOW_PROVIDER_SET.has(providerId) + ) { + return fallbackDebug( + event, + base, + 'malformed auth_device_flow_authorized payload', + ); + } + const expiresAt = numberField(event.data, 'expiresAt'); + const accountAlias = getString(event.data, 'accountAlias'); + return [ + { + ...base, + type: 'auth.device_flow.authorized', + deviceFlowId, + providerId: providerId as DaemonAuthProviderId, + ...(expiresAt !== undefined ? { expiresAt } : {}), + ...(accountAlias ? { accountAlias } : {}), + }, + ]; +} + +function normalizeAuthDeviceFlowFailed( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + const errorKind = getString(event.data, 'errorKind'); + if (!deviceFlowId || !isDeviceFlowErrorKind(errorKind)) { + return fallbackDebug( + event, + base, + 'malformed auth_device_flow_failed payload', + ); + } + const hint = getString(event.data, 'hint'); + return [ + { + ...base, + type: 'auth.device_flow.failed', + deviceFlowId, + errorKind, + ...(hint ? { hint } : {}), + }, + ]; +} + +/** + * Known closed-set of `DaemonAuthDeviceFlowErrorKind` values, exported as + * documentation of the canonical kinds the daemon emits today. + * + * Reviewers (wenshao + doudouOUC, PR #4353 round 2026-05-23): both + * suggested strict validation against this set. We intentionally keep + * lenient pass-through — the public type + * `DaemonAuthDeviceFlowSdkErrorKind` explicitly includes `(string & {})` + * as a forward-compat escape hatch so future daemon emissions of new + * kinds remain typed-acceptable AND propagate end-to-end without an SDK + * release. The existing test `keeps future auth_device_flow_failed + * errorKind values observable` enforces this contract. + * + * Downstream consumers `switch(errorKind)` exhaustively MUST include a + * `default:` arm for the open `(string & {})` case — the typed + * known-set arms cover the listed kinds. The known set is referenced + * here in code only so it surfaces in IDE hovers / type-doc tooling. + */ +export const KNOWN_DEVICE_FLOW_ERROR_KINDS = [ + 'expired_token', + 'access_denied', + 'invalid_grant', + 'upstream_error', + 'persist_failed', + 'not_found_or_evicted', +] as const satisfies readonly DaemonAuthDeviceFlowSdkErrorKind[]; + +function isDeviceFlowErrorKind( + value: unknown, +): value is DaemonAuthDeviceFlowSdkErrorKind { + // Lenient pass-through. See `KNOWN_DEVICE_FLOW_ERROR_KINDS` above for + // the canonical set; the `(string & {})` arm of the public type + // tolerates anything else for forward-compat. + return typeof value === 'string' && value.trim().length > 0; +} + +function normalizeAuthDeviceFlowCancelled( + event: DaemonEvent, + base: NormalizedEventBase, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + if (!deviceFlowId) { + return fallbackDebug( + event, + base, + 'malformed auth_device_flow_cancelled payload', + ); + } + return [{ ...base, type: 'auth.device_flow.cancelled', deviceFlowId }]; +} + +function numberField(value: unknown, key: string): number | undefined { + if (!isRecord(value)) return undefined; + const v = value[key]; + return typeof v === 'number' && Number.isFinite(v) ? v : undefined; +} diff --git a/packages/sdk-typescript/src/daemon/ui/render.ts b/packages/sdk-typescript/src/daemon/ui/render.ts new file mode 100644 index 0000000000..90065a87af --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/render.ts @@ -0,0 +1,785 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * PR-D — Render contract. + * + * Three helpers that project a `DaemonTranscriptBlock` (or a single + * `DaemonToolPreview`) into a renderable string: + * + * - `daemonBlockToMarkdown` — GFM-compatible markdown for web / docs + * - `daemonBlockToHtml` — sanitized HTML for SSR / webview surfaces + * - `daemonBlockToPlainText` — plain text for copy-paste / logs + * - `daemonToolPreviewToMarkdown` — preview-to-markdown helper used by all + * higher-level renderers (consumers can compose freely) + * + * The render contract is the missing piece behind "any adapter (TUI / web + * / IDE / channel) renders the same transcript identically." TUI uses + * `terminal.ts`'s ANSI projection; this module is the equivalent for the + * other surfaces. + */ + +import type { + DaemonToolPreview, + DaemonTranscriptBlock, + DaemonTranscriptQuestion, +} from './types.js'; +import { sanitizeTerminalText } from './utils.js'; + +export interface DaemonRenderOptions { + /** + * When true, image / file URLs are stripped of authentication tokens + * before rendering. Default: false (caller responsibility). + */ + sanitizeUrls?: boolean; + /** + * Locale for date formatting in any embedded timestamps. Default: + * runtime default. + */ + locale?: string; + /** + * Max length of any single rendered text field. Strings longer than this + * are truncated with an ellipsis. Default: 8192. Set to `Infinity` to + * disable. + */ + maxFieldLength?: number; +} + +const DEFAULT_MAX_FIELD_LENGTH = 8192; + +/* ────────────────────────────────────────────────────────────────────────── + * Markdown + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Render a single transcript block as GFM-compatible markdown. + * + * Producers should call this per block and join with `\n\n` between blocks + * to produce a full transcript document. + */ +export function daemonBlockToMarkdown( + block: DaemonTranscriptBlock, + opts: DaemonRenderOptions = {}, +): string { + const cap = capLength(opts); + const text = (value: string) => cap(sanitizeTerminalText(value)); + switch (block.kind) { + case 'user': + return `**You**\n\n${text(block.text)}`; + case 'assistant': + return text(block.text); + case 'thought': + // wenshao R3 (claude-opus-4-7): blockquote each line so multi-line + // reasoning traces don't escape the `>` indent on newline. + return blockquote(`*thought:* ${text(block.text)}`); + case 'tool': { + const header = renderToolHeader(block, opts); + const previewMd = daemonToolPreviewToMarkdown(block.preview, opts); + const status = `_status: ${escapeMarkdownText(block.status, opts)}_`; + // wenshao R7 verification finding: `block.details` is the + // serialized `rawInput` JSON. When `rawInput.url` contains + // credentials (Basic Auth in userinfo / OAuth in `#fragment` / + // signed-URL query params), the preview path correctly sanitizes + // via `sanitizeUrl`, but the details dump previously passed the + // raw JSON through `text()` which only handled ANSI/bidi. HTML + + // plaintext branches exclude details entirely; markdown's + // asymmetry leaked credentials. When `sanitizeUrls: true`, + // run a URL-credential-stripping pass over the details string so + // markdown matches the other render paths' safety baseline. + const detailsText = block.details + ? opts.sanitizeUrls + ? sanitizeUrlsInText(block.details) + : block.details + : undefined; + const details = detailsText ? `\n\n${text(detailsText)}` : ''; + return `${header}\n\n${previewMd}\n\n${status}${details}`; + } + case 'shell': { + const lang = block.stream === 'stderr' ? 'shellsession-stderr' : 'shell'; + return markdownFence(lang, text(block.text)); + } + case 'permission': { + const optionList = block.options + .map( + (opt) => + `- **${escapeMarkdownText(opt.label, opts)}**${ + opt.description + ? ` - ${escapeMarkdownText(opt.description, opts)}` + : '' + }`, + ) + .join('\n'); + const resolved = block.resolved + ? `\n\n_resolved: ${escapeMarkdownText(block.resolved, opts)}_` + : '\n\n_awaiting decision_'; + const previewMd = daemonToolPreviewToMarkdown(block.preview, opts); + return `### Permission: ${escapeMarkdownText( + block.title, + opts, + )}\n\n${previewMd}\n\n${optionList}${resolved}`; + } + case 'status': + return `*${text(block.text)}*`; + case 'debug': + return blockquote(`debug: ${text(block.text)}`); + case 'error': + return `> [!CAUTION]\n${blockquote(text(block.text))}`; + default: + return ''; + } +} + +function renderToolHeader( + block: Extract, + opts: DaemonRenderOptions = {}, +): string { + // doudouOUC review: forward `opts` so `maxFieldLength` is honored for + // tool titles / kinds (previously bypassed — a 20KB title would render + // uncapped while every other text field hit the 8192 default). + // `escapeMarkdownText` / `inlineCode` apply `capLength` internally when + // `opts` is provided. + const parts: string[] = [`### ${escapeMarkdownText(block.title, opts)}`]; + if (block.toolName) parts.push(inlineCode(block.toolName, opts)); + if (block.toolKind) parts.push(`(${escapeMarkdownText(block.toolKind, opts)})`); + return parts.join(' '); +} + +/** + * Project a `DaemonToolPreview` into markdown. Each kind gets a dedicated + * shape — diffs become fenced unified-diff blocks, file reads become + * `path:line-range` lines, etc. + */ +export function daemonToolPreviewToMarkdown( + preview: DaemonToolPreview, + opts: DaemonRenderOptions = {}, +): string { + const cap = capLength(opts); + const text = (value: string) => cap(sanitizeTerminalText(value)); + switch (preview.kind) { + case 'ask_user_question': + return preview.questions.map((q) => renderQuestion(q, opts)).join('\n\n'); + case 'command': + return markdownFence( + 'bash', + [ + preview.cwd ? `# cwd: ${text(preview.cwd)}` : null, + text(preview.command), + ] + .filter(Boolean) + .join('\n'), + ); + case 'file_diff': + if (preview.patch) { + return markdownFence('diff', text(preview.patch)); + } + if (preview.oldText !== undefined && preview.newText !== undefined) { + return [ + `**Edit ${inlineCode(preview.path, opts)}**`, + '', + markdownFence( + 'diff', + [ + ...text(preview.oldText) + .split('\n') + .map((line) => `- ${line}`), + ...text(preview.newText) + .split('\n') + .map((line) => `+ ${line}`), + ].join('\n'), + ), + ].join('\n'); + } + if (preview.newText !== undefined) { + return [ + `**Write ${inlineCode(preview.path, opts)}**`, + '', + markdownFence('', text(preview.newText)), + ].join('\n'); + } + return `**Edit ${inlineCode(preview.path, opts)}**`; + case 'file_read': + if (preview.range) { + return `Read ${inlineCode(preview.path, opts)} (lines ${preview.range[0]}-${preview.range[1]})`; + } + return `Read ${inlineCode(preview.path, opts)}`; + case 'web_fetch': { + const url = opts.sanitizeUrls ? sanitizeUrl(preview.url) : preview.url; + return `${escapeMarkdownText(preview.method ?? 'GET', opts)} ${inlineCode( + url, + opts, + )}`; + } + case 'mcp_invocation': + return [ + `**MCP** ${inlineCode( + `${preview.serverId}::${preview.toolName}`, + opts, + )}`, + preview.argsSummary + ? `_args:_ ${inlineCode(preview.argsSummary, opts)}` + : null, + ] + .filter(Boolean) + .join('\n'); + case 'code_block': + return [ + preview.origin ? `_${escapeMarkdownText(preview.origin, opts)}_` : null, + markdownFence( + escapeFenceLanguage(preview.language ?? ''), + text(preview.code), + ), + ] + .filter(Boolean) + .join('\n'); + case 'search': { + const lines = [ + `**Search** ${inlineCode(preview.query, opts)}`, + preview.resultCount !== undefined + ? `_${preview.resultCount} result${preview.resultCount === 1 ? '' : 's'}_` + : null, + ]; + if (preview.top && preview.top.length > 0) { + for (const result of preview.top) { + lines.push(`- ${escapeMarkdownText(result, opts)}`); + } + } + return lines.filter(Boolean).join('\n'); + } + case 'tabular': { + if (preview.columns.length === 0) return '_(empty table)_'; + const headerRow = `| ${preview.columns + .map((column) => escapeTableCell(column, opts)) + .join(' | ')} |`; + const sepRow = `| ${preview.columns.map(() => '---').join(' | ')} |`; + const bodyRows = preview.rows.map( + (row) => + `| ${preview.columns + .map((_, idx) => escapeTableCell(String(row[idx] ?? ''), opts)) + .join(' | ')} |`, + ); + const lines = [headerRow, sepRow, ...bodyRows]; + if ( + preview.totalRows !== undefined && + preview.totalRows > preview.rows.length + ) { + lines.push( + `_… ${preview.totalRows - preview.rows.length} more row(s) not shown_`, + ); + } + return lines.join('\n'); + } + case 'image_generation': + return [ + `**Image generation**`, + blockquote(text(preview.prompt)), + preview.model + ? `_model: ${escapeMarkdownText(preview.model, opts)}_` + : null, + preview.thumbnailUrl + ? // wenshao R2 (qwen3.7-max): always protocol-validate image + // URLs regardless of `sanitizeUrls` opt-in. `javascript:` / + // `vbscript:` in `` is never legitimate; the markdown + // pipeline will convert `![image](javascript:...)` into an + // attacker-controlled `` in most renderers. + // `sanitizeUrls: true` additionally strips query-param + // tokens + Basic Auth. + `![image](${ + opts.sanitizeUrls + ? sanitizeUrl(preview.thumbnailUrl) + : ensureSafeImageUrl(preview.thumbnailUrl) + })` + : null, + ] + .filter(Boolean) + .join('\n'); + case 'subagent_delegation': + return [ + `**Delegate -> ${inlineCode(preview.agentName, opts)}**`, + '', + blockquote(text(preview.task)), + preview.parentDelegationId + ? `_(chained from ${escapeMarkdownText( + preview.parentDelegationId, + opts, + )})_` + : null, + ] + .filter(Boolean) + .join('\n'); + case 'key_value': + return preview.rows + .map( + (row) => + `- **${escapeMarkdownText(row.label, opts)}:** ${escapeMarkdownText( + row.value, + opts, + )}`, + ) + .join('\n'); + case 'generic': + return preview.summary + ? `_${escapeMarkdownText(preview.summary, opts)}_` + : ''; + default: + return ''; + } +} + +/** + * Prefix every line of `raw` with `> ` so a markdown blockquote stays + * intact across newlines. The naive `> ${text}` form only quotes the + * first line; subsequent lines render as bare markdown. + */ +function blockquote(raw: string): string { + return raw + .split('\n') + .map((line) => `> ${line}`) + .join('\n'); +} + +function markdownFence(language: string, raw: string): string { + const maxRun = Math.max( + 2, + ...Array.from(raw.matchAll(/`+/g), (match) => match[0].length), + ); + const fence = '`'.repeat(maxRun + 1); + return [`${fence}${language}`, raw, fence].join('\n'); +} + +function renderQuestion( + question: DaemonTranscriptQuestion, + opts: DaemonRenderOptions, +): string { + const heading = question.header + ? `**${escapeMarkdownText(question.header, opts)}**\n\n` + : ''; + const options = question.options + .map( + (opt) => + `- ${escapeMarkdownText(opt.label, opts)}${ + opt.description + ? ` - ${escapeMarkdownText(opt.description, opts)}` + : '' + }`, + ) + .join('\n'); + return `${heading}${escapeMarkdownText(question.question, opts)}\n\n${options}`; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Plain text + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Render a transcript block as plain text (no markdown formatting, no + * ANSI). Use for copy-paste, log lines, accessibility-friendly output. + */ +export function daemonBlockToPlainText( + block: DaemonTranscriptBlock, + opts: DaemonRenderOptions = {}, +): string { + // wenshao R5 (qwen3.7-max): sanitize ANSI / bidi controls in plain text + // for parity with markdown (which calls sanitizeTerminalText via `text()`) + // and HTML (via defaultEscapeHtml). Without this, terminal escapes and + // bidi overrides survived into plaintext output — contradicting the + // "for copy-paste / logs" JSDoc intent. + const cap = capLength(opts); + const clean = (raw: string) => cap(sanitizeTerminalText(raw)); + switch (block.kind) { + case 'user': + return `You: ${clean(block.text)}`; + case 'assistant': + return clean(block.text); + case 'thought': + return `(thought: ${clean(block.text)})`; + case 'tool': { + // wenshao R3 (qwen3.7-max): cap header fields. Markdown + HTML + // paths cap; plainText path previously rendered uncapped titles. + const header = [ + clean(block.title), + block.toolName ? `[${clean(block.toolName)}]` : null, + block.toolKind ? `(${clean(block.toolKind)})` : null, + ] + .filter(Boolean) + .join(' '); + // wenshao review (review 4350741340): forward `opts` so + // `sanitizeUrls` + `maxFieldLength` reach the preview's URL fields + // (web_fetch URL, image_generation thumbnailUrl). The HTML path at + // line 509 already did this; plainText was missed in the prior + // doudouOUC fix. + const preview = daemonToolPreviewToPlainText(block.preview, opts); + const status = `status: ${block.status}`; + return [header, preview, status].filter(Boolean).join('\n'); + } + case 'shell': + return `[shell ${block.stream ?? 'stdout'}]\n${clean(block.text)}`; + case 'permission': { + // wenshao R3 (qwen3.7-max): cap permission fields for parity. + const optionList = block.options + .map( + (opt) => + ` - ${clean(opt.label)}${opt.description ? `: ${clean(opt.description)}` : ''}`, + ) + .join('\n'); + const resolved = block.resolved + ? `(resolved: ${clean(block.resolved)})` + : '(awaiting decision)'; + return `Permission: ${clean(block.title)}\n${optionList}\n${resolved}`; + } + case 'status': + return `[status] ${clean(block.text)}`; + case 'debug': + return `[debug] ${clean(block.text)}`; + case 'error': + return `[error] ${clean(block.text)}`; + default: + return ''; + } +} + +function daemonToolPreviewToPlainText( + preview: DaemonToolPreview, + opts: DaemonRenderOptions = {}, +): string { + // doudouOUC review (Important): thread `sanitizeUrls` through. The HTML + // path calls this helper to render the tool preview inside the `
`
+  // block, but previously the helper took no opts — so even when the
+  // caller set `sanitizeUrls: true` to strip auth tokens from URLs, the
+  // HTML path leaked tokens into the DOM (markdown path was already safe).
+  //
+  // wenshao R3 + doudouOUC R3 (qwen3.7-max): apply `maxFieldLength` for
+  // parity with markdown's `text()` wrapper. Previously plaintext /
+  // HTML preview content was uncapped while every other field hit the
+  // 8192 default.
+  const url = (u: string) => (opts.sanitizeUrls ? sanitizeUrl(u) : u);
+  const cap = capLength(opts);
+  switch (preview.kind) {
+    case 'ask_user_question':
+      return preview.questions
+        .map((q) => `${q.header ? `${cap(q.header)}: ` : ''}${cap(q.question)}`)
+        .join('\n');
+    case 'command':
+      return preview.cwd
+        ? `$ ${cap(preview.command)} (cwd: ${cap(preview.cwd)})`
+        : `$ ${cap(preview.command)}`;
+    case 'file_diff':
+      if (preview.patch) return cap(preview.patch);
+      if (preview.newText !== undefined)
+        return `${cap(preview.path)}: ${cap(preview.newText)}`;
+      return cap(preview.path);
+    case 'file_read':
+      return preview.range
+        ? `${cap(preview.path)} (lines ${preview.range[0]}-${preview.range[1]})`
+        : cap(preview.path);
+    case 'web_fetch':
+      return `${preview.method ?? 'GET'} ${cap(url(preview.url))}`;
+    case 'mcp_invocation':
+      return `${cap(preview.serverId)}::${cap(preview.toolName)}${preview.argsSummary ? ` (${cap(preview.argsSummary)})` : ''}`;
+    case 'code_block':
+      return preview.origin
+        ? `[${cap(preview.origin)}]\n${cap(preview.code)}`
+        : cap(preview.code);
+    case 'search':
+      return [
+        `search: ${cap(preview.query)}`,
+        preview.resultCount !== undefined
+          ? `(${preview.resultCount} results)`
+          : null,
+        ...(preview.top ?? []).map((r) => `  ${cap(r)}`),
+      ]
+        .filter(Boolean)
+        .join('\n');
+    case 'tabular': {
+      if (preview.columns.length === 0) return '(empty table)';
+      const lines = [preview.columns.map((c) => cap(c)).join('\t')];
+      for (const row of preview.rows) {
+        lines.push(
+          preview.columns
+            .map((_, idx) => cap(String(row[idx] ?? '')))
+            .join('\t'),
+        );
+      }
+      if (
+        preview.totalRows !== undefined &&
+        preview.totalRows > preview.rows.length
+      ) {
+        lines.push(
+          `... ${preview.totalRows - preview.rows.length} more row(s)`,
+        );
+      }
+      return lines.join('\n');
+    }
+    case 'image_generation': {
+      const thumb = preview.thumbnailUrl
+        ? // Image URLs also get protocol validation even when sanitizeUrls
+          // is false (XSS defense for img-src contexts).
+          ` [${
+            opts.sanitizeUrls
+              ? sanitizeUrl(preview.thumbnailUrl)
+              : ensureSafeImageUrl(preview.thumbnailUrl)
+          }]`
+        : '';
+      return `image: "${cap(preview.prompt)}"${preview.model ? ` (${cap(preview.model)})` : ''}${thumb}`;
+    }
+    case 'subagent_delegation':
+      return `delegate to ${cap(preview.agentName)}: ${cap(preview.task)}`;
+    case 'key_value':
+      return preview.rows.map((r) => `${cap(r.label)}: ${cap(r.value)}`).join('\n');
+    case 'generic':
+      return preview.summary ? cap(preview.summary) : '';
+    default:
+      return '';
+  }
+}
+
+/* ──────────────────────────────────────────────────────────────────────────
+ * HTML (with conservative sanitization)
+ * ──────────────────────────────────────────────────────────────────────── */
+
+export interface DaemonHtmlRenderOptions extends DaemonRenderOptions {
+  /**
+   * Custom HTML sanitizer. If omitted, the default escapes `<`, `>`, `&`,
+   * `'`, `"` and rejects `javascript:` URLs. Consumers wanting markdown→
+   * HTML should pre-render via `daemonBlockToMarkdown` and pass a real
+   * markdown→HTML pipeline (e.g., markdown-it + DOMPurify).
+   */
+  sanitizer?: (raw: string) => string;
+}
+
+/**
+ * Render a transcript block as conservatively escaped HTML. The default
+ * implementation does NOT parse markdown — it only escapes special chars
+ * and wraps content in semantic tags. For markdown→HTML, use
+ * `daemonBlockToMarkdown` + a markdown pipeline of your choice.
+ *
+ * Renderers that want richer HTML (collapsible code blocks, syntax
+ * highlighting, image rendering) should layer those on top — this is the
+ * safe baseline shared across SSR / webview / dashboard surfaces.
+ */
+export function daemonBlockToHtml(
+  block: DaemonTranscriptBlock,
+  opts: DaemonHtmlRenderOptions = {},
+): string {
+  const sanitizer = opts.sanitizer ?? defaultEscapeHtml;
+  const cap = capLength(opts);
+  switch (block.kind) {
+    case 'user':
+      return `
You

${sanitizer(cap(block.text))}

`; + case 'assistant': + return `

${sanitizer(cap(block.text))}

`; + case 'thought': + return `
${sanitizer(cap(block.text))}
`; + case 'tool': { + const previewHtml = sanitizer( + daemonToolPreviewToPlainText(block.preview, opts), + ); + const safeTitle = sanitizer(cap(block.title)); + const safeStatus = sanitizer(block.status); + return `
${safeTitle}
${previewHtml}
`; + } + case 'shell': + return `
${sanitizer(cap(block.text))}
`; + case 'permission': { + // wenshao R3 (qwen3.7-max): apply `cap()` for parity with every + // other block kind in this function. The tool block's `cap(title)` + // was added in the prior round; permission was overlooked. + const optionList = block.options + .map( + (opt) => + `
  • ${sanitizer(cap(opt.label))}${opt.description ? ` — ${sanitizer(cap(opt.description))}` : ''}
  • `, + ) + .join(''); + const resolved = block.resolved + ? `

    resolved: ${sanitizer(cap(block.resolved))}

    ` + : '

    awaiting decision

    '; + return `

    ${sanitizer(cap(block.title))}

      ${optionList}
    ${resolved}
    `; + } + case 'status': + return `
    ${sanitizer(cap(block.text))}
    `; + case 'debug': + return `
    ${sanitizer(cap(block.text))}
    `; + case 'error': + return ``; + default: + return ''; + } +} + +/* ────────────────────────────────────────────────────────────────────────── + * Internal utilities + * ──────────────────────────────────────────────────────────────────────── */ + +function capLength(opts: DaemonRenderOptions): (s: string) => string { + const max = opts.maxFieldLength ?? DEFAULT_MAX_FIELD_LENGTH; + if (!Number.isFinite(max) || max <= 0) return (s) => s; + return (s) => (s.length <= max ? s : `${s.slice(0, max)}… [truncated]`); +} + +function escapeMarkdownText( + raw: string, + opts: DaemonRenderOptions = {}, +): string { + const capped = capLength(opts)(sanitizeTerminalText(raw)); + // wenshao R7 (qwen3.7-max): include `<` so consumers piping the + // markdown output through markdown-it (with `html: true`) or any + // HTML-backed renderer don't see raw `', + { now: 2 }, + ); + const html = daemonBlockToHtml(state.blocks[0]!); + expect(html).not.toContain(''), + ), + ).toContain('![image](#)'); + // javascript: → rejected to '#' + expect(daemonBlockToMarkdown(mkBlock('javascript:alert(1)'))).toContain( + '![image](#)', + ); + }); +}); + +describe('R5 review batch — coverage additions', () => { + it('normalizeAuthDeviceFlowCancelled happy path', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'auth_device_flow_cancelled', + data: { deviceFlowId: 'flow-123' }, + } as never); + expect(events).toEqual([ + expect.objectContaining({ + type: 'auth.device_flow.cancelled', + deviceFlowId: 'flow-123', + }), + ]); + }); + + it('normalizeAuthDeviceFlowCancelled malformed → fallback debug', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'auth_device_flow_cancelled', + data: { /* no deviceFlowId */ }, + } as never); + expect(events[0]?.type).toBe('debug'); + }); + + it('sanitizeUrl clears OAuth implicit-grant access_token in #fragment', async () => { + const { + daemonBlockToMarkdown, + createDaemonToolPreview, + } = await import('../../src/daemon/ui/index.js'); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'fetch', + status: 'completed', + preview: createDaemonToolPreview( + { + url: 'https://app.example.com/callback#access_token=gho_FRAGMENT_LEAK&token_type=bearer', + method: 'GET', + }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const out = daemonBlockToMarkdown(block, { sanitizeUrls: true }); + expect(out).not.toContain('FRAGMENT_LEAK'); + expect(out).not.toContain('access_token='); + }); + + it('sanitizeUrl strips AWS / GCP / Azure SAS credential params', async () => { + const { + daemonBlockToMarkdown, + createDaemonToolPreview, + } = await import('../../src/daemon/ui/index.js'); + const mkBlock = (url: string) => ({ + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'fetch', + status: 'completed', + preview: createDaemonToolPreview( + { url, method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }); + // AWS S3 presigned + const aws = daemonBlockToMarkdown( + mkBlock('https://bucket.s3.amazonaws.com/x?AWSAccessKeyId=AKIA_LEAK&Expires=1234&Signature=SIG_LEAK'), + { sanitizeUrls: true }, + ); + expect(aws).not.toContain('AKIA_LEAK'); + expect(aws).not.toContain('SIG_LEAK'); + // GCP signed URL + const gcp = daemonBlockToMarkdown( + mkBlock('https://storage.googleapis.com/b/o?GoogleAccessId=svc_LEAK@proj.iam.gserviceaccount.com&Expires=999&Signature=GCP_LEAK'), + { sanitizeUrls: true }, + ); + expect(gcp).not.toContain('svc_LEAK'); + expect(gcp).not.toContain('GCP_LEAK'); + // Azure SAS + const az = daemonBlockToMarkdown( + mkBlock('https://acct.blob.core.windows.net/c/x?sv=2020-08-04&se=2026-12-31&sig=AZ_LEAK&sp=r'), + { sanitizeUrls: true }, + ); + expect(az).not.toContain('AZ_LEAK'); + }); + + it('formatMissedRange handles no-gap / single-event / multi-event', async () => { + const { formatMissedRange } = await import( + '../../src/daemon/ui/transcript.js' + ); + expect(formatMissedRange(5, 6)).toContain('no events lost'); + expect(formatMissedRange(5, 7)).toContain('1 daemon event'); + expect(formatMissedRange(5, 10)).toContain('6-9'); + }); + + it('detectFileDiff content alias rejected for non-write tools', async () => { + const { createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + // `{ path, content }` with READ-like tool name → NOT file_diff + const read = createDaemonToolPreview( + { path: '/x', content: 'expected text' }, + { toolName: 'read_file' }, + ); + expect(read.kind).not.toBe('file_diff'); + // Same shape with WRITE-like tool name → IS file_diff + const write = createDaemonToolPreview( + { path: '/x', content: 'new content' }, + { toolName: 'write_file' }, + ); + expect(write.kind).toBe('file_diff'); + }); + + it('writeIntent regex word-boundary: prewrite_check does NOT match write', async () => { + const { createDaemonToolPreview } = await import( + '../../src/daemon/ui/index.js' + ); + const preview = createDaemonToolPreview( + { path: '/x', content: 'data' }, + { toolName: 'prewrite_check' }, + ); + expect(preview.kind).not.toBe('file_diff'); + }); + + it('conformance suite captures adapter throw as fixture failure (does not abort)', async () => { + const { runAdapterConformanceSuite } = await import( + '../../src/daemon/ui/index.js' + ); + const result = runAdapterConformanceSuite( + { + reduce: () => { + throw new Error('adapter bug — intentional'); + }, + renderToText: () => '', + } as never, + { only: ['simple-chat'] }, + ); + expect(result.failed).toHaveLength(1); + expect(result.failed[0]!.renderedExcerpt).toContain('adapter threw'); + expect(result.failed[0]!.renderedExcerpt).toContain('adapter bug'); + // Suite did not throw — caller's assertion contract holds. + }); + + it('unrecognized daemon event emits single debug block (not status+debug)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'future_event_in_2027' as never, + data: {}, + } as never); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('debug'); + }); + + it('store.clearAwaitingResync clears latch', async () => { + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + expect(store.getSnapshot().awaitingResync).toBe(true); + store.clearAwaitingResync(); + expect(store.getSnapshot().awaitingResync).toBe(false); + }); +}); + +describe('R6 review batch — recovery flow + pending pointer', () => { + it('newly-created tool block with undefined status sets currentToolCallId to its default `pending`', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'unspecified', + title: 'starting', + // no status — daemon emit without explicit status field + }, + }, + } as never), + { now: 2 }, + ); + // Block has effective status 'pending' AND currentToolCallId points to it. + const block = state.blocks.find( + (b): b is Extract => + b.kind === 'tool' && b.toolCallId === 'unspecified', + )!; + expect(block.status).toBe('pending'); + expect(state.currentToolCallId).toBe('unspecified'); + }); + + it('clearAwaitingResync FIRST then dispatch new events: events flow', async () => { + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + // Set the latch. + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + expect(store.getSnapshot().awaitingResync).toBe(true); + // Clear BEFORE the new event stream. + store.clearAwaitingResync(); + expect(store.getSnapshot().awaitingResync).toBe(false); + // Now dispatch a normal event — should land in transcript. + store.dispatch( + normalizeDaemonEvent({ + id: 100, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replay-event-1' }, + }, + }, + } as never), + ); + const text = store + .getSnapshot() + .blocks.map((b) => + b.kind === 'assistant' ? (b as { text: string }).text : '', + ) + .join(''); + expect(text).toContain('replay-event-1'); + }); + + it('clearAwaitingResync AFTER dispatching events: events ARE dropped (documents the flow)', async () => { + // This test pins the correct flow as documented: latch drops everything + // until cleared. If a consumer dispatches events FIRST then clears, the + // events are lost. + const { createDaemonTranscriptStore } = await import( + '../../src/daemon/ui/index.js' + ); + const store = createDaemonTranscriptStore(); + store.dispatch({ + type: 'session.state_resync_required', + reason: 'sse_eviction', + lastDeliveredId: 5, + earliestAvailableId: 12, + } as never); + // WRONG order — dispatch BEFORE clear (replay window). + store.dispatch( + normalizeDaemonEvent({ + id: 101, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'replay-event-2' }, + }, + }, + } as never), + ); + store.clearAwaitingResync(); + // Event was dropped by the latch. + const text = store + .getSnapshot() + .blocks.map((b) => + b.kind === 'assistant' ? (b as { text: string }).text : '', + ) + .join(''); + expect(text).not.toContain('replay-event-2'); + }); +}); + +describe('R7 review batch — markdown escape + details sanitization', () => { + it('escapeMarkdownText escapes < in metadata fields (titles/kinds) for HTML-backed pipelines', async () => { + const { + daemonBlockToMarkdown, + createDaemonToolPreview, + } = await import('../../src/daemon/ui/index.js'); + // `escapeMarkdownText` is applied to METADATA fields (title / + // toolKind / status) — those are reviewer-untrusted and should + // escape `<` to prevent raw HTML pass-through when consumers run + // the markdown through markdown-it with html:true. Assistant / + // user / thought BODIES are intentionally NOT escape-formatted + // (they're markdown content; escaping `<` there would mangle + // legitimate markdown). + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + // Reviewer-untrusted title from a malicious daemon emit / tool + // response. Markdown escape must defang `<`. + title: '', + status: 'running', + preview: createDaemonToolPreview( + { command: 'echo hi' }, + { toolName: 'Bash', toolKind: 'tool' }, + ), + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block); + // The `<` is escaped to `\<` — markdown-it will render that as a + // literal `<` character which then gets HTML-escaped in the + // markdown→HTML pipeline. Verify the escape is present, AND that + // no unescaped ` { + const { + daemonBlockToMarkdown, + createDaemonToolPreview, + } = await import('../../src/daemon/ui/index.js'); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'Fetch', + status: 'running', + preview: createDaemonToolPreview( + { url: 'https://api.example.com/v1', method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + // details simulates the serialized rawInput JSON containing a URL + // with Basic Auth userinfo, query token, and OAuth fragment token. + details: + '{\n "url": "https://admin:BASIC_LEAK@api.example.com/v1?token=QUERY_LEAK&x-amz-credential=AWS_LEAK#access_token=FRAG_LEAK"\n}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block, { sanitizeUrls: true }); + expect(md).not.toContain('BASIC_LEAK'); + expect(md).not.toContain('QUERY_LEAK'); + expect(md).not.toContain('AWS_LEAK'); + expect(md).not.toContain('FRAG_LEAK'); + }); + + it('markdown tool block details preserves URLs verbatim when sanitizeUrls:false (back-compat)', async () => { + const { + daemonBlockToMarkdown, + createDaemonToolPreview, + } = await import('../../src/daemon/ui/index.js'); + const block = { + id: 'b', + kind: 'tool' as const, + toolCallId: 't', + title: 'Fetch', + status: 'running', + preview: createDaemonToolPreview( + { url: 'https://api.example.com/v1', method: 'GET' }, + { toolName: 'WebFetch', toolKind: 'tool' }, + ), + details: + '{\n "url": "https://api.example.com/v1?token=visible"\n}', + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + }; + const md = daemonBlockToMarkdown(block); + // Default (no sanitizeUrls) — details survive verbatim per existing + // contract; consumers must opt in. + expect(md).toContain('token=visible'); + }); }); diff --git a/packages/webui/package.json b/packages/webui/package.json index 3cbe6c75d7..4b41503659 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -44,7 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@qwen-code/sdk": "~0.1.7", + "@qwen-code/sdk": "~0.1.8", "markdown-it": "^14.1.0" }, "devDependencies": { diff --git a/packages/webui/src/components/toolcalls/shared/types.ts b/packages/webui/src/components/toolcalls/shared/types.ts index d2746b31f0..4e9c15bd25 100644 --- a/packages/webui/src/components/toolcalls/shared/types.ts +++ b/packages/webui/src/components/toolcalls/shared/types.ts @@ -97,6 +97,15 @@ export interface ToolCallData { content?: ToolCallContent[]; locations?: ToolCallLocation[]; timestamp?: number; + /** + * Optional markdown summary projection of the tool's preview (file + * diff, MCP invocation, tabular, etc.) — populated by + * `daemonTranscriptToUnifiedMessages` when + * `enrichToolDetailsWithPreview: true`. Renderers can show it + * alongside `rawOutput` (which is now preserved verbatim, addressing + * the doudouOUC review on PR #4353). + */ + previewMarkdown?: string; } /** diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 69b48ad550..8728801faf 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -20,7 +20,6 @@ import { DaemonSessionClient, createDaemonTranscriptStore, normalizeDaemonEvent, - selectPendingPermissionBlocks, type CreateSessionRequest, type DaemonTranscriptBlock, type DaemonTranscriptState, @@ -153,6 +152,19 @@ export function DaemonSessionProvider({ promptAbortRef.current = undefined; promptBusyRef.current = false; store.reset(); + } else if (previousSessionId !== undefined) { + store.dispatch({ type: 'assistant.done', reason: 'reconnected' }); + // wenshao R6 (qwen3.7-max): clear the awaitingResync latch + // BEFORE the new SSE event loop starts. Otherwise, if the + // prior connection ended after `state_resync_required` set + // the latch, every event from the fresh stream gets dropped + // by `applyDaemonTranscriptEvent`'s passthrough guard — + // transcript stays permanently frozen even though the + // connection is healthy. Same-session reconnect IS the + // recovery path; signal it to the reducer now. + if (store.getSnapshot().awaitingResync) { + store.clearAwaitingResync(); + } } session = nextSession; lastSessionIdRef.current = session.sessionId; @@ -195,10 +207,16 @@ export function DaemonSessionProvider({ if (!disposed && !abort.signal.aborted) { // Keep the session handle after a normal SSE close so the next // subscription can resume from DaemonSessionClient.lastEventId. + store.dispatch({ type: 'assistant.done', reason: 'stream_ended' }); store.dispatch({ type: 'status', text: 'SSE stream ended', }); + setConnection((current) => ({ + ...current, + status: 'disconnected', + error: 'SSE stream ended', + })); } } catch (error) { if (disposed || abort.signal.aborted) return; @@ -445,12 +463,29 @@ export function useDaemonTranscriptState(): DaemonTranscriptState { } export function useDaemonTranscriptBlocks(): readonly DaemonTranscriptBlock[] { - return useDaemonTranscriptState().blocks; + const store = useDaemonTranscriptStore(); + return useSyncExternalStore( + store.subscribe, + () => store.getSnapshot().blocks, + () => store.getSnapshot().blocks, + ); } export function useDaemonPendingPermissions() { - const state = useDaemonTranscriptState(); - return useMemo(() => selectPendingPermissionBlocks(state), [state]); + // wenshao R5 (qwen3.7-max): subscribe at the blocks level instead of + // the full transcript state. `selectPendingPermissionBlocks` reads + // only `state.blocks`; subscribing to the full state caused this + // hook to re-render on every daemon event (text deltas, tool + // updates, sidechannel changes) even when blocks were unchanged. + const blocks = useDaemonTranscriptBlocks(); + return useMemo( + () => + blocks.filter( + (block): block is Extract => + block.kind === 'permission' && block.resolved === undefined, + ), + [blocks], + ); } export function useDaemonActions(): DaemonUiSessionActions { diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 5b7d619970..cf0d0476ce 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -15,6 +15,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'error-1', kind: 'error', text: 'SSE stream error', + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -69,11 +70,11 @@ describe('daemonTranscriptToUnifiedMessages', () => { expect(messages.map((message) => message.toolCall?.status)).toEqual([ 'pending', 'completed', - 'failed', - 'cancelled', 'completed', - 'failed', - 'cancelled', + 'completed', + 'completed', + 'completed', + 'completed', 'completed', 'failed', 'completed', @@ -90,6 +91,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'assistant-1', kind: 'assistant', text: '\u202etxt.exe\u001b[31mred\x00', + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -101,14 +103,15 @@ describe('daemonTranscriptToUnifiedMessages', () => { status: 'completed', preview: { kind: 'generic' }, rawInput: { - '\u202ecommand': '\u202enpm test', + '‮command': '‮npm test', apiKey: 'secret-input', headers: { Authorization: 'Bearer secret-auth' }, }, rawOutput: { token: 'secret-output', - text: '\u001b]0;bad\u0007ok', + text: ']0;badok', }, + clientReceivedAt: 2, createdAt: 2, updatedAt: 2, }, @@ -137,6 +140,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { kind: 'shell', text: '\u001b[31mstdout', stream: 'stdout', + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -144,6 +148,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'status-1', kind: 'status', text: '\u202econnected', + clientReceivedAt: 2, createdAt: 2, updatedAt: 2, }, @@ -183,6 +188,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { }, ], locations: [{ path: '\u202esrc/index.ts', line: 3 }], + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -220,6 +226,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { status: 'completed', preview: { kind: 'generic' }, rawOutput: nested, + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -242,6 +249,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'user-1', kind: 'user', text: 'hi', + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }, @@ -249,6 +257,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'debug-1', kind: 'debug', text: 'internal', + clientReceivedAt: 2, createdAt: 2, updatedAt: 2, }, @@ -256,6 +265,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'status-1', kind: 'status', text: 'connecting', + clientReceivedAt: 3, createdAt: 3, updatedAt: 3, }, @@ -263,6 +273,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { id: 'assistant-1', kind: 'assistant', text: 'hello', + clientReceivedAt: 4, createdAt: 4, updatedAt: 4, }, @@ -295,6 +306,7 @@ function createToolBlock( title: 'Tool', status, preview: { kind: 'generic' }, + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, }; @@ -311,8 +323,41 @@ function createPermissionBlock( title: 'Permission', options: [], preview: { kind: 'generic' }, + clientReceivedAt: 1, createdAt: 1, updatedAt: 1, ...(resolved !== undefined ? { resolved } : {}), }; } + +describe('previewMarkdown preserves rawOutput (wenshao R3 qwen3.7-max)', () => { + it('keeps rawOutput as original object and exposes previewMarkdown when enrich enabled', () => { + const block: DaemonTranscriptBlock = { + id: 'tool-1', + kind: 'tool', + toolCallId: 't', + title: 'edit file', + status: 'completed', + rawInput: { path: '/x.ts', oldText: 'a', newText: 'b' }, + rawOutput: { ok: true, lines: 42, message: 'wrote' }, + preview: { + kind: 'file_diff', + path: '/x.ts', + oldText: 'a', + newText: 'b', + }, + clientReceivedAt: 1, + createdAt: 1, + updatedAt: 1, + } as DaemonTranscriptBlock; + const [msg] = daemonTranscriptToUnifiedMessages([block], { + enrichToolDetailsWithPreview: true, + }); + const tc = (msg as unknown as { toolCall: Record }) + .toolCall; + expect(typeof tc['rawOutput']).toBe('object'); + expect(tc['rawOutput']).toMatchObject({ ok: true, lines: 42 }); + expect(typeof tc['previewMarkdown']).toBe('string'); + expect((tc['previewMarkdown'] as string).includes('/x.ts')).toBe(true); + }); +}); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index bdd0ed4f31..75812ce902 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -5,6 +5,8 @@ */ import { + daemonBlockToMarkdown, + daemonToolPreviewToMarkdown, isDaemonUiSensitiveKey, sanitizeDaemonTerminalText, type DaemonTranscriptBlock, @@ -18,9 +20,35 @@ import type { ToolCallStatus, } from '../components/toolcalls/shared/index.js'; +export interface DaemonTranscriptAdapterOptions { + /** + * When true, user/assistant/thought block content is projected via the + * SDK's `daemonBlockToMarkdown` helper instead of raw sanitized text. + * This gives the WebUI's markdown renderer (markdown-it) richer + * formatting — bold "You" labels, thought blockquotes, structured + * permission lists. + * + * Default: `false` — preserves the legacy plain-text behavior. + * Pass `true` to opt into the PR-D render contract. + */ + useMarkdown?: boolean; + /** + * When true, tool block `details`/`rawOutput` is enriched with the + * preview's markdown projection (file_diff fenced as diff, mcp_invocation + * as server::tool, tabular as GFM table). Renderers that already have + * structured renderers for each preview kind should leave this `false`. + * + * Default: `false`. + */ + enrichToolDetailsWithPreview?: boolean; +} + export function daemonTranscriptToUnifiedMessages( blocks: readonly DaemonTranscriptBlock[], + options: DaemonTranscriptAdapterOptions = {}, ): UnifiedMessage[] { + const useMarkdown = options.useMarkdown ?? false; + const enrichToolDetails = options.enrichToolDetailsWithPreview ?? false; const visibleBlocks = blocks.filter((block) => block.kind !== 'debug'); return visibleBlocks.flatMap((block, index, arr): UnifiedMessage[] => { const prev = arr[index - 1]; @@ -36,7 +64,9 @@ export function daemonTranscriptToUnifiedMessages( id: block.id, type: 'user', timestamp, - content: sanitizeDisplayText(block.text), + content: useMarkdown + ? sanitizeDisplayText(daemonBlockToMarkdown(block)) + : sanitizeDisplayText(block.text), isFirst, isLast, }, @@ -47,7 +77,9 @@ export function daemonTranscriptToUnifiedMessages( id: block.id, type: 'assistant', timestamp, - content: sanitizeDisplayText(block.text), + content: useMarkdown + ? sanitizeDisplayText(daemonBlockToMarkdown(block)) + : sanitizeDisplayText(block.text), isFirst, isLast, }, @@ -58,7 +90,9 @@ export function daemonTranscriptToUnifiedMessages( id: block.id, type: 'thinking', timestamp, - content: sanitizeDisplayText(block.text), + content: useMarkdown + ? sanitizeDisplayText(daemonBlockToMarkdown(block)) + : sanitizeDisplayText(block.text), isFirst, isLast, }, @@ -69,7 +103,7 @@ export function daemonTranscriptToUnifiedMessages( id: block.id, type: 'tool_call', timestamp, - toolCall: daemonToolBlockToToolCallData(block), + toolCall: daemonToolBlockToToolCallData(block, enrichToolDetails), isFirst, isLast, }, @@ -164,7 +198,21 @@ export function daemonTranscriptToUnifiedMessages( function daemonToolBlockToToolCallData( block: DaemonToolTranscriptBlock, + enrichDetails: boolean = false, ): ToolCallData { + // doudouOUC review (Important): do NOT overwrite `rawOutput` with the + // preview markdown. The previous code replaced the structured tool + // output with a string summary when `enrichDetails === true`, which + // (a) broke downstream consumers that expect an object shape on + // `ToolCallData.rawOutput`, and + // (b) silently dropped the actual tool output (e.g., a 500-line file) + // in favor of a short summary. + // Surface the preview markdown on a new optional `previewMarkdown` + // field instead. `rawOutput` is now always the verbatim (sanitized) + // daemon-emitted value. + const previewMarkdown = enrichDetails + ? sanitizeDisplayText(daemonToolPreviewToMarkdown(block.preview)) + : undefined; return { toolCallId: block.toolCallId, kind: block.toolKind ?? block.toolName ?? 'tool', @@ -175,6 +223,7 @@ function daemonToolBlockToToolCallData( | string | undefined, rawOutput: sanitizeDaemonValue(block.rawOutput), + ...(previewMarkdown !== undefined ? { previewMarkdown } : {}), ...(block.content !== undefined ? { content: normalizeToolContent(block.content) } : {}), @@ -251,13 +300,35 @@ function normalizePermissionStatus( return 'completed'; case 'selected': // A selected option resolves the prompt even when the option id is a - // domain value like a city name rather than allow/deny terminology. - return classifyPermissionToken(detailParts.join(':')) ?? 'completed'; + // domain value like a city name or an option id containing deny/cancel. + return classifySelectedPermissionOption(detailParts.join(':')); default: return classifyPermissionToken(primary) ?? 'failed'; } } +function classifySelectedPermissionOption(detail: string): ToolCallStatus { + // Design intent (see caller comment at the `selected` branch): a + // selected option resolves the prompt even when the option id contains + // labels like `cancel` / `abort` / `dismiss`. The user actively + // chose the option, so the prompt is resolved — not cancelled. Only + // the FAILED set is honored here, because daemons distinguish + // explicit-fail (`failed:reason`) from option-selection (`selected:x`) + // at the caller layer. + // + // wenshao R3 (qwen3.7-max) proposed adding a CANCELLED check here, but + // that conflicts with the explicit design intent and the existing + // `cancelled-substring-permission` test (input `selected:abort`, + // expected status `completed`). When the daemon means "user cancelled + // the prompt", it emits `cancelled` as the primary token, NOT + // `selected:cancel` — and that path is handled separately. + const normalized = detail.trim().toLowerCase(); + if (FAILED_PERMISSION_TERMS.has(normalized)) { + return 'failed'; + } + return 'completed'; +} + function classifyPermissionToken(token: string): ToolCallStatus | undefined { if (!token) return undefined; const terms = new Set(