,
+ 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 `` into an
+ // attacker-controlled `
` in most renderers.
+ // `sanitizeUrls: true` additionally strips query-param
+ // tokens + Basic Auth.
+ `
+ : 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 ``;
+ }
+ 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))}
${resolved}
`;
+ }
+ case 'status':
+ return `${sanitizer(cap(block.text))}
`;
+ case 'debug':
+ return `${sanitizer(cap(block.text))}
`;
+ case 'error':
+ return `${sanitizer(cap(block.text))}
`;
+ 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('');
+ // javascript: → rejected to '#'
+ expect(daemonBlockToMarkdown(mkBlock('javascript:alert(1)'))).toContain(
+ '',
+ );
+ });
+});
+
+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(