From 7b6fd6124e5db1c13ac8ec196c7030af056c73d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:21:14 +0800 Subject: [PATCH 01/24] feat(sdk/daemon-ui): expand event coverage to 28+ daemon event types (PR-A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "12+ daemon events fall through to debug" gap surfaced in the PR the daemon currently emits (Stage 1 + Wave 3-4), so renderers stop having to peek at `rawEvent.data` for known event categories. Session-meta: - session.metadata.changed (from session_metadata_updated) - session.approval_mode.changed (from approval_mode_changed) - session.available_commands (from available_commands_update; upgraded from a status-text fallback to a typed event carrying the command list) Workspace state (Wave 3-4): - workspace.memory.changed - workspace.agent.changed - workspace.tool.toggled - workspace.initialized - workspace.mcp.budget_warning - workspace.mcp.child_refused - workspace.mcp.server_restarted - workspace.mcp.server_restart_refused Auth device-flow (Wave 4 OAuth, RFC 8628): - auth.device_flow.started - auth.device_flow.throttled - auth.device_flow.authorized - auth.device_flow.failed (carries DaemonAuthDeviceFlowSdkErrorKind) - auth.device_flow.cancelled - `DaemonUiErrorEvent.errorKind?: DaemonErrorKind` — closed-enum error category propagated from daemon's typed-error taxonomy. Renderers can branch on errorKind for "retry auth" vs "check file path" affordances instead of regex-matching `text`. - `DaemonUiToolUpdateEvent.provenance?: DaemonUiToolProvenance` + `.serverId?` — closed enum ('builtin' | 'mcp' | 'subagent' | 'unknown'). Falls back to the `mcp____` naming heuristic when the daemon doesn't stamp provenance explicitly. Unblocks UI namespace dispatch without string-matching toolName. Session-meta / workspace / auth events do NOT push transcript blocks. They are intentional sidechannel observations: `lastEventId` advances (monotonic invariant preserved), but the chat-stream transcript stays focused on user/assistant/tool/shell/permission content. Renderers consume them via selectors (introduced in follow-up PRs). All new event types produce short structured lines in `daemonUiEventToTerminalText` for tail-style debug consumers. Web/IDE renderers should consume the typed events directly via subscription. 40/40 tests pass. New tests verify: - All 16 new event types normalize correctly - Malformed payloads fall back to debug without leaking raw data (`secret` field never appears in fallback text) - MCP tool provenance heuristic (`mcp__github__create_issue` → provenance='mcp', serverId='github') - errorKind propagation on session_died / stream_error - Reducer is no-op on new event types; lastEventId still advances This is PR-A of the unified-renderer-layer follow-up series: - PR-A (this commit) — event coverage + closed-enum schema - PR-B — server-side timestamps + ordering refactor - PR-C — multimodal content + tool preview taxonomy - PR-D — render contract (toMarkdown / toHtml / toPlainText) + adapter conformance test framework - PR-E — reducer state machine (subagent / progress / current tool / cancellation propagation) See https://github.com/QwenLM/qwen-code/pull/4328#issuecomment-4494179724 for the full proposal. Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/index.ts | 22 + .../src/daemon/ui/normalizer.ts | 579 +++++++++++++++++- .../sdk-typescript/src/daemon/ui/terminal.ts | 98 +++ .../src/daemon/ui/transcript.ts | 27 + .../sdk-typescript/src/daemon/ui/types.ts | 224 ++++++- .../sdk-typescript/test/unit/daemonUi.test.ts | 373 ++++++++++- 6 files changed, 1297 insertions(+), 26 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 1628e8e589d..fc840394aae 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -41,6 +41,7 @@ export type { DaemonTranscriptReducerOptions, DaemonTranscriptState, DaemonTranscriptStore, + // Chat-stream events DaemonUiAssistantDoneEvent, DaemonUiErrorEvent, DaemonUiEvent, @@ -55,5 +56,26 @@ export type { DaemonUiStatusEvent, DaemonUiTextEvent, DaemonUiToolUpdateEvent, + DaemonUiToolProvenance, + // Session-meta events + DaemonUiSessionMetadataChangedEvent, + DaemonUiSessionApprovalModeChangedEvent, + DaemonUiSessionAvailableCommandsEvent, + // 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 4e028ea01ad..71b166858bf 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,25 @@ import { stringifyRedactedJson, } from './utils.js'; +const DAEMON_ERROR_KIND_SET = new Set(DAEMON_ERROR_KINDS); +const DEVICE_FLOW_ERROR_KIND_SET = new Set([ + 'expired', + 'access_denied', + 'slow_down_exhausted', + 'transport', + 'server', + 'invalid_client', + 'unsupported_provider', + 'internal', + 'not_found_or_evicted', +]); +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( @@ -76,6 +102,13 @@ export function normalizeDaemonEvent( ...base, type: 'error', recoverable: false, + ...(asDaemonErrorKind(getString(event.data, 'errorKind')) + ? { + errorKind: asDaemonErrorKind( + getString(event.data, 'errorKind'), + )!, + } + : {}), text: getString(event.data, 'reason') ?? 'Session died (no details available)', @@ -114,11 +147,67 @@ export function normalizeDaemonEvent( ...base, type: 'error', recoverable: true, + ...(asDaemonErrorKind(getString(event.data, 'errorKind')) + ? { + errorKind: asDaemonErrorKind( + getString(event.data, 'errorKind'), + )!, + } + : {}), text: getString(event.data, 'error') ?? 'SSE stream error (no details available)', }, ]; + + // ── 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: return [ { @@ -206,14 +295,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, }, ]; } @@ -275,6 +368,7 @@ function normalizeToolUpdate( text: `Tool update missing toolCallId${title ? ` (${title})` : ''}`, }; } + const { provenance, serverId } = extractToolProvenance(update, toolName); return { ...base, type: 'tool.update', @@ -285,6 +379,8 @@ function normalizeToolUpdate( ...(toolKind ? { toolKind } : {}), ...(content !== undefined ? { content } : {}), ...(locations !== undefined ? { locations } : {}), + ...(provenance ? { provenance } : {}), + ...(serverId ? { serverId } : {}), ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined @@ -342,6 +438,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]`; @@ -473,3 +612,435 @@ 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: Pick, + reason: string, +): DaemonUiEvent[] { + return [ + { + ...base, + type: 'debug', + text: `${event.type}: ${reason}`, + }, + ]; +} + +function normalizeSessionMetadataUpdated( + event: DaemonEvent, + base: Pick, +): 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: Pick, +): 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: Pick, +): DaemonUiEvent[] { + const scope = getString(event.data, 'scope'); + const filePath = getString(event.data, 'filePath'); + const mode = getString(event.data, 'mode'); + const bytesWritten = + isRecord(event.data) && typeof event.data['bytesWritten'] === 'number' + ? (event.data['bytesWritten'] as number) + : undefined; + 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): 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: Pick, +): DaemonUiEvent[] { + const deviceFlowId = getString(event.data, 'deviceFlowId'); + const errorKind = getString(event.data, 'errorKind'); + if ( + !deviceFlowId || + !errorKind || + !DEVICE_FLOW_ERROR_KIND_SET.has(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: errorKind as DaemonAuthDeviceFlowSdkErrorKind, + ...(hint ? { hint } : {}), + }, + ]; +} + +function normalizeAuthDeviceFlowCancelled( + event: DaemonEvent, + base: Pick, +): 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/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index ac26e43a0a1..d6ede45d0cf 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -42,6 +42,104 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { return terminalLine(event.type, event.text, '2'); case 'error': return terminalLine('error', event.text, '31'); + // Session-meta / workspace / auth events: emit a short structured line + // in terminal mode so they show up in tail-style debug, but do not + // flood the terminal with full payloads. UI clients with richer + // surfaces (web / IDE) consume the typed events directly. + case 'session.metadata.changed': + return terminalLine( + 'session', + `metadata: ${event.displayName ?? '(no display name)'}`, + '36', + ); + case 'session.approval_mode.changed': + return terminalLine( + 'approval-mode', + `${event.previous} → ${event.next}${event.persisted ? ' (persisted)' : ''}`, + '36', + ); + case 'session.available_commands': + return terminalLine('commands', `available ${event.count}`, '2'); + case 'workspace.memory.changed': + return terminalLine( + 'memory', + `${event.mode} ${event.scope} ${event.filePath} +${event.bytesWritten}b`, + '36', + ); + case 'workspace.agent.changed': + return terminalLine( + 'agent', + `${event.change} ${event.level}/${event.name}`, + '36', + ); + case 'workspace.tool.toggled': + return terminalLine( + 'tool', + `${event.toolName} ${event.enabled ? 'enabled' : 'disabled'}`, + '36', + ); + case 'workspace.initialized': + return terminalLine( + 'workspace', + `init ${event.action} ${event.path}`, + '36', + ); + case 'workspace.mcp.budget_warning': + return terminalLine( + 'mcp', + `${event.mode}: ${event.liveCount}/${event.budget} (${Math.round( + event.thresholdRatio * 100, + )}% threshold)`, + '33', + ); + case 'workspace.mcp.child_refused': + return terminalLine( + 'mcp', + `refused ${event.refusedServers.length} servers (budget ${event.budget})`, + '31', + ); + case 'workspace.mcp.server_restarted': + return terminalLine( + 'mcp', + `${event.serverName} restarted in ${event.durationMs}ms`, + '36', + ); + case 'workspace.mcp.server_restart_refused': + return terminalLine( + 'mcp', + `${event.serverName} restart refused: ${event.reason}`, + '33', + ); + case 'auth.device_flow.started': + return terminalLine( + 'auth', + `${event.providerId} device-flow started (${event.deviceFlowId})`, + '36', + ); + case 'auth.device_flow.throttled': + return terminalLine( + 'auth', + `device-flow throttled, retry after ${event.intervalMs}ms`, + '33', + ); + case 'auth.device_flow.authorized': + return terminalLine( + 'auth', + `${event.providerId} authorized${event.accountAlias ? ` as ${event.accountAlias}` : ''}`, + '32', + ); + case 'auth.device_flow.failed': + return terminalLine( + 'auth', + `device-flow failed: ${event.errorKind}${event.hint ? ` (${event.hint})` : ''}`, + '31', + ); + case 'auth.device_flow.cancelled': + return terminalLine( + 'auth', + `device-flow cancelled (${event.deviceFlowId})`, + '2', + ); default: return assertNever(event); } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 3acb45dea6b..d4e71947d2c 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -132,6 +132,33 @@ function applyDaemonTranscriptEvent( case 'error': appendStatusBlock(next, event.type, event.text, event); break; + // Session-meta / workspace / auth events do NOT push transcript blocks. + // Renderers subscribe to the store and select them via separate + // selectors (e.g., `selectApprovalMode`, `selectAvailableCommands`, + // `selectAuthFlow`) — see `selectors.ts`. They are still observed by + // the reducer so `lastEventId` advances monotonically, but the + // chat-stream transcript stays focused on user/assistant/tool/shell/ + // permission content. PRs in the C/D series may opt some of these + // into transcript projection as structured non-chat blocks. + case 'session.metadata.changed': + case 'session.approval_mode.changed': + case 'session.available_commands': + case 'workspace.memory.changed': + case 'workspace.agent.changed': + case 'workspace.tool.toggled': + case 'workspace.initialized': + case 'workspace.mcp.budget_warning': + case 'workspace.mcp.child_refused': + case 'workspace.mcp.server_restarted': + case 'workspace.mcp.server_restart_refused': + case 'auth.device_flow.started': + case 'auth.device_flow.throttled': + case 'auth.device_flow.authorized': + case 'auth.device_flow.failed': + case 'auth.device_flow.cancelled': + // Intentional no-op against `blocks[]`. Sidechannel state machines + // (introduced in PR-A follow-ups) consume these via `selectors.ts`. + break; default: assertNever(event); } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index e0eb4517628..130e3f154d3 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -4,11 +4,18 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { DaemonEvent, PermissionResponse } from '../types.js'; +import type { + DaemonAuthDeviceFlowSdkErrorKind, + DaemonAuthProviderId, + DaemonEvent, + DaemonErrorKind, + PermissionResponse, +} from '../types.js'; export const DAEMON_PLAN_TOOL_CALL_ID = 'daemon-plan'; export type DaemonUiEventType = + // Chat-stream events (Stage 1) | 'user.text.delta' | 'assistant.text.delta' | 'assistant.done' @@ -20,7 +27,26 @@ export type DaemonUiEventType = | 'model.changed' | 'status' | 'error' - | 'debug'; + | 'debug' + // Session-meta events + | 'session.metadata.changed' + | 'session.approval_mode.changed' + | 'session.available_commands' + // Workspace events (Wave 3-4) + | 'workspace.memory.changed' + | 'workspace.agent.changed' + | 'workspace.tool.toggled' + | 'workspace.initialized' + | 'workspace.mcp.budget_warning' + | 'workspace.mcp.child_refused' + | 'workspace.mcp.server_restarted' + | 'workspace.mcp.server_restart_refused' + // Auth flow events (Wave 4 OAuth) + | 'auth.device_flow.started' + | 'auth.device_flow.throttled' + | 'auth.device_flow.authorized' + | 'auth.device_flow.failed' + | 'auth.device_flow.cancelled'; export interface DaemonUiEventBase { type: DaemonUiEventType; @@ -39,6 +65,17 @@ export interface DaemonUiAssistantDoneEvent extends DaemonUiEventBase { reason?: string; } +/** + * Where a tool originated. Closed enum so UI dispatch (icon, MCP server + * badge, subagent header) doesn't depend on string-matching `toolName`. + * + * - `builtin`: ships with qwen-code (Bash, Edit, Read, etc.) + * - `mcp`: provided by an MCP server (cross-reference `serverId`) + * - `subagent`: invoked by a sub-agent delegation + * - `unknown`: daemon did not stamp provenance — treat as unspecified + */ +export type DaemonUiToolProvenance = 'builtin' | 'mcp' | 'subagent' | 'unknown'; + export interface DaemonUiToolUpdateEvent extends DaemonUiEventBase { type: 'tool.update'; toolCallId: string; @@ -48,6 +85,18 @@ export interface DaemonUiToolUpdateEvent extends DaemonUiEventBase { toolKind?: string; content?: unknown; locations?: unknown; + /** + * Provenance taxonomy — defaults to `'unknown'` when the daemon event + * lacks the `provenance` field. Heuristic fallback: a `toolName` starting + * with `mcp__` is treated as `'mcp'`. + */ + provenance?: DaemonUiToolProvenance; + /** + * When `provenance: 'mcp'`, identifies which MCP server provides the + * tool. Parsed from `update.serverId` when present, or extracted from + * `mcp____` naming convention as a fallback. + */ + serverId?: string; details?: string; rawInput?: unknown; rawOutput?: unknown; @@ -95,9 +144,163 @@ export interface DaemonUiErrorEvent extends DaemonUiEventBase { type: 'error'; text: string; recoverable?: boolean; + /** + * Closed-enum error category propagated from the daemon's typed-error + * taxonomy. Lets renderers branch on `errorKind` for "retry auth" vs + * "check file path" affordances instead of regex-matching `text`. + * Undefined when the originating daemon event is not categorized. + */ + errorKind?: DaemonErrorKind; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Session-meta events + * ──────────────────────────────────────────────────────────────────────── */ + +export interface DaemonUiSessionMetadataChangedEvent extends DaemonUiEventBase { + type: 'session.metadata.changed'; + sessionId: string; + displayName?: string; +} + +export interface DaemonUiSessionApprovalModeChangedEvent + extends DaemonUiEventBase { + type: 'session.approval_mode.changed'; + sessionId: string; + previous: string; + next: string; + persisted: boolean; +} + +/** + * Slash-command availability snapshot for the session. Fires from the + * daemon's `available_commands_update` session-update. Renderers use it + * to refresh command completion menus (TUI / web command palette / IDE + * quick pick). + */ +export interface DaemonUiSessionAvailableCommandsEvent + extends DaemonUiEventBase { + type: 'session.available_commands'; + /** Total count exposed by the daemon; convenience for renderers. */ + count: number; + /** Raw command objects from the daemon for downstream parsing. */ + commands: ReadonlyArray>; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Workspace events (Wave 3-4) + * ──────────────────────────────────────────────────────────────────────── */ + +export interface DaemonUiWorkspaceMemoryChangedEvent extends DaemonUiEventBase { + type: 'workspace.memory.changed'; + scope: 'workspace' | 'global'; + filePath: string; + mode: 'append' | 'replace'; + bytesWritten: number; +} + +export interface DaemonUiWorkspaceAgentChangedEvent extends DaemonUiEventBase { + type: 'workspace.agent.changed'; + change: 'created' | 'updated' | 'deleted'; + name: string; + level: 'project' | 'user'; } +export interface DaemonUiWorkspaceToolToggledEvent extends DaemonUiEventBase { + type: 'workspace.tool.toggled'; + toolName: string; + enabled: boolean; +} + +export interface DaemonUiWorkspaceInitializedEvent extends DaemonUiEventBase { + type: 'workspace.initialized'; + path: string; + action: 'created' | 'overwrote' | 'noop'; +} + +export interface DaemonUiMcpBudgetWarningEvent extends DaemonUiEventBase { + type: 'workspace.mcp.budget_warning'; + liveCount: number; + reservedCount: number; + budget: number; + thresholdRatio: number; + mode: 'warn' | 'enforce'; +} + +export interface DaemonUiMcpChildRefusedEvent extends DaemonUiEventBase { + type: 'workspace.mcp.child_refused'; + refusedServers: ReadonlyArray<{ + name: string; + transport: string; + reason: 'budget_exhausted'; + }>; + budget: number; + liveCount: number; + reservedCount: number; +} + +export interface DaemonUiMcpServerRestartedEvent extends DaemonUiEventBase { + type: 'workspace.mcp.server_restarted'; + serverName: string; + durationMs: number; +} + +export interface DaemonUiMcpServerRestartRefusedEvent + extends DaemonUiEventBase { + type: 'workspace.mcp.server_restart_refused'; + serverName: string; + reason: 'in_flight' | 'disabled' | 'budget_would_exceed'; +} + +/* ────────────────────────────────────────────────────────────────────────── + * Auth device-flow events (Wave 4 OAuth, RFC 8628) + * ──────────────────────────────────────────────────────────────────────── */ + +export interface DaemonUiAuthDeviceFlowStartedEvent extends DaemonUiEventBase { + type: 'auth.device_flow.started'; + deviceFlowId: string; + providerId: DaemonAuthProviderId; + expiresAt: number; +} + +export interface DaemonUiAuthDeviceFlowThrottledEvent + extends DaemonUiEventBase { + type: 'auth.device_flow.throttled'; + deviceFlowId: string; + intervalMs: number; +} + +export interface DaemonUiAuthDeviceFlowAuthorizedEvent + extends DaemonUiEventBase { + type: 'auth.device_flow.authorized'; + deviceFlowId: string; + providerId: DaemonAuthProviderId; + expiresAt?: number; + accountAlias?: string; +} + +export interface DaemonUiAuthDeviceFlowFailedEvent extends DaemonUiEventBase { + type: 'auth.device_flow.failed'; + deviceFlowId: string; + errorKind: DaemonAuthDeviceFlowSdkErrorKind; + hint?: string; +} + +export interface DaemonUiAuthDeviceFlowCancelledEvent + extends DaemonUiEventBase { + type: 'auth.device_flow.cancelled'; + deviceFlowId: string; +} + +export type DaemonUiAuthDeviceFlowEvent = + | DaemonUiAuthDeviceFlowStartedEvent + | DaemonUiAuthDeviceFlowThrottledEvent + | DaemonUiAuthDeviceFlowAuthorizedEvent + | DaemonUiAuthDeviceFlowFailedEvent + | DaemonUiAuthDeviceFlowCancelledEvent; + export type DaemonUiEvent = + // Chat-stream events | DaemonUiTextEvent | DaemonUiAssistantDoneEvent | DaemonUiToolUpdateEvent @@ -106,7 +309,22 @@ export type DaemonUiEvent = | DaemonUiPermissionResolvedEvent | DaemonUiModelChangedEvent | DaemonUiStatusEvent - | DaemonUiErrorEvent; + | DaemonUiErrorEvent + // Session-meta events + | DaemonUiSessionMetadataChangedEvent + | DaemonUiSessionApprovalModeChangedEvent + | DaemonUiSessionAvailableCommandsEvent + // Workspace events + | DaemonUiWorkspaceMemoryChangedEvent + | DaemonUiWorkspaceAgentChangedEvent + | DaemonUiWorkspaceToolToggledEvent + | DaemonUiWorkspaceInitializedEvent + | DaemonUiMcpBudgetWarningEvent + | DaemonUiMcpChildRefusedEvent + | DaemonUiMcpServerRestartedEvent + | DaemonUiMcpServerRestartRefusedEvent + // Auth device-flow events + | DaemonUiAuthDeviceFlowEvent; export interface NormalizeDaemonEventOptions { /** diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 218cc29b821..6bd9a9304cd 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -830,30 +830,31 @@ describe('daemon UI normalizer and transcript reducer', () => { data: { update: { sessionUpdate: 'available_commands_update', - availableCommands: ['help', 'model'], + // Raw command objects pass through to the typed event; + // primitive entries (the legacy `['help', 'model']` shape) are + // filtered since they cannot be projected as records. + availableCommands: [{ name: 'help' }, { name: 'model' }], }, }, }), - ).toMatchObject([ - { type: 'status', text: 'Available commands updated (2)' }, - ]); - expect( - normalizeDaemonEvent({ - id: 58, - v: 1, - type: 'mcp_budget_warning', - data: { token: 'secret' }, - }), - ).toMatchObject([ - { - type: 'status', - text: 'mcp_budget_warning (unrecognized daemon event)', - }, - { + ).toMatchObject([{ type: 'session.available_commands', count: 2 }]); + // Known event type with malformed payload: normalizer drops to `debug` + // with a `: malformed payload` text. Crucially the raw `data` is + // NOT dumped — `token: 'secret'` must not appear in the fallback text. + const malformed = normalizeDaemonEvent({ + id: 58, + v: 1, + type: 'mcp_budget_warning', + data: { token: 'secret' }, + }); + expect(malformed).toEqual([ + expect.objectContaining({ type: 'debug', - text: expect.not.stringContaining('secret') as string, - }, + text: expect.stringContaining('mcp_budget_warning'), + }), ]); + expect(malformed[0]).toMatchObject({ type: 'debug' }); + expect((malformed[0] as { text: string }).text).not.toContain('secret'); }); it('normalizes plan session updates as visible tool blocks', () => { @@ -1435,3 +1436,337 @@ describe('daemon UI normalizer and transcript reducer', () => { } }); }); + +describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { + function envelopeOf(type: string, data: T, id = 100) { + return { id, v: 1 as const, type, data } as never; + } + + it('normalizes session_metadata_updated into a typed session-meta event', () => { + const events = normalizeDaemonEvent( + envelopeOf('session_metadata_updated', { + sessionId: 'sess-1', + displayName: 'Fix login bug', + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.metadata.changed', + sessionId: 'sess-1', + displayName: 'Fix login bug', + }), + ]); + }); + + it('normalizes approval_mode_changed with persisted flag', () => { + const events = normalizeDaemonEvent( + envelopeOf('approval_mode_changed', { + sessionId: 's1', + previous: 'default', + next: 'yolo', + persisted: true, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.approval_mode.changed', + sessionId: 's1', + previous: 'default', + next: 'yolo', + persisted: true, + }), + ]); + }); + + it('upgrades available_commands_update from status text to typed event', () => { + const events = normalizeDaemonEvent( + envelopeOf('session_update', { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: [ + { name: 'memory', description: 'Manage memory' }, + { name: 'mcp', description: 'Manage MCP' }, + ], + }, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'session.available_commands', + count: 2, + }), + ]); + }); + + it('normalizes memory_changed with closed-enum scope + mode', () => { + const events = normalizeDaemonEvent( + envelopeOf('memory_changed', { + scope: 'workspace', + filePath: '/work/QWEN.md', + mode: 'append', + bytesWritten: 42, + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.memory.changed', + scope: 'workspace', + filePath: '/work/QWEN.md', + mode: 'append', + bytesWritten: 42, + }), + ]); + }); + + it('normalizes agent_changed for create/update/delete', () => { + for (const change of ['created', 'updated', 'deleted'] as const) { + const events = normalizeDaemonEvent( + envelopeOf('agent_changed', { + change, + name: 'reviewer', + level: 'project', + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.agent.changed', + change, + name: 'reviewer', + level: 'project', + }), + ]); + } + }); + + it('normalizes tool_toggled', () => { + const events = normalizeDaemonEvent( + envelopeOf('tool_toggled', { toolName: 'Bash', enabled: false }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.tool.toggled', + toolName: 'Bash', + enabled: false, + }), + ]); + }); + + it('normalizes workspace_initialized actions', () => { + const events = normalizeDaemonEvent( + envelopeOf('workspace_initialized', { path: '/w', action: 'created' }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.initialized', + path: '/w', + action: 'created', + }), + ]); + }); + + it('normalizes mcp_budget_warning with mode enum', () => { + const events = normalizeDaemonEvent( + envelopeOf('mcp_budget_warning', { + liveCount: 6, + reservedCount: 2, + budget: 8, + thresholdRatio: 0.75, + mode: 'warn', + }), + ); + expect(events).toEqual([ + expect.objectContaining({ + type: 'workspace.mcp.budget_warning', + liveCount: 6, + budget: 8, + mode: 'warn', + }), + ]); + }); + + it('normalizes mcp_child_refused_batch with refusedServers list', () => { + const events = normalizeDaemonEvent( + envelopeOf('mcp_child_refused_batch', { + refusedServers: [ + { name: 'github', transport: 'stdio', reason: 'budget_exhausted' }, + ], + budget: 4, + liveCount: 4, + reservedCount: 0, + mode: 'enforce', + }), + ); + expect(events[0]).toMatchObject({ + type: 'workspace.mcp.child_refused', + budget: 4, + refusedServers: [ + { name: 'github', transport: 'stdio', reason: 'budget_exhausted' }, + ], + }); + }); + + it('normalizes mcp_server_restarted and restart_refused', () => { + const restarted = normalizeDaemonEvent( + envelopeOf('mcp_server_restarted', { + serverName: 'github', + durationMs: 142, + }), + ); + expect(restarted[0]).toMatchObject({ + type: 'workspace.mcp.server_restarted', + serverName: 'github', + durationMs: 142, + }); + const refused = normalizeDaemonEvent( + envelopeOf('mcp_server_restart_refused', { + serverName: 'github', + reason: 'in_flight', + }), + ); + expect(refused[0]).toMatchObject({ + type: 'workspace.mcp.server_restart_refused', + reason: 'in_flight', + }); + }); + + it('normalizes auth_device_flow lifecycle (started → throttled → authorized)', () => { + const started = normalizeDaemonEvent( + envelopeOf('auth_device_flow_started', { + deviceFlowId: 'df-1', + providerId: 'qwen', + expiresAt: 1_900_000_000_000, + }), + ); + expect(started[0]).toMatchObject({ + type: 'auth.device_flow.started', + providerId: 'qwen', + }); + + const throttled = normalizeDaemonEvent( + envelopeOf('auth_device_flow_throttled', { + deviceFlowId: 'df-1', + intervalMs: 10_000, + }), + ); + expect(throttled[0]).toMatchObject({ + type: 'auth.device_flow.throttled', + intervalMs: 10_000, + }); + + const authorized = normalizeDaemonEvent( + envelopeOf('auth_device_flow_authorized', { + deviceFlowId: 'df-1', + providerId: 'qwen', + accountAlias: 'alice', + }), + ); + expect(authorized[0]).toMatchObject({ + type: 'auth.device_flow.authorized', + accountAlias: 'alice', + }); + }); + + it('normalizes auth_device_flow_failed with closed-enum errorKind', () => { + const events = normalizeDaemonEvent( + envelopeOf('auth_device_flow_failed', { + deviceFlowId: 'df-1', + errorKind: 'expired', + hint: 'restart the device flow', + }), + ); + expect(events[0]).toMatchObject({ + type: 'auth.device_flow.failed', + errorKind: 'expired', + hint: 'restart the device flow', + }); + }); + + it('falls back to debug for malformed payloads (e.g., missing required field)', () => { + const events = normalizeDaemonEvent( + envelopeOf('memory_changed', { + scope: 'unknown-scope', + filePath: '/x', + mode: 'append', + bytesWritten: 5, + }), + ); + expect(events[0]).toMatchObject({ type: 'debug' }); + }); + + it('infers mcp tool provenance from `mcp____` naming', () => { + const events = normalizeDaemonEvent({ + id: 999, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + name: 'mcp__github__create_issue', + title: 'Create Issue', + status: 'running', + }, + }, + } as never); + expect(events[0]).toMatchObject({ + type: 'tool.update', + toolName: 'mcp__github__create_issue', + provenance: 'mcp', + serverId: 'github', + }); + }); + + it('passes through errorKind on session_died when daemon stamps it', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_died', + data: { + sessionId: 's', + reason: 'ACP child crashed', + errorKind: 'init_timeout', + }, + } as never); + expect(events[0]).toMatchObject({ + type: 'error', + errorKind: 'init_timeout', + }); + }); + + it('reducer is no-op on session-meta / workspace / auth events (no transcript blocks emitted)', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent( + envelopeOf('memory_changed', { + scope: 'workspace', + filePath: '/x', + mode: 'replace', + bytesWritten: 1, + }), + ), + ...normalizeDaemonEvent( + envelopeOf('approval_mode_changed', { + sessionId: 's', + previous: 'default', + next: 'plan', + persisted: false, + }), + ), + ...normalizeDaemonEvent( + envelopeOf('auth_device_flow_started', { + deviceFlowId: 'df', + providerId: 'qwen', + expiresAt: 1_900_000_000_000, + }), + ), + ], + { now: 2 }, + ); + // No transcript blocks pushed — sidechannel state subscribers handle these. + expect(state.blocks).toEqual([]); + // lastEventId still advanced (monotonic invariant preserved). + expect(state.lastEventId).toBe(100); + }); +}); From a0673b7e9dad9362f94f1079928663c5e8ac6f76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:47:07 +0800 Subject: [PATCH 02/24] feat(sdk/daemon-ui): server timestamps + event-id-based ordering (PR-B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "时间定义不标准" gap surfaced in the PR #4328 review: - Client-side `Date.now()` drifts across clients - No daemon-authoritative timestamp propagated to UI - Out-of-order replay events get fresher `state.now` than originals, breaking `createdAt` ordering - `DaemonUiEventBase.serverTimestamp?: number` — daemon-authoritative wall-clock timestamp extracted from envelope. - `DaemonTranscriptBlockBase.serverTimestamp?: number` + `clientReceivedAt: number`. - `createdAt` preserved as `@deprecated` alias for `clientReceivedAt` (backward compat for code written before this PR). `extractServerTimestamp` looks at three candidate envelope locations: 1. `event.serverTimestamp` (preferred when daemon adds it) 2. `event._meta.serverTimestamp` (Anthropic-style metadata convention) 3. `event.data._meta.serverTimestamp` (sessionUpdate nested location) The SDK is ready to consume serverTimestamp WHEN daemon emits it, without requiring a coordinated SDK release. Undefined when daemon doesn't emit (current state) — graceful degradation to client-clock ordering. `selectTranscriptBlocksOrderedByEventId(state)` — returns blocks sorted by: 1. `eventId` (daemon-monotonic SSE cursor) — primary key 2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames 3. `clientReceivedAt` (local clock) — last resort Use this when displaying long sessions where event id 5 may arrive AFTER event id 7 (typical in SSE replay-after-reconnect). `formatBlockTimestamp(block, opts)` — formats the most authoritative timestamp on a block using `Intl.DateTimeFormat`. Prefers `serverTimestamp` over `clientReceivedAt` for cross-client consistency. Accepts locale / timeZone / dateStyle / timeStyle. Daemon needs to stamp `_meta.serverTimestamp` on every SSE envelope. This SDK PR is ready to consume it the moment the daemon ships the field; no coordination needed. - serverTimestamp extraction from all three envelope locations - Defaults undefined when envelope has none - `selectTranscriptBlocksOrderedByEventId` sorts mixed-arrival events by eventId (replay scenario) - `formatBlockTimestamp` prefers serverTimestamp; returns localized string PR-B of the unified follow-up to PR #4328 (PR-A + PR-B + PR-C + PR-D + PR-E in one branch). Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/index.ts | 2 + .../src/daemon/ui/normalizer.ts | 84 ++++++-- .../src/daemon/ui/transcript.ts | 110 ++++++++++- .../sdk-typescript/src/daemon/ui/types.ts | 48 +++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 181 ++++++++++++++++++ 5 files changed, 403 insertions(+), 22 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index fc840394aae..a055b7f818a 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -9,10 +9,12 @@ export { createDaemonToolPreview } from './toolPreview.js'; export { appendLocalUserTranscriptMessage, createDaemonTranscriptState, + formatBlockTimestamp, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, selectPendingPermissionBlocks, selectTranscriptBlocks, + selectTranscriptBlocksOrderedByEventId, } from './transcript.js'; export { createDaemonTranscriptStore } from './store.js'; export { diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 71b166858bf..e513ecb57b6 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -29,6 +29,16 @@ 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_ERROR_KIND_SET = new Set([ 'expired', @@ -227,9 +237,11 @@ export function normalizeDaemonEvent( 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 } : {}), @@ -239,9 +251,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); @@ -325,7 +367,7 @@ function normalizeSessionUpdate( function normalizeToolUpdate( update: Record, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent { const metadata = isRecord(update['_meta']) ? update['_meta'] : undefined; const toolName = @@ -488,7 +530,7 @@ function capDetails(details: string): string { function normalizePermissionRequest( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { if (!isRecord(event.data)) { return [ @@ -530,7 +572,7 @@ function normalizePermissionRequest( function normalizePermissionResolved( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const requestId = getString(event.data, 'requestId'); if (!requestId) { @@ -625,7 +667,7 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { function fallbackDebug( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, reason: string, ): DaemonUiEvent[] { return [ @@ -639,7 +681,7 @@ function fallbackDebug( function normalizeSessionMetadataUpdated( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const sessionId = getString(event.data, 'sessionId'); if (!sessionId) return fallbackDebug(event, base, 'missing sessionId'); @@ -656,7 +698,7 @@ function normalizeSessionMetadataUpdated( function normalizeApprovalModeChanged( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const sessionId = getString(event.data, 'sessionId'); const previous = getString(event.data, 'previous'); @@ -682,7 +724,7 @@ function normalizeApprovalModeChanged( function normalizeMemoryChanged( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const scope = getString(event.data, 'scope'); const filePath = getString(event.data, 'filePath'); @@ -713,7 +755,7 @@ function normalizeMemoryChanged( function normalizeAgentChanged( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const change = getString(event.data, 'change'); const name = getString(event.data, 'name'); @@ -738,7 +780,7 @@ function normalizeAgentChanged( function normalizeToolToggled( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const toolName = getString(event.data, 'toolName'); const enabled = @@ -760,7 +802,7 @@ function normalizeToolToggled( function normalizeWorkspaceInitialized( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const path = getString(event.data, 'path'); const action = getString(event.data, 'action'); @@ -779,7 +821,7 @@ function normalizeWorkspaceInitialized( function normalizeMcpBudgetWarning( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { if (!isRecord(event.data)) { return fallbackDebug(event, base, 'non-object payload'); @@ -813,7 +855,7 @@ function normalizeMcpBudgetWarning( function normalizeMcpChildRefused( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { if (!isRecord(event.data)) { return fallbackDebug(event, base, 'non-object payload'); @@ -871,7 +913,7 @@ function normalizeMcpChildRefused( function normalizeMcpServerRestarted( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const serverName = getString(event.data, 'serverName'); const durationMs = numberField(event.data, 'durationMs'); @@ -890,7 +932,7 @@ function normalizeMcpServerRestarted( function normalizeMcpServerRestartRefused( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const serverName = getString(event.data, 'serverName'); const reason = getString(event.data, 'reason'); @@ -913,7 +955,7 @@ function normalizeMcpServerRestartRefused( function normalizeAuthDeviceFlowStarted( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const deviceFlowId = getString(event.data, 'deviceFlowId'); const providerId = getString(event.data, 'providerId'); @@ -943,7 +985,7 @@ function normalizeAuthDeviceFlowStarted( function normalizeAuthDeviceFlowThrottled( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const deviceFlowId = getString(event.data, 'deviceFlowId'); const intervalMs = numberField(event.data, 'intervalMs'); @@ -966,7 +1008,7 @@ function normalizeAuthDeviceFlowThrottled( function normalizeAuthDeviceFlowAuthorized( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const deviceFlowId = getString(event.data, 'deviceFlowId'); const providerId = getString(event.data, 'providerId'); @@ -997,7 +1039,7 @@ function normalizeAuthDeviceFlowAuthorized( function normalizeAuthDeviceFlowFailed( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const deviceFlowId = getString(event.data, 'deviceFlowId'); const errorKind = getString(event.data, 'errorKind'); @@ -1026,7 +1068,7 @@ function normalizeAuthDeviceFlowFailed( function normalizeAuthDeviceFlowCancelled( event: DaemonEvent, - base: Pick, + base: NormalizedEventBase, ): DaemonUiEvent[] { const deviceFlowId = getString(event.data, 'deviceFlowId'); if (!deviceFlowId) { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index d4e71947d2c..b301a8f1047 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -198,7 +198,13 @@ function appendTextDelta( return; } - const block = createTextBlock(state, kind, text, event.eventId); + const block = createTextBlock( + state, + kind, + text, + event.eventId, + event.serverTimestamp, + ); if (kind === 'assistant') block.streaming = true; if (kind === 'thought') block.collapsed = true; appendBlock(state, block); @@ -274,9 +280,13 @@ function upsertToolBlock( toolName: event.toolName, toolKind: event.toolKind, }), + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), ...(event.details ? { details: event.details } : {}), ...(event.content !== undefined ? { content: event.content } : {}), ...(event.locations !== undefined ? { locations: event.locations } : {}), @@ -310,9 +320,13 @@ function appendShellBlock( id: allocateBlockId(state, 'shell'), kind: 'shell', text: truncateText(event.text), + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), ...(event.stream ? { stream: event.stream } : {}), }; appendBlock(state, block); @@ -345,9 +359,13 @@ function upsertPermissionBlock( title: event.title, options: event.options.map((option) => ({ ...option })), preview, + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), ...(event.sessionId ? { sessionId: event.sessionId } : {}), ...(event.toolCall !== undefined ? { toolCall: event.toolCall } : {}), }; @@ -378,9 +396,13 @@ function resolvePermissionBlock( options: [], preview: { kind: 'generic', summary: event.outcome }, resolved: event.outcome, + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), }; appendBlock(state, block); state.permissionBlockByRequestId[event.requestId] = block.id; @@ -398,9 +420,13 @@ function appendStatusBlock( id: allocateBlockId(state, kind), kind, text: truncateText(text), + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(event?.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event?.serverTimestamp !== undefined + ? { serverTimestamp: event.serverTimestamp } + : {}), }; appendBlock(state, block); if (opts.clearActiveText !== false) clearActiveText(state); @@ -411,14 +437,17 @@ function createTextBlock( kind: 'user' | 'assistant' | 'thought', text: string, eventId?: number, + serverTimestamp?: number, ): DaemonTextTranscriptBlock { return { id: allocateBlockId(state, kind), kind, text: truncateText(text), + clientReceivedAt: state.now, createdAt: state.now, updatedAt: state.now, ...(eventId !== undefined ? { eventId } : {}), + ...(serverTimestamp !== undefined ? { serverTimestamp } : {}), }; } @@ -594,3 +623,82 @@ function assertNever(value: never): never { `Unhandled daemon transcript event: ${JSON.stringify(value)}`, ); } + +/* ────────────────────────────────────────────────────────────────────────── + * PR-B helpers: timestamp ordering + formatting + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Return transcript blocks sorted by **daemon-authoritative** ordering. Use + * this instead of `state.blocks` when displaying a long session where event + * id 5 may arrive AFTER event id 7 (typical in SSE replay-after-reconnect). + * + * Ordering precedence: + * 1. `eventId` (daemon-monotonic SSE cursor) — primary key + * 2. `serverTimestamp` (daemon wall clock) — fallback for synthetic frames + * 3. `clientReceivedAt` (local clock) — last resort + * + * Returns a new array — callers can rely on referential stability of + * untouched blocks (structural sharing in the reducer) but the array + * itself is fresh. + */ +export function selectTranscriptBlocksOrderedByEventId( + state: DaemonTranscriptState, +): readonly DaemonTranscriptBlock[] { + return [...state.blocks].sort(compareBlocksByEventOrder); +} + +function compareBlocksByEventOrder( + a: DaemonTranscriptBlock, + b: DaemonTranscriptBlock, +): number { + // Primary: eventId (monotonic when present). + if (a.eventId !== undefined && b.eventId !== undefined) { + return a.eventId - b.eventId; + } + if (a.eventId !== undefined) return -1; + if (b.eventId !== undefined) return 1; + // Fallback: serverTimestamp. + if (a.serverTimestamp !== undefined && b.serverTimestamp !== undefined) { + return a.serverTimestamp - b.serverTimestamp; + } + if (a.serverTimestamp !== undefined) return -1; + if (b.serverTimestamp !== undefined) return 1; + // Last resort: client clock at the moment of receipt. + return a.clientReceivedAt - b.clientReceivedAt; +} + +/** + * Format the most authoritative timestamp on a block as a localized + * string. Prefers `serverTimestamp` (cross-client consistent), falls back + * to `clientReceivedAt` (always set, but client-clock). + * + * Returns `''` if the block has neither — defensive against future block + * types that may not carry timestamps. + * + * @example + * formatBlockTimestamp(block) // "2026-05-20 14:32:18" + * formatBlockTimestamp(block, { locale: 'zh-CN', timeStyle: 'short' }) + */ +export function formatBlockTimestamp( + block: DaemonTranscriptBlock, + opts: { + locale?: string; + timeZone?: string; + timeStyle?: 'short' | 'medium' | 'long' | 'full'; + dateStyle?: 'short' | 'medium' | 'long' | 'full'; + } = {}, +): string { + const ts = block.serverTimestamp ?? block.clientReceivedAt; + if (typeof ts !== 'number' || !Number.isFinite(ts)) return ''; + const formatter = new Intl.DateTimeFormat(opts.locale, { + ...(opts.timeZone ? { timeZone: opts.timeZone } : {}), + ...(opts.dateStyle + ? { dateStyle: opts.dateStyle } + : { dateStyle: 'short' }), + ...(opts.timeStyle + ? { timeStyle: opts.timeStyle } + : { timeStyle: 'medium' }), + }); + return formatter.format(new Date(ts)); +} diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 130e3f154d3..7e03ab20eed 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -50,7 +50,23 @@ export type DaemonUiEventType = export interface DaemonUiEventBase { type: DaemonUiEventType; + /** + * Daemon-monotonic SSE cursor. Use as the **primary ordering key** when + * sorting events or transcript blocks — independent of any clock and + * preserved across reconnects via `Last-Event-ID` replay. + */ eventId?: number; + /** + * Daemon-authoritative wall-clock timestamp (ms since epoch). Extracted + * from `event._meta.serverTimestamp` if present. Use as the fallback + * ordering key when `eventId` is absent (synthetic frames). Always + * prefer this over client clock for cross-client "X minutes ago" display + * — multiple subscribers viewing the same session see the same value. + * + * Undefined when the daemon did not stamp the envelope. Forward-compat: + * the SDK reads the field whether the daemon emits it today or not. + */ + serverTimestamp?: number; originatorClientId?: string; rawEvent?: DaemonEvent; } @@ -387,8 +403,40 @@ export type DaemonTranscriptBlockKind = export interface DaemonTranscriptBlockBase { id: string; kind: DaemonTranscriptBlockKind; + /** + * Daemon-monotonic SSE cursor. Primary ordering key — use this for + * `blocks.sort((a, b) => (a.eventId ?? 0) - (b.eventId ?? 0))` instead + * of `createdAt`, which is client-clock-based and unstable under + * replay/reconnect (see PR-B time-schema notes). + */ eventId?: number; + /** + * Daemon-authoritative wall-clock timestamp captured when the block was + * first observed. Mirrors the event's `serverTimestamp`. Undefined when + * the daemon did not stamp the envelope (current state) or when the + * block was created locally (e.g., `appendLocalUserTranscriptMessage`). + * + * **Prefer this** over `createdAt` for cross-client "X minutes ago" + * display: clients viewing the same session see the same value. + */ + serverTimestamp?: number; + /** + * Same as the previous `createdAt` semantics — client-local clock at the + * moment the block was first observed. Renamed for clarity: + * - `clientReceivedAt`: when **this** client saw the event (always set) + * - `serverTimestamp`: when the daemon emitted it (may be unset) + * + * Backwards-compatible alias `createdAt` remains as a getter for + * existing consumers; new code should use `clientReceivedAt`. + */ + clientReceivedAt: number; + /** + * @deprecated Use `clientReceivedAt` instead. Preserved for backwards + * compatibility with code written before PR-B. Always equals + * `clientReceivedAt`. + */ createdAt: number; + /** Client-local clock at the moment the block was last mutated. */ updatedAt: number; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 6bd9a9304cd..d117e0813ef 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1770,3 +1770,184 @@ describe('daemon UI normalizer — Wave 3/4 event coverage (PR-A)', () => { expect(state.lastEventId).toBe(100); }); }); + +describe('daemon UI time schema (PR-B)', () => { + it('extracts serverTimestamp from envelope _meta and propagates to blocks', () => { + const eventWithMeta = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + _meta: { serverTimestamp: 1_900_000_000_000 }, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi' }, + }, + }, + } as never); + expect(eventWithMeta[0]).toMatchObject({ + type: 'assistant.text.delta', + serverTimestamp: 1_900_000_000_000, + }); + + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + eventWithMeta, + { now: 2 }, + ); + expect(state.blocks[0]).toMatchObject({ + kind: 'assistant', + serverTimestamp: 1_900_000_000_000, + clientReceivedAt: 2, + createdAt: 2, + }); + }); + + it('extracts serverTimestamp from data._meta as fallback (sessionUpdate nested location)', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + _meta: { serverTimestamp: 1_888_888_888_888 }, + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + } as never); + expect(events[0]).toMatchObject({ + type: 'user.text.delta', + serverTimestamp: 1_888_888_888_888, + }); + }); + + it('extracts serverTimestamp from top-level envelope field when present', () => { + const events = normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'model_switched', + serverTimestamp: 1_777_777_777_777, + data: { sessionId: 's', modelId: 'qwen-coder-flash' }, + } as never); + expect(events[0]).toMatchObject({ + type: 'model.changed', + serverTimestamp: 1_777_777_777_777, + }); + }); + + it('defaults serverTimestamp undefined when envelope has none (forward-compat with older daemons)', () => { + const events = normalizeDaemonEvent({ + id: 4, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'no ts' }, + }, + }, + }); + expect(events[0]).not.toHaveProperty('serverTimestamp'); + }); + + it('selectTranscriptBlocksOrderedByEventId sorts by eventId, ignoring out-of-order arrival', async () => { + const { selectTranscriptBlocksOrderedByEventId } = await import( + '../../src/daemon/ui/index.js' + ); + let state = createDaemonTranscriptState({ now: 1 }); + // Push 3 blocks; insert ids in mixed arrival order (replay scenario) + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent({ + id: 10, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'ten' }, + }, + }, + } as never), + ], + { now: 2 }, + ); + // simulate a later (higher id) event arriving first, then earlier replayed + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent({ + id: 20, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 't', + title: 'twenty', + status: 'completed', + }, + }, + } as never), + ], + { now: 3 }, + ); + // Append a third with id between the two (would normally arrive earlier + // but replay delivered it out-of-order). + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent({ + id: 15, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'fifteen' }, + }, + }, + } as never), + ], + { now: 4 }, + ); + + const ordered = selectTranscriptBlocksOrderedByEventId(state); + const eventIds = ordered.map((b) => b.eventId); + expect(eventIds).toEqual([10, 15, 20]); + }); + + it('formatBlockTimestamp prefers serverTimestamp over clientReceivedAt', async () => { + const { formatBlockTimestamp } = await import( + '../../src/daemon/ui/index.js' + ); + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + _meta: { serverTimestamp: 1_900_000_000_000 }, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'x' }, + }, + }, + } as never); + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + events, + { now: 2 }, + ); + const formatted = formatBlockTimestamp(state.blocks[0]!, { + locale: 'en-US', + timeZone: 'UTC', + dateStyle: 'long', + timeStyle: 'medium', + }); + // 1_900_000_000_000 ms = March 17, 2030 UTC + expect(formatted).toContain('2030'); + expect(formatted).toContain('March'); + }); +}); From 058b75db5284325eb54fa027dcd978b9a5c59050 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:53:34 +0800 Subject: [PATCH 03/24] =?UTF-8?q?feat(sdk/daemon-ui):=20reducer=20state=20?= =?UTF-8?q?machine=20=E2=80=94=20currentTool=20/=20approvalMode=20/=20canc?= =?UTF-8?q?ellation=20propagation=20(PR-E)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "reducer state machine 设计缺漏" gap surfaced in the PR #4328 review: - No `currentTool` — UI scans `blocks[]` to find the running tool - No mirrored approval mode — UI walks events to badge "plan"/"yolo" - Cancellation does not propagate — in-flight tool blocks stuck at 'in_progress' forever when the parent prompt is cancelled ## State additions (sidechannel, no transcript blocks) `DaemonTranscriptSidechannelState`: - `currentToolCallId?: string` — toolCallId of the in-flight tool - `approvalMode?: string` — mirrored from session.approval_mode.changed - `toolProgress: Record` — per-tool progress shape (daemon-side emission of `tool.progress` events pending) ## Reducer behavior ### `tool.update` events `IN_FLIGHT_TOOL_STATUSES` = { pending, confirming, running, in_progress } `TERMINAL_TOOL_STATUSES` = { completed, success, failed, error, canceled, cancelled } - Tool enters in-flight: set `currentToolCallId = event.toolCallId` - Tool enters terminal: clear `currentToolCallId` if it matches - Unknown status (forward-compat): leave pointer untouched This avoids the failure mode where a future daemon-emitted status like `'paused'` would silently mark unknown states as either in-flight or terminal incorrectly. ### `session.approval_mode.changed` Mirror `event.next` onto `state.approvalMode`. Renderers can render a mode badge ("plan" / "default" / "auto-edit" / "yolo") with a single selector call, no event-stream walking. ### `assistant.done` with `reason === 'cancelled'` `propagateCancellationToInFlightTools` walks every tool block whose status is still in-flight and force-sets it to 'cancelled'. The daemon does not guarantee terminal `tool_call_update` for every in-flight tool when the parent prompt is cancelled, so this propagation prevents UI spinners from spinning forever. `currentToolCallId` is also cleared in the same call. Non-cancellation `assistant.done` (e.g., `reason: 'end_turn'`) does NOT propagate — in-flight tools remain in-flight until the daemon emits their terminal update naturally. ## Selectors - `selectCurrentTool(state)` — returns the running tool block, or undefined - `selectApprovalMode(state)` — returns the mirrored approval mode - `selectToolProgress(state, toolCallId)` — per-tool progress query All exported from `@qwen-code/sdk/daemon`. ## Scope deliberately deferred Subagent nesting (`parentBlockId` / `delegationId` / `DaemonSubagentTranscriptBlock`) is NOT in this PR. The shape needs design discussion (how to project nested events; whether to bake delegation tracking into transcript or sidechannel). PR-D / PR-F follow-up. ## Test coverage (51/51 pass) - currentToolCallId set on enter, cleared on terminal - approvalMode mirrors changes - Cancellation marks in-flight tools 'cancelled', leaves completed alone - Unknown status does NOT clear currentToolCallId (forward-compat) - Non-cancellation `assistant.done` does NOT propagate ## Roadmap PR-E of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E in this branch; PR-C / PR-D pending). Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/index.ts | 3 + .../src/daemon/ui/transcript.ts | 135 +++++++++- .../sdk-typescript/src/daemon/ui/types.ts | 28 ++- .../sdk-typescript/test/unit/daemonUi.test.ts | 236 ++++++++++++++++++ 4 files changed, 400 insertions(+), 2 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index a055b7f818a..4a382f19364 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -12,7 +12,10 @@ export { formatBlockTimestamp, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, + selectApprovalMode, + selectCurrentTool, selectPendingPermissionBlocks, + selectToolProgress, selectTranscriptBlocks, selectTranscriptBlocksOrderedByEventId, } from './transcript.js'; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index b301a8f1047..8a81730601a 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -33,12 +33,42 @@ export function createDaemonTranscriptState( toolBlockByCallId: {}, trimmedToolNotificationByCallId: {}, permissionBlockByRequestId: {}, + // PR-E sidechannel: track current tool / approval mode / progress + toolProgress: {}, nextOrdinal: 1, now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS, }; } +/** + * Tool statuses that count as "in-flight" — when one of these is set, the + * tool block is considered active and `state.currentToolCallId` mirrors + * its id. Closed list; daemon-side may emit other status values (e.g., + * future `'paused'`) — those are NOT treated as in-flight here. + */ +const IN_FLIGHT_TOOL_STATUSES: ReadonlySet = new Set([ + 'pending', + 'confirming', + 'running', + 'in_progress', +]); + +/** + * Tool statuses that terminate the in-flight phase. Any other status + * (including unknown future ones) keeps the tool considered in-flight, + * which is the forward-compat-friendly default — the alternative would + * silently mark unknown states as terminal. + */ +const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ + 'completed', + 'success', + 'failed', + 'error', + 'canceled', + 'cancelled', +]); + export function appendLocalUserTranscriptMessage( state: DaemonTranscriptState, text: string, @@ -97,6 +127,13 @@ function applyDaemonTranscriptEvent( break; case 'assistant.done': finishAssistant(next); + // PR-E cancellation propagation: when the assistant turn was + // cancelled, any in-flight tool block whose status the daemon + // never updated to a terminal state would otherwise spin forever. + // Force them to 'cancelled' so renderers can clear spinners. + if (event.reason === 'cancelled') { + propagateCancellationToInFlightTools(next); + } break; case 'thought.text.delta': appendTextDelta( @@ -140,8 +177,12 @@ function applyDaemonTranscriptEvent( // chat-stream transcript stays focused on user/assistant/tool/shell/ // permission content. PRs in the C/D series may opt some of these // into transcript projection as structured non-chat blocks. - case 'session.metadata.changed': case 'session.approval_mode.changed': + // PR-E sidechannel: mirror the new approval mode onto state so + // renderers don't have to walk events. + next.approvalMode = event.next; + break; + case 'session.metadata.changed': case 'session.available_commands': case 'workspace.memory.changed': case 'workspace.agent.changed': @@ -266,6 +307,7 @@ function upsertToolBlock( if (event.rawOutput !== undefined) existing.rawOutput = event.rawOutput; if (event.toolName) existing.toolName = event.toolName; if (event.toolKind) existing.toolKind = event.toolKind; + updateCurrentToolPointer(state, event.toolCallId, event.status); return; } @@ -297,9 +339,54 @@ function upsertToolBlock( }; appendBlock(state, block); state.toolBlockByCallId[event.toolCallId] = block.id; + updateCurrentToolPointer(state, event.toolCallId, event.status); clearActiveText(state); } +/** + * PR-E: maintain `state.currentToolCallId`. Sets when tool enters in-flight + * status; clears when tool enters terminal status; leaves untouched for + * unknown statuses (forward-compat). + */ +function updateCurrentToolPointer( + state: DaemonTranscriptState, + toolCallId: string, + status: string | undefined, +): void { + if (status === undefined) return; + if (IN_FLIGHT_TOOL_STATUSES.has(status)) { + state.currentToolCallId = toolCallId; + return; + } + if (TERMINAL_TOOL_STATUSES.has(status)) { + if (state.currentToolCallId === toolCallId) { + state.currentToolCallId = undefined; + } + return; + } + // Unknown status (forward-compat): leave pointer as-is. +} + +/** + * PR-E cancellation propagation: walk every tool block whose status is + * still in-flight and force it to `'cancelled'`. Triggered when + * `assistant.done.reason === 'cancelled'` since the daemon does not + * guarantee a terminal `tool_call_update` for every in-flight tool when + * the parent prompt is cancelled. + */ +function propagateCancellationToInFlightTools( + state: DaemonTranscriptState, +): void { + for (const blockId of Object.values(state.toolBlockByCallId)) { + const block = getWritableBlockById(state, blockId); + if (!block || block.kind !== 'tool') continue; + if (!IN_FLIGHT_TOOL_STATUSES.has(block.status)) continue; + block.status = 'cancelled'; + block.updatedAt = state.now; + } + state.currentToolCallId = undefined; +} + function appendShellBlock( state: DaemonTranscriptState, event: Extract, @@ -648,6 +735,52 @@ export function selectTranscriptBlocksOrderedByEventId( return [...state.blocks].sort(compareBlocksByEventOrder); } +/* ────────────────────────────────────────────────────────────────────────── + * PR-E selectors — sidechannel state queries + * ──────────────────────────────────────────────────────────────────────── */ + +/** + * Return the currently-running tool block, or `undefined` when no tool is + * in flight. Used by UI to render a "正在运行 X" header without scanning + * `blocks[]`. + */ +export function selectCurrentTool( + state: DaemonTranscriptState, +): Extract | undefined { + const id = state.currentToolCallId; + if (!id) return undefined; + const blockId = state.toolBlockByCallId[id]; + if (!blockId || blockId === TRIMMED_TOOL_BLOCK_ID) return undefined; + const index = state.blockIndexById[blockId]; + if (index === undefined) return undefined; + const block = state.blocks[index]; + return block?.kind === 'tool' ? block : undefined; +} + +/** + * Approval mode currently active for the session, mirrored from + * `session.approval_mode.changed` events. `undefined` until the daemon + * emits at least one change event. + */ +export function selectApprovalMode( + state: DaemonTranscriptState, +): string | undefined { + return state.approvalMode; +} + +/** + * Per-tool progress query. Returns `undefined` if no progress has been + * recorded for the given toolCallId. The shape `{ ratio?, step? }` matches + * the eventual `tool.progress` event payload (daemon-side emission + * pending — SDK is ready to consume). + */ +export function selectToolProgress( + state: DaemonTranscriptState, + toolCallId: string, +): { ratio?: number; step?: string } | undefined { + return state.toolProgress[toolCallId]; +} + function compareBlocksByEventOrder( a: DaemonTranscriptBlock, b: DaemonTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 7e03ab20eed..ba376c924ab 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -492,7 +492,33 @@ export type DaemonTranscriptBlock = | DaemonPermissionTranscriptBlock | DaemonStatusTranscriptBlock; -export interface DaemonTranscriptState { +/** + * PR-E sidechannel state — workspace / session state mirror that tracks + * non-chat events without polluting the chat-stream `blocks[]`. + */ +export interface DaemonTranscriptSidechannelState { + /** + * `toolCallId` of the tool currently in `running` / `in_progress` / + * `pending`. Updated by the reducer when a `tool.update` event arrives; + * cleared when the tool terminates. Used by UI to show a "正在运行 X tool" + * status header without scanning `blocks[]`. + */ + currentToolCallId?: string; + /** + * Approval mode for the current session, mirrored from + * `session.approval_mode.changed` events. Renderers use this to badge + * the input area ("plan" / "default" / "auto-edit" / "yolo"). + */ + approvalMode?: string; + /** + * Per-tool progress map, keyed by `toolCallId`. Populated by future + * `tool.progress` events (daemon-side emission pending — the SDK is + * ready to consume the field shape today). + */ + toolProgress: Record; +} + +export interface DaemonTranscriptState extends DaemonTranscriptSidechannelState { blocks: DaemonTranscriptBlock[]; lastEventId?: number; activeUserBlockId?: string; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d117e0813ef..1527218dbda 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1951,3 +1951,239 @@ describe('daemon UI time schema (PR-B)', () => { expect(formatted).toContain('March'); }); }); + +describe('daemon UI reducer state machine (PR-E)', () => { + it('tracks currentToolCallId as tools enter and leave in-flight', async () => { + const { selectCurrentTool } = await import('../../src/daemon/ui/index.js'); + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'long task', + status: 'running', + }, + }, + } as never), + { now: 2 }, + ); + expect(state.currentToolCallId).toBe('call-1'); + expect(selectCurrentTool(state)).toMatchObject({ + kind: 'tool', + toolCallId: 'call-1', + status: 'running', + }); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + }, + }, + } as never), + { now: 3 }, + ); + expect(state.currentToolCallId).toBeUndefined(); + expect(selectCurrentTool(state)).toBeUndefined(); + }); + + it('mirrors approval mode from session.approval_mode.changed event', async () => { + const { selectApprovalMode } = await import('../../src/daemon/ui/index.js'); + let state = createDaemonTranscriptState({ now: 1 }); + expect(selectApprovalMode(state)).toBeUndefined(); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'approval_mode_changed', + data: { + sessionId: 's', + previous: 'default', + next: 'plan', + persisted: false, + }, + } as never), + { now: 2 }, + ); + expect(state.approvalMode).toBe('plan'); + expect(selectApprovalMode(state)).toBe('plan'); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'approval_mode_changed', + data: { + sessionId: 's', + previous: 'plan', + next: 'yolo', + persisted: true, + }, + } as never), + { now: 3 }, + ); + expect(selectApprovalMode(state)).toBe('yolo'); + }); + + it('propagates cancellation to in-flight tool blocks on assistant.done with reason=cancelled', () => { + let state = createDaemonTranscriptState({ now: 1 }); + + // Two tools in flight + one already completed + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'a', + title: 'A', + status: 'running', + }, + }, + } as never), + ...normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'b', + title: 'B', + status: 'pending', + }, + }, + } as never), + ...normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'c', + title: 'C', + status: 'completed', + }, + }, + } as never), + ], + { now: 2 }, + ); + + // Cancel — propagation should mark a/b as cancelled, leave c untouched. + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'cancelled' }], + { now: 3 }, + ); + + const toolBlocks = state.blocks.filter( + (b): b is Extract => + b.kind === 'tool', + ); + const a = toolBlocks.find((b) => b.toolCallId === 'a')!; + const b = toolBlocks.find((b2) => b2.toolCallId === 'b')!; + const c = toolBlocks.find((b3) => b3.toolCallId === 'c')!; + expect(a.status).toBe('cancelled'); + expect(b.status).toBe('cancelled'); + expect(c.status).toBe('completed'); + expect(state.currentToolCallId).toBeUndefined(); + }); + + it('forward-compat: unknown tool status does NOT clear currentToolCallId', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'a', + title: 'A', + status: 'running', + }, + }, + } as never), + { now: 2 }, + ); + expect(state.currentToolCallId).toBe('a'); + + // Daemon emits a future 'paused' status the SDK doesn't know. + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'a', + status: 'paused', + }, + }, + } as never), + { now: 3 }, + ); + // currentToolCallId should remain — unknown status is forward-compat. + expect(state.currentToolCallId).toBe('a'); + }); + + it('explicit assistant.done without reason does NOT propagate cancellation', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + [ + ...normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'a', + title: 'A', + status: 'running', + }, + }, + } as never), + ], + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'end_turn' }], + { now: 3 }, + ); + const toolBlock = state.blocks.find( + (b): b is Extract => + b.kind === 'tool', + )!; + expect(toolBlock.status).toBe('running'); + expect(state.currentToolCallId).toBe('a'); + }); +}); From 4dbc349dad8cd40cc2ce5015c4329ee51601bdde Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:59:44 +0800 Subject: [PATCH 04/24] feat(sdk/daemon-ui): tool preview taxonomy + multimodal content extraction (PR-C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes two related gaps surfaced in the PR #4328 review: - `DaemonToolPreview` had only 4 kinds — UI fell back to `key_value` / `generic` for tools that deserved structured display - `getTextContent` silently dropped non-text content (image / audio / resource), so multimodal conversations vanished from the UI `DaemonToolPreview` extends from 4 to 8 variants: - `file_diff` — `{ path, oldText?, newText?, patch? }` — file edit tools (Anthropic-style `oldText/newText`, aider-style `patch`, write-style `newText` alone) - `file_read` — `{ path, range?: [start, end] }` — file read tools, with range extracted from `lineRange` tuple OR `offset/limit` pair - `web_fetch` — `{ url, method? }` — HTTP fetch tools (requires URL with scheme to avoid false positives on relative paths) - `mcp_invocation` — `{ serverId, toolName, argsSummary? }` — MCP server tool calls, identified via `mcp____` naming convention (same heuristic as PR-A `DaemonUiToolUpdateEvent.provenance`) Detector order matters — MCP wins first (most specific), then file_diff, file_read, web_fetch, then the existing command / key_value fallbacks. New helper `extractContentPart(value): DaemonUiContentPart | undefined` returns a discriminated union: ```ts type DaemonUiContentPart = | { kind: 'text'; text: string } | { kind: 'image'; mediaType: string; source: { url?, data? } } | { kind: 'audio'; mediaType: string; source: { url?, data? } } | { kind: 'resource'; uri: string; mediaType?, description? }; ``` The existing `getTextContent` is preserved for backward compat. Renderers that need to surface non-text content (web UI thumbnails, IDE attachment chips) now have a typed shape to consume. - Wiring `extractContentPart` into the normalizer / reducer so text blocks accumulate `parts: DaemonUiContentPart[]` alongside `text` (additive shape change requires render contract coordination — PR-D). - 5 additional tool preview kinds (image_generation / code_block / tabular / subagent_delegation / search) — useful but not urgent; current 8 kinds cover the typical agent flows. - file_diff detection from Anthropic / aider / write shapes - file_read with lineRange tuple AND offset+limit pair - web_fetch with method, REJECTS relative paths (no scheme) - mcp_invocation with serverId + toolName extraction - Detector priority: MCP wins over file_diff on conflicting shapes - extractContentPart for text / image (url) / audio (data) / resource - Unknown content type returns undefined (skip rather than synthesize) - Image without source returns undefined (defensive) PR-C of the unified follow-up to PR #4328 (PR-A + PR-B + PR-E + PR-C in this branch; PR-D render contract pending). Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/index.ts | 2 + .../src/daemon/ui/toolPreview.ts | 163 ++++++++++++++++ .../sdk-typescript/src/daemon/ui/types.ts | 39 +++- .../sdk-typescript/src/daemon/ui/utils.ts | 108 ++++++++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 184 ++++++++++++++++++ 5 files changed, 495 insertions(+), 1 deletion(-) diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 4a382f19364..604913db99a 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -25,6 +25,7 @@ export { transcriptBlockToTerminalText, } from './terminal.js'; export { + extractContentPart, getOutputText, isSensitiveKey as isDaemonUiSensitiveKey, redactSensitiveFields as redactDaemonUiSensitiveFields, @@ -33,6 +34,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, diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 79b6ba2b7de..1a2b9888b6e 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -50,6 +50,21 @@ export function createDaemonToolPreview( return { kind: 'ask_user_question', questions: askUserQuestions }; } + // PR-C: try specific tool-shape detectors before falling back to + // generic command / key_value detection. Detector order matters — + // most specific wins. + const mcpPreview = detectMcpInvocation(input, opts); + if (mcpPreview) return mcpPreview; + + const fileDiff = detectFileDiff(input); + if (fileDiff) return fileDiff; + + const fileRead = detectFileRead(input, opts); + if (fileRead) return fileRead; + + const webFetch = detectWebFetch(input); + if (webFetch) return webFetch; + if (isRecord(input)) { const command = getFirstString(input, ['command', 'cmd']); if (command) { @@ -71,6 +86,154 @@ export function createDaemonToolPreview( return { kind: 'generic', ...(summary ? { summary } : {}) }; } +/** + * Detect file-edit tool calls by signature. Matches: + * + * - Anthropic-style: `oldText` + `newText` (or `old_str` + `new_str`) + * - Aider-style: `patch` text + * - All variants require a `path` / `filePath` field. + */ +function detectFileDiff(input: unknown): DaemonToolPreview | undefined { + if (!isRecord(input)) return undefined; + const path = getFirstString(input, [ + 'path', + 'filePath', + 'file_path', + 'absolutePath', + ]); + if (!path) return undefined; + const oldText = getFirstString(input, [ + 'oldText', + 'old_text', + 'old_str', + 'oldString', + ]); + const newText = getFirstString(input, [ + 'newText', + 'new_text', + 'new_str', + 'newString', + 'content', + ]); + const patch = getFirstString(input, ['patch', 'diff', 'unified_diff']); + // Require at least one of: oldText+newText pair (edit), patch (apply), + // newText (write). Pure path with no diff content → not a diff preview. + if (!oldText && !newText && !patch) return undefined; + return { + kind: 'file_diff', + path, + ...(oldText ? { oldText } : {}), + ...(newText ? { newText } : {}), + ...(patch ? { patch } : {}), + }; +} + +/** + * Detect file-read tool calls. Requires a path-like field and either an + * explicit read intent (toolName matches /read/i) OR optional range + * fields (lineRange / offset+limit). + */ +function detectFileRead( + input: unknown, + opts: { title?: string; toolName?: string; toolKind?: string }, +): DaemonToolPreview | undefined { + if (!isRecord(input)) return undefined; + const path = getFirstString(input, [ + 'path', + 'filePath', + 'file_path', + 'absolutePath', + ]); + if (!path) return undefined; + const toolName = opts.toolName ?? getFirstString(input, ['toolName', 'name']); + const looksLikeRead = + toolName !== undefined && /read|view|cat/i.test(toolName); + // Range extraction: prefer explicit lineRange tuple, fall back to + // offset+limit pair. + const rangeArr = input['lineRange'] ?? input['line_range'] ?? input['range']; + let range: readonly [number, number] | undefined; + if ( + Array.isArray(rangeArr) && + rangeArr.length === 2 && + typeof rangeArr[0] === 'number' && + typeof rangeArr[1] === 'number' + ) { + range = [rangeArr[0], rangeArr[1]] as const; + } else { + const offset = input['offset']; + const limit = input['limit']; + if (typeof offset === 'number' && typeof limit === 'number' && limit > 0) { + range = [offset, offset + limit - 1] as const; + } + } + if (!looksLikeRead && !range) return undefined; + return { + kind: 'file_read', + path, + ...(range ? { range } : {}), + }; +} + +/** + * Detect web_fetch tool calls. Matches a URL field plus optional method. + */ +function detectWebFetch(input: unknown): DaemonToolPreview | undefined { + if (!isRecord(input)) return undefined; + const url = getFirstString(input, ['url', 'uri', 'href']); + if (!url) return undefined; + // Require a `url` scheme to avoid false positives on relative paths. + if (!/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url)) return undefined; + const method = getFirstString(input, ['method', 'httpMethod']); + return { + kind: 'web_fetch', + url, + ...(method ? { method } : {}), + }; +} + +/** + * Detect MCP-invocation tool calls. Uses the `mcp____` + * naming convention from the provenance heuristic — same one introduced + * for `DaemonUiToolUpdateEvent.provenance` in PR-A. Lets the preview + * carry server + tool name structurally instead of as a generic title. + */ +function detectMcpInvocation( + input: unknown, + opts: { title?: string; toolName?: string; toolKind?: string }, +): DaemonToolPreview | undefined { + const toolName = + opts.toolName ?? + (isRecord(input) ? getFirstString(input, ['toolName', 'name']) : undefined); + if (!toolName || !toolName.startsWith('mcp__')) return undefined; + const rest = toolName.slice('mcp__'.length); + const sep = rest.indexOf('__'); + if (sep <= 0) return undefined; + const serverId = rest.slice(0, sep); + const toolPart = rest.slice(sep + 2); + // Summarize args for inline display — first key=value when possible. + let argsSummary: string | undefined; + if (isRecord(input)) { + const args = input['arguments'] ?? input['args'] ?? input; + if (isRecord(args)) { + const firstEntry = Object.entries(args) + .filter(([key]) => key !== 'name' && key !== 'toolName') + .slice(0, 1) + .map(([key, value]) => { + const v = typeof value === 'string' ? value : JSON.stringify(value); + const trimmed = v.length > 60 ? `${v.slice(0, 60)}…` : v; + return `${key}=${trimmed}`; + })[0]; + if (firstEntry) argsSummary = firstEntry; + } + } + return { + kind: 'mcp_invocation', + serverId, + toolName: toolPart, + ...(argsSummary ? { argsSummary } : {}), + }; +} + function extractAskUserQuestions(input: unknown): DaemonTranscriptQuestion[] { if (!isRecord(input) || !Array.isArray(input['questions'])) return []; return input['questions'].filter(isRecord).map((question) => { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index ba376c924ab..a551f06b945 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -380,6 +380,42 @@ export type DaemonToolPreview = command: string; cwd?: string; } + | { + kind: 'file_diff'; + path: string; + oldText?: string; + newText?: string; + /** + * Optional unified-diff text. When the daemon ships a pre-computed + * patch, prefer rendering this over recomputing in the UI. + */ + patch?: string; + } + | { + kind: 'file_read'; + path: string; + /** + * Optional `[startLine, endLine]` 1-based inclusive range. Undefined + * when the tool read the entire file. + */ + range?: readonly [number, number]; + } + | { + kind: 'web_fetch'; + url: string; + /** HTTP method (defaults to GET when daemon does not stamp it). */ + method?: string; + } + | { + kind: 'mcp_invocation'; + serverId: string; + toolName: string; + /** + * Trimmed argument summary. Full args remain on `rawInput`; this is + * a short string for inline display. + */ + argsSummary?: string; + } | { kind: 'key_value'; rows: Array<{ label: string; value: string }>; @@ -518,7 +554,8 @@ export interface DaemonTranscriptSidechannelState { toolProgress: Record; } -export interface DaemonTranscriptState extends DaemonTranscriptSidechannelState { +export interface DaemonTranscriptState + extends DaemonTranscriptSidechannelState { blocks: DaemonTranscriptBlock[]; lastEventId?: number; activeUserBlockId?: string; diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index 73f5e5d5fd5..088b8fe3b77 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -98,6 +98,114 @@ export function getTextContent(value: unknown): string { return typeof text === 'string' ? text : ''; } +/** + * PR-C: discriminated content part extracted from a daemon `content` field. + * + * Existing `getTextContent` returns only the `text` field, silently dropping + * multimodal content (`image` / `audio` / `resource`). `extractContentPart` + * returns the typed shape so renderers can decide how to project each kind: + * a chat bubble for `text`, a thumbnail for `image`, a play button for + * `audio`, an attachment link for `resource`. + * + * Returns `undefined` for unrecognized payloads — callers should treat that + * as "skip this content" rather than synthesizing a placeholder. + */ +export type DaemonUiContentPart = + | { kind: 'text'; text: string } + | { + kind: 'image'; + mediaType: string; + source: { url?: string; data?: string }; + } + | { + kind: 'audio'; + mediaType: string; + source: { url?: string; data?: string }; + } + | { + kind: 'resource'; + uri: string; + mediaType?: string; + description?: string; + }; + +export function extractContentPart( + value: unknown, +): DaemonUiContentPart | undefined { + if (typeof value === 'string') return { kind: 'text', text: value }; + if (!isRecord(value)) return undefined; + const type = value['type']; + if (type === 'text' || type === undefined) { + const text = value['text']; + if (typeof text === 'string') return { kind: 'text', text }; + return undefined; + } + if (type === 'image') { + const source = isRecord(value['source']) ? value['source'] : undefined; + if (!source) return undefined; + const mediaType = + (typeof value['mediaType'] === 'string' + ? (value['mediaType'] as string) + : undefined) ?? + (typeof source['mediaType'] === 'string' + ? (source['mediaType'] as string) + : undefined) ?? + 'image/*'; + const url = + typeof source['url'] === 'string' ? (source['url'] as string) : undefined; + const data = + typeof source['data'] === 'string' + ? (source['data'] as string) + : undefined; + if (!url && !data) return undefined; + return { + kind: 'image', + mediaType, + source: { ...(url ? { url } : {}), ...(data ? { data } : {}) }, + }; + } + if (type === 'audio') { + const source = isRecord(value['source']) ? value['source'] : undefined; + if (!source) return undefined; + const mediaType = + (typeof value['mediaType'] === 'string' + ? (value['mediaType'] as string) + : undefined) ?? 'audio/*'; + const url = + typeof source['url'] === 'string' ? (source['url'] as string) : undefined; + const data = + typeof source['data'] === 'string' + ? (source['data'] as string) + : undefined; + if (!url && !data) return undefined; + return { + kind: 'audio', + mediaType, + source: { ...(url ? { url } : {}), ...(data ? { data } : {}) }, + }; + } + if (type === 'resource' || type === 'resource_link') { + const uri = + typeof value['uri'] === 'string' ? (value['uri'] as string) : undefined; + if (!uri) return undefined; + const mediaType = + typeof value['mediaType'] === 'string' + ? (value['mediaType'] as string) + : undefined; + const description = + typeof value['description'] === 'string' + ? (value['description'] as string) + : undefined; + return { + kind: 'resource', + uri, + ...(mediaType ? { mediaType } : {}), + ...(description ? { description } : {}), + }; + } + return undefined; +} + const MAX_OUTPUT_TEXT_DEPTH = 64; export function getOutputText(value: unknown, depth = 0): string { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 1527218dbda..d6aeffec169 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2187,3 +2187,187 @@ describe('daemon UI reducer state machine (PR-E)', () => { expect(state.currentToolCallId).toBe('a'); }); }); + +describe('daemon UI tool preview taxonomy (PR-C)', () => { + it('detects file_diff from Anthropic-style oldText/newText', () => { + const preview = createDaemonToolPreview({ + path: '/work/foo.ts', + oldText: 'const x = 1', + newText: 'const x = 2', + }); + expect(preview).toMatchObject({ + kind: 'file_diff', + path: '/work/foo.ts', + oldText: 'const x = 1', + newText: 'const x = 2', + }); + }); + + it('detects file_diff from patch text', () => { + const preview = createDaemonToolPreview({ + filePath: '/work/bar.ts', + patch: '--- a/bar.ts\n+++ b/bar.ts\n@@ -1 +1 @@\n-old\n+new\n', + }); + expect(preview).toMatchObject({ + kind: 'file_diff', + path: '/work/bar.ts', + patch: expect.stringContaining('---') as string, + }); + }); + + it('detects file_read from tool name + range (lineRange)', () => { + const preview = createDaemonToolPreview( + { path: '/work/x.md', lineRange: [10, 20] }, + { toolName: 'Read' }, + ); + expect(preview).toMatchObject({ + kind: 'file_read', + path: '/work/x.md', + range: [10, 20], + }); + }); + + it('detects file_read from offset/limit pair', () => { + const preview = createDaemonToolPreview( + { path: '/work/y.md', offset: 100, limit: 50 }, + { toolName: 'View' }, + ); + expect(preview).toMatchObject({ + kind: 'file_read', + path: '/work/y.md', + range: [100, 149], + }); + }); + + it('detects web_fetch from URL with scheme', () => { + const preview = createDaemonToolPreview({ + url: 'https://api.example.com/data', + method: 'POST', + }); + expect(preview).toMatchObject({ + kind: 'web_fetch', + url: 'https://api.example.com/data', + method: 'POST', + }); + }); + + it('does NOT detect web_fetch from relative URL (no scheme)', () => { + const preview = createDaemonToolPreview({ + url: '/relative/path', + }); + expect(preview.kind).not.toBe('web_fetch'); + }); + + it('detects mcp_invocation from mcp____ naming', () => { + const preview = createDaemonToolPreview( + { arguments: { issueTitle: 'Bug report' } }, + { toolName: 'mcp__github__create_issue' }, + ); + expect(preview).toMatchObject({ + kind: 'mcp_invocation', + serverId: 'github', + toolName: 'create_issue', + }); + expect((preview as { argsSummary?: string }).argsSummary).toContain( + 'issueTitle', + ); + }); + + it('mcp_invocation takes priority over file_diff (more specific)', () => { + // Even if the input shape happens to match file_diff (e.g., an MCP + // tool that edits files), MCP provenance wins. + const preview = createDaemonToolPreview( + { + path: '/x', + oldText: 'a', + newText: 'b', + }, + { toolName: 'mcp__editor__patch_file' }, + ); + expect(preview.kind).toBe('mcp_invocation'); + }); + + it('falls back to command preview when no specific shape matches', () => { + const preview = createDaemonToolPreview({ + command: 'npm test', + cwd: '/work', + }); + expect(preview).toMatchObject({ + kind: 'command', + command: 'npm test', + cwd: '/work', + }); + }); +}); + +describe('daemon UI content extraction (PR-C)', () => { + it('extractContentPart returns text for string', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + expect(extractContentPart('hello')).toEqual({ + kind: 'text', + text: 'hello', + }); + }); + + it('extractContentPart returns text for { type: "text" } object', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + expect(extractContentPart({ type: 'text', text: 'hi' })).toEqual({ + kind: 'text', + text: 'hi', + }); + }); + + it('extractContentPart returns image with source.url', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + const part = extractContentPart({ + type: 'image', + mediaType: 'image/png', + source: { url: 'https://example.com/img.png' }, + }); + expect(part).toMatchObject({ + kind: 'image', + mediaType: 'image/png', + source: { url: 'https://example.com/img.png' }, + }); + }); + + it('extractContentPart returns audio with source.data', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + const part = extractContentPart({ + type: 'audio', + mediaType: 'audio/mpeg', + source: { data: 'base64-blob' }, + }); + expect(part).toMatchObject({ + kind: 'audio', + mediaType: 'audio/mpeg', + source: { data: 'base64-blob' }, + }); + }); + + it('extractContentPart returns resource with uri', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + const part = extractContentPart({ + type: 'resource', + uri: 'file:///work/README.md', + description: 'Project readme', + }); + expect(part).toMatchObject({ + kind: 'resource', + uri: 'file:///work/README.md', + description: 'Project readme', + }); + }); + + it('extractContentPart returns undefined for unknown content type', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + expect( + extractContentPart({ type: 'video', source: { url: 'x' } }), + ).toBeUndefined(); + }); + + it('extractContentPart returns undefined for image without source', async () => { + const { extractContentPart } = await import('../../src/daemon/ui/index.js'); + expect(extractContentPart({ type: 'image' })).toBeUndefined(); + }); +}); From f40c3e1731b58109902138569acb07be08489f6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 15:03:40 +0800 Subject: [PATCH 05/24] =?UTF-8?q?feat(sdk/daemon-ui):=20render=20contract?= =?UTF-8?q?=20=E2=80=94=20markdown=20/=20HTML=20/=20plain=20text=20helpers?= =?UTF-8?q?=20(PR-D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the "render 契约只覆盖 terminal" gap surfaced in the PR #4328 review: > PR ships `daemonUiEventToTerminalText` for terminal. Web/IDE/channel > adapters each roll their own projection. No shared contract → adapter > divergence is inevitable. ## New helpers ```ts daemonBlockToMarkdown(block, opts?): string // GFM-compatible daemonBlockToHtml(block, opts?): string // conservatively escaped HTML daemonBlockToPlainText(block, opts?): string // for copy-paste / logs daemonToolPreviewToMarkdown(preview, opts?): string ``` All three respect the same `kind` discrimination so adapters can switch between them without touching call sites. ## Per-kind projection For each `DaemonTranscriptBlock['kind']`: - `user` / `assistant` / `thought` — plain text with role labels - `tool` — header with toolName + structured preview + status badge - `shell` — fenced code block, stream-discriminated (stdout vs stderr) - `permission` — title + options list + resolved/pending indicator - `status` / `debug` / `error` — semantic class / role (error → role=alert) For each `DaemonToolPreview['kind']`: - `ask_user_question` — question + options as bullet list - `command` — fenced bash with optional cwd comment - `file_diff` — unified diff in fenced code block (oldText/newText OR patch) - `file_read` — `path (lines N-M)` line - `web_fetch` — `METHOD url` line - `mcp_invocation` — `serverId::toolName` with args summary - `key_value` — bullet list - `generic` — emphasized summary ## Security - Default HTML sanitizer escapes `<`, `>`, `&`, `"`, `'` and FIRST strips ANSI/control sequences via `sanitizeTerminalText` (defense against agent-emitted escape codes in HTML output). - Custom sanitizer hook for consumers wanting markdown→HTML pipelines (markdown-it + DOMPurify, etc.). - `sanitizeUrls` option strips token-like query params (`token=`, `key=`, `x-amz-`, etc.) from URLs in `web_fetch` previews. - `maxFieldLength` truncation defaults 8192, prevents pathological rendering on huge content. ## Adapter conformance (out of scope for this commit) The conformance test framework (fixture corpus + `runAdapterConformanceSuite`) mentioned in PR-D scope is deferred to a follow-up. The render helpers here are the precondition — once stable, the conformance framework can use them as the reference projection. ## Test coverage (77/77 pass) - All 9 block kinds render in markdown (verified for user/assistant/tool/ shell/permission/error specifically) - file_diff renders as unified diff with old/new lines - mcp_invocation renders as `server::tool` format - HTML escapes XSS (`', + { now: 2 }, + ); + const html = daemonBlockToHtml(state.blocks[0]!); + expect(html).not.toContain('` pass the protocol check. Modern browsers don't execute ``, but the comment claimed "never legitimate in ``" which slightly over-claimed the protection. Tighten the data: branch to require an `image/` MIME prefix. Verified by a new test that covers: https (allow), data:image/png (allow), data:text/html (reject → '#'), javascript: (reject → '#'). Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/render.ts | 30 ++++++++++--- .../sdk-typescript/test/unit/daemonUi.test.ts | 45 +++++++++++++++++++ 2 files changed, 68 insertions(+), 7 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/render.ts b/packages/sdk-typescript/src/daemon/ui/render.ts index 6004b90ce1d..ae578ad2a12 100644 --- a/packages/sdk-typescript/src/daemon/ui/render.ts +++ b/packages/sdk-typescript/src/daemon/ui/render.ts @@ -681,9 +681,13 @@ function sanitizeUrl(url: string): string { /** * Protocol-only validation for URLs that need XSS defense even when the - * caller hasn't opted into full sanitization. `javascript:` / `data:` / - * `vbscript:` URLs are never legitimate in `` / `![image]()` - * contexts; reject them up front regardless of `sanitizeUrls`. + * caller hasn't opted into full sanitization. `javascript:` / `vbscript:` + * URLs are never legitimate in `` / `![image]()` contexts; + * reject them up front regardless of `sanitizeUrls`. `data:` URIs are + * allowed ONLY when they carry an image media-type — modern browsers + * don't execute ``, but tightening the + * allow-list to `data:image/*` removes a defense-in-depth gap flagged + * in the post-merge audit. * * wenshao R2 (qwen3.7-max): added because `sanitizeUrls` is opt-in and * defaults to false, but image-URL XSS exposure has no legitimate @@ -691,11 +695,23 @@ function sanitizeUrl(url: string): string { */ function ensureSafeImageUrl(url: string): string { try { - const protocol = new URL(url).protocol.toLowerCase(); - if (protocol !== 'http:' && protocol !== 'https:' && protocol !== 'data:') { - return '#'; + const parsed = new URL(url); + const protocol = parsed.protocol.toLowerCase(); + if (protocol === 'http:' || protocol === 'https:') { + return url; + } + if (protocol === 'data:') { + // Only `data:image/[;base64],` is acceptable in + // an `` context. Other MIME types open avenues like + // `data:text/html,'), + ), + ).toContain('![image](#)'); + // javascript: → rejected to '#' + expect(daemonBlockToMarkdown(mkBlock('javascript:alert(1)'))).toContain( + '![image](#)', + ); + }); +}); From 599f1acb8d3d0265e092e41c35b6fa58e123ac7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 24 May 2026 00:52:25 +0800 Subject: [PATCH 21/24] fix(daemon-ui): wenshao + doudouOUC R4 review batch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks 6 wenshao items (delivered as 8 review submissions — 2 CHANGES_REQUESTED + 6 individual COMMENTED — but 6 distinct concerns) and 3 doudouOUC R4 nits. All 9 real issues addressed; no false-positives this round. ## Real Criticals ### awaitingResync recovery API (wenshao R4) `store.reset()` requires session-id change semantics — wrong shape for "same-session reconnect with SSE replay" recovery. Added explicit `store.clearAwaitingResync()` API. Latch is still set on receipt of `session.state_resync_required` (intentional one-way during replay window); consumers now have a clean path to clear after the replay stream drains. ### normalizeAuthDeviceFlowCancelled test coverage (wenshao R4) Coverage gap surfaced — happy path (valid deviceFlowId) and malformed fallback to debug both untested. Added 2 tests. ## Real Suggestions ### sanitizeUrl: AWS / Azure / GCP credential patterns The previous regex caught `x-amz-` and `x-goog-` headers + generic `signature` / `sig`, but missed: - `AWSAccessKeyId` (S3 presigned) - Azure SAS short codes (`sv` / `se` / `sr` / `sp` / `st` / `spr` / `sip` / `ss` / `srt` / `sig` / `skoid` / etc.) - GCP signed-URL `GoogleAccessId` + `Expires` (paired with credentials in signed URL contexts) Widened regex to include `aws|google|expires` prefixes + added explicit Azure-SAS Set check. ### detectFileDiff: `content` alias disambiguated `{ path, content }` was being classified as `file_diff` regardless of tool semantics — but the same shape is common for file_read assertions or search queries. Since detectFileDiff runs BEFORE detectFileRead in the detector chain, this caused mis-classification. Fix: restrict bare `content` to require either (a) write-intent tool name (write/create/edit/replace/save/update) OR (b) co-occurrence with `oldText`. Explicit `newText` / `new_text` / etc. still pass through unconditionally. Required adding `opts` to the `detectFileDiff` signature (callers already pass opts to siblings). ### detectFileRead: 0-based offset → 1-based range Type doc says `range: [startLine, endLine]` is 1-based inclusive. The offset+limit conversion produced 0-based output ([0, 9] for offset=0/limit=10), which displayed as "lines 0-9" — line 0 doesn't exist in 1-based. Convert at the detector: `[offset+1, offset+limit]`. Updated the matching test (which had encoded the 0-based bug as expected behavior). ### formatMissedRange — guard inverted / single-event ranges The naive `lastDeliveredId+1 .. earliestAvailableId-1` formula produced: - `gap === 0`: "missed 6-5" (inverted) - `gap === 1`: "missed 6-6" (single event shown as range) Added `formatMissedRange()` helper with explicit branches: - `last < first` → "no events lost (resync requested without gap)" - `last === first` → "missed 1 daemon event (id N)" - `last > first` → "missed daemon events X-Y" Applied in both `transcript.ts` (status block message) and `terminal.ts` (ANSI projection) — same formula was duplicated. ## doudouOUC R4 nits ### README errorKind list outdated Replaced `expired / transport / server / internal` with pointer to `KNOWN_DEVICE_FLOW_ERROR_KINDS` exported constant — canonical list auto-stays-in-sync. ### README "10 scenarios" stale Was 10, became 11 with subagent-nesting. Removed the count and let the corpus be derived at runtime via `DAEMON_UI_CONFORMANCE_FIXTURES.length`. ### selectTranscriptBlocks danger post lazy-COW With state.blocks now shared across sidechannel snapshots, a misbehaving consumer doing `(state.blocks as DaemonTranscriptBlock[]).sort()` would poison every snapshot sharing the reference. Freeze the blocks array at the dispatch boundary in `reduceDaemonTranscriptEvents`. Internal reducer mutation goes through `takeBlocksOwnership` which copies before mutating, so the frozen reference is never modified in place. ## Validation | | | |---|---| | SDK tests | **162/162** | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 --- docs/developers/daemon-ui/README.md | 10 +++--- .../sdk-typescript/src/daemon/ui/render.ts | 19 ++++++++-- .../sdk-typescript/src/daemon/ui/store.ts | 20 +++++++++++ .../sdk-typescript/src/daemon/ui/terminal.ts | 20 +++++++---- .../src/daemon/ui/toolPreview.ts | 35 ++++++++++++++++--- .../src/daemon/ui/transcript.ts | 33 +++++++++++++++-- .../sdk-typescript/src/daemon/ui/types.ts | 9 +++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 8 +++-- 8 files changed, 133 insertions(+), 21 deletions(-) diff --git a/docs/developers/daemon-ui/README.md b/docs/developers/daemon-ui/README.md index 67dd19991d2..808f96a26b9 100644 --- a/docs/developers/daemon-ui/README.md +++ b/docs/developers/daemon-ui/README.md @@ -106,7 +106,7 @@ store.subscribe(() => { `auth.device_flow.{started,throttled,authorized,failed,cancelled}` Each carries the daemon's `deviceFlowId`. Failed events carry a closed-enum -`errorKind` (`expired` / `access_denied` / `transport` / etc.). +`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) @@ -313,9 +313,11 @@ it('my adapter conforms to daemon UI corpus', () => { }); ``` -The fixture corpus (`DAEMON_UI_CONFORMANCE_FIXTURES`) covers 10 scenarios: -chat, tool lifecycle, file edits, MCP, permissions, MCP budget warning, -cancellation, malformed payload redaction, OAuth, command updates. +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 diff --git a/packages/sdk-typescript/src/daemon/ui/render.ts b/packages/sdk-typescript/src/daemon/ui/render.ts index ae578ad2a12..2543e95225d 100644 --- a/packages/sdk-typescript/src/daemon/ui/render.ts +++ b/packages/sdk-typescript/src/daemon/ui/render.ts @@ -664,11 +664,26 @@ function sanitizeUrl(url: string): string { // must cover both query-param tokens AND the userinfo component. u.username = ''; u.password = ''; + // wenshao R4 (qwen3.7-max): widen regex to catch additional cloud + // provider credential / signed-URL params: + // - AWS S3 presigned: `AWSAccessKeyId` (case-insensitive starts + // with `aws`), `X-Amz-*` (already covered) + // - GCP signed: `GoogleAccessId`, `Signature` (already), `Expires` + // - Azure SAS: short codes `sv`/`se`/`sr`/`sp`/`st`/`spr`/`sip`/`ss`/`srt`/`sig` + // `Expires` is included because in signed-URL contexts it pairs with + // the credential; non-signed URLs typically don't include it as a + // top-level query param so the false-positive risk is bounded. + const AZURE_SAS_KEYS = new Set([ + 'sv', 'se', 'sr', 'sp', 'st', 'spr', 'sip', 'ss', 'srt', 'sig', 'skoid', + 'sktid', 'skt', 'ske', 'sks', 'skv', + ]); for (const key of Array.from(u.searchParams.keys())) { + const k = key.toLowerCase(); if ( - /^(token|key|auth|signature|sig|access|secret|bearer|credential|session|api[_-]?key|x-amz-|x-goog-)/i.test( + /^(token|key|auth|signature|sig|access|secret|bearer|credential|session|api[_-]?key|x-amz-|x-goog-|aws|google|expires)/i.test( key, - ) + ) || + AZURE_SAS_KEYS.has(k) ) { u.searchParams.delete(key); } diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 269f178042d..ec4a44793a7 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -68,6 +68,26 @@ export function createDaemonTranscriptStore( }); scheduleNotify(); }, + // wenshao R4 (qwen3.7-max): explicit recovery from the + // `awaitingResync` one-way latch. After the client receives a + // `session.state_resync_required` event, it should: + // 1. Drop local state if a full replay isn't feasible, OR + // 2. Re-subscribe with `Last-Event-ID: 0` to receive a full + // replay, then call `clearAwaitingResync()` once the replay + // stream has drained. + // Without this API the latch could only be cleared by `reset()`, + // which forces session-id reset semantics — wrong shape for the + // same-session-with-replay recovery flow. + clearAwaitingResync() { + if (!state.awaitingResync) return; + state = { + ...state, + awaitingResync: false, + // Keep lastResyncRequired for diagnostic visibility — consumers + // who want a clean slate can also call reset(). + }; + scheduleNotify(); + }, }; } diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index 4397537f824..f9c5cf84dcb 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -60,12 +60,20 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { ); case 'session.available_commands': return terminalLine('commands', `available ${event.count}`, '2'); - case 'session.state_resync_required': - return terminalLine( - 'resync-required', - `${event.reason}: missed ${event.lastDeliveredId + 1}-${event.earliestAvailableId - 1}`, - '31', - ); + case 'session.state_resync_required': { + // Same defensive range formula as the transcript reducer — see + // `formatMissedRange` in transcript.ts. Inline here to keep the + // terminal module self-contained. + const first = event.lastDeliveredId + 1; + const last = event.earliestAvailableId - 1; + const gap = + last < first + ? 'no events lost' + : last === first + ? `missed 1 event (id ${first})` + : `missed ${first}-${last}`; + return terminalLine('resync-required', `${event.reason}: ${gap}`, '31'); + } case 'workspace.memory.changed': return terminalLine( 'memory', diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index f705124a2f6..01314bda765 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -69,7 +69,7 @@ export function createDaemonToolPreview( const imageGeneration = detectImageGeneration(input, opts); if (imageGeneration) return imageGeneration; - const fileDiff = detectFileDiff(input); + const fileDiff = detectFileDiff(input, opts); if (fileDiff) return fileDiff; const fileRead = detectFileRead(input, opts); @@ -112,7 +112,10 @@ export function createDaemonToolPreview( * - Aider-style: `patch` text * - All variants require a `path` / `filePath` field. */ -function detectFileDiff(input: unknown): DaemonToolPreview | undefined { +function detectFileDiff( + input: unknown, + opts: { title?: string; toolName?: string; toolKind?: string } = {}, +): DaemonToolPreview | undefined { if (!isRecord(input)) return undefined; const path = getFirstString(input, [ 'path', @@ -127,13 +130,30 @@ function detectFileDiff(input: unknown): DaemonToolPreview | undefined { 'old_str', 'oldString', ]); - const newText = getFirstString(input, [ + // wenshao R4 (qwen3.7-max): `content` is too ambiguous as a newText + // alias — `{ path, content }` is a common shape for both file writes + // AND read assertions / search queries / file_read results echoed in + // rawInput. Since `detectFileDiff` runs BEFORE `detectFileRead` in the + // detector chain, accepting `content` would mis-classify reads as + // writes. Restrict bare `content` to tools whose name signals a write + // (`write` / `create` / `edit` / `replace` / `save`); otherwise + // require an explicit `newText` / `new_str` / `newString` alias OR + // co-occurrence with `oldText` (edit shape). + const explicitNewText = getFirstString(input, [ 'newText', 'new_text', 'new_str', 'newString', - 'content', ]); + const toolNameLower = (opts.toolName ?? '').toLowerCase(); + const writeIntent = + /write|create|edit|replace|save|update/.test(toolNameLower) || + !!oldText; + const contentField = + explicitNewText === undefined && writeIntent + ? getFirstString(input, ['content']) + : undefined; + const newText = explicitNewText ?? contentField; const patch = getFirstString(input, ['patch', 'diff', 'unified_diff']); // Require at least one of: oldText+newText pair (edit), patch (apply), // newText (write). Pure path with no diff content → not a diff preview. @@ -182,7 +202,12 @@ function detectFileRead( const offset = input['offset']; const limit = input['limit']; if (typeof offset === 'number' && typeof limit === 'number' && limit > 0) { - range = [offset, offset + limit - 1] as const; + // wenshao R4 (qwen3.7-max): convert 0-based offset+limit pair to + // 1-based inclusive range, matching the documented `range` type + // (`Optional [startLine, endLine] 1-based inclusive`). + // For offset=0, limit=10 the old formula produced [0, 9] which + // displayed as "lines 0-9" — line 0 doesn't exist in 1-based. + range = [offset + 1, offset + limit] as const; } } if (!looksLikeRead && !range) return undefined; diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 632f7f5f8a1..9eed57b9a62 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -108,7 +108,18 @@ export function reduceDaemonTranscriptEvents( if (events.length === 0) return state; const next = cloneTranscriptState(state, opts); for (const event of events) applyDaemonTranscriptEvent(next, event); - return trimTranscriptState(next); + const result = trimTranscriptState(next); + // doudouOUC R4: with lazy COW, `state.blocks` is shared across + // sidechannel-only snapshots. A misbehaving consumer doing + // `(state.blocks as DaemonTranscriptBlock[]).sort()` would corrupt + // EVERY snapshot that shares the reference (previously only the + // current one). Freeze the array at the dispatch boundary so external + // in-place mutation throws in strict mode instead of silently + // poisoning future snapshots. Internal reducer mutation goes through + // `takeBlocksOwnership` which copies BEFORE mutating, so the frozen + // shared reference is never touched in-place by the next dispatch. + Object.freeze(result.blocks); + return result; } export function rebuildDaemonTranscriptBlockIndex( @@ -276,11 +287,29 @@ function handleStateResyncRequired( appendStatusBlock( state, 'error', - `State resync required: missed daemon events ${event.lastDeliveredId + 1}-${event.earliestAvailableId - 1}. Reload the session to recover.`, + `State resync required: ${formatMissedRange(event.lastDeliveredId, event.earliestAvailableId)}. Reload the session to recover.`, event, ); } +/** + * Format `missed daemon events X-Y` defensively. The naive formula + * `lastDeliveredId+1 .. earliestAvailableId-1` produces inverted output + * for `gap == 0` (next-id-is-next, no actual gap) and confusing + * single-event range for `gap == 1`. Round all edge cases to natural + * phrasing so the diagnostic stays readable. wenshao R4 (qwen3.7-max). + */ +function formatMissedRange( + lastDeliveredId: number, + earliestAvailableId: number, +): string { + const first = lastDeliveredId + 1; + const last = earliestAvailableId - 1; + if (last < first) return 'no events lost (resync requested without gap)'; + if (last === first) return `missed 1 daemon event (id ${first})`; + return `missed daemon events ${first}-${last}`; +} + export function selectTranscriptBlocks( state: DaemonTranscriptState, ): readonly DaemonTranscriptBlock[] { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index db35fbe3d8e..15a4466b623 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -685,6 +685,15 @@ export interface DaemonTranscriptStore { dispatch(event: DaemonUiEvent | DaemonUiEvent[]): void; appendLocalUserMessage(text: string): void; reset(seed?: Partial): void; + /** + * Clear the `awaitingResync` latch that gets set when the daemon emits + * `session.state_resync_required`. Call this after re-subscribing to + * SSE with `Last-Event-ID: 0` and the replay stream has fully drained + * (or after dropping state via your own flow). Without this API, the + * latch could only be cleared by `reset()`, which forces session-id + * change semantics that don't fit same-session reconnect. + */ + clearAwaitingResync(): void; } export interface DaemonUiSessionActions { diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index a0e132978db..251af8ca89c 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -2500,7 +2500,11 @@ describe('daemon UI tool preview taxonomy (PR-C)', () => { }); }); - it('detects file_read from offset/limit pair', () => { + it('detects file_read from offset/limit pair with 1-based range conversion', () => { + // wenshao R4 (qwen3.7-max): `range` is 1-based inclusive per the + // `DaemonToolPreview.file_read` type doc. The detector converts + // daemon-emitted 0-based offset+limit to that contract. For + // offset=100, limit=50 the displayed range is "lines 101-150". const preview = createDaemonToolPreview( { path: '/work/y.md', offset: 100, limit: 50 }, { toolName: 'View' }, @@ -2508,7 +2512,7 @@ describe('daemon UI tool preview taxonomy (PR-C)', () => { expect(preview).toMatchObject({ kind: 'file_read', path: '/work/y.md', - range: [100, 149], + range: [101, 150], }); }); From e394f4935ed942af312e6d351d51f12e181b9b58 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 24 May 2026 01:52:38 +0800 Subject: [PATCH 22/24] =?UTF-8?q?fix(daemon-ui):=20wenshao=20R5=20review?= =?UTF-8?q?=20batch=20=E2=80=94=20Critical=20OAuth=20fragment=20leak=20+?= =?UTF-8?q?=2010=20more?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walks 13 inline items from wenshao's 16:46-17:28 reviews. 11 fixed, 1 deduped (lint-no-console flagged in both reviews), 1 reverted/push-back (multi-part deny re-flags the same design-intent territory as R2 #4). ## Critical fixes ### sanitizeUrl: OAuth #fragment leak `sanitizeUrl` cleared query params and Basic Auth userinfo, but `u.toString()` preserved `u.hash`. OAuth 2.0 implicit grant puts `access_token=...` directly in the fragment (e.g., `https://app/#access_token=gho_xxx&token_type=bearer`); some Azure SAS variants similarly. Now `u.hash = ''` before serialize. For rendered output (markdown / HTML / plaintext), the fragment is client- state-only and dropping it removes the entire fragment-side leak surface. ### ESLint no-console on awaitingResync diagnostic Project lint forbids bare `console.*`. Added `eslint-disable-next-line no-console -- intentional diagnostic` per wenshao's suggestion. Behavior unchanged. ### normalizeAuthDeviceFlowCancelled test coverage (still missing post-R4) R4 added tests for one of the five device-flow normalizers; the `cancelled` variant was still uncovered. Added happy + malformed-payload tests. ## Behavior fixes ### Plaintext sanitizeTerminalText parity `daemonBlockToPlainText` + `daemonToolPreviewToPlainText` previously returned ANSI/bidi-control text verbatim, while markdown and HTML paths sanitized via `sanitizeTerminalText`. A daemon emitting bidi overrides survived clean to plaintext output — contradicting the "copy-paste / logs" JSDoc intent. Now routes every text field through `clean()` = `cap(sanitizeTerminalText(raw))`. ### blockquote helper applied to image_generation + subagent_delegation R3 added the helper for thought/debug/error but missed two preview markdown sites (`> ${text(preview.prompt)}` for image_generation, `> ${text(preview.task)}` for subagent_delegation). Multi-line prompts / tasks now stay inside the blockquote. ### Default unrecognized-event branch: single debug block Was emitting `status + debug` (2 blocks) per unknown event type. In long sessions where the daemon adds new types an older SDK doesn't recognize, this doubled block-consumption rate and accelerated `maxBlocks` trimming of real content. Now emit a single `debug` block that prefixes the event-type for adapters that want to pattern-match. ### writeIntent regex underscore-boundary aware R4's `content` alias gate-check used `\b` word boundaries, but `\b` doesn't match between `write` and `_` in `write_file` (both `\w`). Fixed to `(?:^|[_-])verb(?:$|[_-])` which catches the canonical `write_file` naming AND still rejects `prewrite_check`. Verb list extended per wenshao's suggestion (`overwrite`/`modify`/`patch`/`generate`). ### useDaemonPendingPermissions over-subscription Hook used `useDaemonTranscriptState()` which fires on every daemon event (text deltas, tool updates, sidechannel). Switched to `useDaemonTranscriptBlocks()` which only invalidates when the blocks array reference changes — block-mutating dispatches only, thanks to lazy COW. Same selector semantics, ~10x fewer renders in chat-heavy sessions. ### Conformance suite: try/catch adapter JSDoc promised "does not throw" but the loop wrapped adapter calls without try/catch. Buggy adapters aborted the whole suite instead of producing a structured `ConformanceFailure`. Now wrap; on throw, capture the error message in `renderedExcerpt: "[adapter threw: ...]"` and continue. ## Type / Quality fixes ### DaemonTranscriptState.blocks typed readonly Runtime contract is frozen (lazy-COW poison defense), but the type was mutable — consumers got runtime `TypeError` for in-place mutation instead of compile errors. Now `readonly DaemonTranscriptBlock[]` so mutation is caught at the type level. ### formatMissedRange exported / deduplicated Helper was duplicated inline between transcript.ts (full phrasing) and terminal.ts (terser phrasing). Exported from transcript.ts and reused in terminal.ts to prevent future drift. ## Push-back (false-positive — see reply) ### classifySelectedPermissionOption multi-part deny (`selected:deny:access_violation`) Re-flags the same `selected:X` design intent rejected in R2 #4. The caller comment explicitly states a selected option resolves the prompt even when the option id contains `deny`/`cancel`. The existing test `cancelled-substring-permission` (payload `selected:abort`, expected `completed`) codifies this. Daemon expresses true user-cancellation via the `cancelled` PRIMARY token, not `selected:cancel`. Not changing; reply directs to the same R2 #4 reasoning. ## Tests added (+10) - normalizeAuthDeviceFlowCancelled happy + malformed - sanitizeUrl OAuth fragment access_token rejected - sanitizeUrl AWS/GCP/Azure SAS credential params stripped - formatMissedRange no-gap / single-event / multi-event - detectFileDiff content alias rejected for read-like tools - detectFileDiff content alias accepted for write-like tools - writeIntent word boundaries (prewrite_check NOT matched) - conformance captures adapter throw - unrecognized event → single debug block - store.clearAwaitingResync clears latch ## Validation | | | |---|---| | SDK tests | **172/172** (was 162, +10) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | Generated with AI Co-authored-by: Claude Opus 4.7 --- .../src/daemon/ui/conformance.ts | 27 ++- .../src/daemon/ui/normalizer.ts | 15 +- .../sdk-typescript/src/daemon/ui/render.ts | 45 +++-- .../sdk-typescript/src/daemon/ui/terminal.ts | 23 +-- .../src/daemon/ui/toolPreview.ts | 10 +- .../src/daemon/ui/transcript.ts | 3 +- .../sdk-typescript/src/daemon/ui/types.ts | 10 +- .../sdk-typescript/test/unit/daemonUi.test.ts | 186 +++++++++++++++++- .../src/daemon/DaemonSessionProvider.tsx | 17 +- 9 files changed, 289 insertions(+), 47 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/conformance.ts b/packages/sdk-typescript/src/daemon/ui/conformance.ts index fc408bdd8f4..feaca569127 100644 --- a/packages/sdk-typescript/src/daemon/ui/conformance.ts +++ b/packages/sdk-typescript/src/daemon/ui/conformance.ts @@ -152,11 +152,28 @@ export function runAdapterConformanceSuite( const failed: ConformanceFailure[] = []; let passed = 0; for (const fx of fixtures) { - const events = fx.envelopes.flatMap((env) => - normalizeDaemonEvent(env as never, fx.normalizeOptions ?? {}), - ); - const state = adapter.reduce(events); - const rendered = adapter.renderToText(state); + // 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), ); diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 09818e1b7a8..86dd77c98f7 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -204,16 +204,19 @@ export function normalizeDaemonEvent( 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)}`, }, ]; } diff --git a/packages/sdk-typescript/src/daemon/ui/render.ts b/packages/sdk-typescript/src/daemon/ui/render.ts index 2543e95225d..1abd274ed25 100644 --- a/packages/sdk-typescript/src/daemon/ui/render.ts +++ b/packages/sdk-typescript/src/daemon/ui/render.ts @@ -259,7 +259,7 @@ export function daemonToolPreviewToMarkdown( case 'image_generation': return [ `**Image generation**`, - `> ${text(preview.prompt)}`, + blockquote(text(preview.prompt)), preview.model ? `_model: ${escapeMarkdownText(preview.model, opts)}_` : null, @@ -284,7 +284,7 @@ export function daemonToolPreviewToMarkdown( return [ `**Delegate -> ${inlineCode(preview.agentName, opts)}**`, '', - `> ${text(preview.task)}`, + blockquote(text(preview.task)), preview.parentDelegationId ? `_(chained from ${escapeMarkdownText( preview.parentDelegationId, @@ -366,21 +366,27 @@ 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: ${cap(block.text)}`; + return `You: ${clean(block.text)}`; case 'assistant': - return cap(block.text); + return clean(block.text); case 'thought': - return `(thought: ${cap(block.text)})`; + 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 = [ - cap(block.title), - block.toolName ? `[${cap(block.toolName)}]` : null, - block.toolKind ? `(${cap(block.toolKind)})` : null, + clean(block.title), + block.toolName ? `[${clean(block.toolName)}]` : null, + block.toolKind ? `(${clean(block.toolKind)})` : null, ] .filter(Boolean) .join(' '); @@ -394,26 +400,26 @@ export function daemonBlockToPlainText( return [header, preview, status].filter(Boolean).join('\n'); } case 'shell': - return `[shell ${block.stream ?? 'stdout'}]\n${cap(block.text)}`; + 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) => - ` - ${cap(opt.label)}${opt.description ? `: ${cap(opt.description)}` : ''}`, + ` - ${clean(opt.label)}${opt.description ? `: ${clean(opt.description)}` : ''}`, ) .join('\n'); const resolved = block.resolved - ? `(resolved: ${cap(block.resolved)})` + ? `(resolved: ${clean(block.resolved)})` : '(awaiting decision)'; - return `Permission: ${cap(block.title)}\n${optionList}\n${resolved}`; + return `Permission: ${clean(block.title)}\n${optionList}\n${resolved}`; } case 'status': - return `[status] ${cap(block.text)}`; + return `[status] ${clean(block.text)}`; case 'debug': - return `[debug] ${cap(block.text)}`; + return `[debug] ${clean(block.text)}`; case 'error': - return `[error] ${cap(block.text)}`; + return `[error] ${clean(block.text)}`; default: return ''; } @@ -688,6 +694,15 @@ function sanitizeUrl(url: string): string { u.searchParams.delete(key); } } + // wenshao R5 (qwen3.7-max) Critical: clear the URL fragment. OAuth + // 2.0 implicit-grant flow places `access_token` directly in + // `#fragment` (e.g., `https://app/#access_token=gho_xxx&token_type=bearer`), + // and some Azure SAS variants similarly use the fragment. The + // previous serialization preserved `u.hash` and leaked credentials + // even when the query path was scrubbed. The fragment is for + // client-side state only; for rendered output, dropping it is safe + // and removes the leak surface entirely. + u.hash = ''; return u.toString(); } catch { return '#'; diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index f9c5cf84dcb..eb4212d86fb 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -5,6 +5,7 @@ */ import type { DaemonTranscriptBlock, DaemonUiEvent } from './types.js'; +import { formatMissedRange } from './transcript.js'; import { sanitizeTerminalText } from './utils.js'; export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { @@ -60,20 +61,14 @@ export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { ); case 'session.available_commands': return terminalLine('commands', `available ${event.count}`, '2'); - case 'session.state_resync_required': { - // Same defensive range formula as the transcript reducer — see - // `formatMissedRange` in transcript.ts. Inline here to keep the - // terminal module self-contained. - const first = event.lastDeliveredId + 1; - const last = event.earliestAvailableId - 1; - const gap = - last < first - ? 'no events lost' - : last === first - ? `missed 1 event (id ${first})` - : `missed ${first}-${last}`; - return terminalLine('resync-required', `${event.reason}: ${gap}`, '31'); - } + case 'session.state_resync_required': + // wenshao R5 (deepseek-v4-pro): reuse the exported formatter from + // transcript.ts so the two sites can't silently diverge. + return terminalLine( + 'resync-required', + `${event.reason}: ${formatMissedRange(event.lastDeliveredId, event.earliestAvailableId)}`, + '31', + ); case 'workspace.memory.changed': return terminalLine( 'memory', diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 01314bda765..fb02dcdbffe 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -146,8 +146,16 @@ function detectFileDiff( 'newString', ]); const toolNameLower = (opts.toolName ?? '').toLowerCase(); + // wenshao R5 (deepseek-v4-pro): use `_`/`-`/start/end boundaries + // instead of `\b`. `\b` doesn't match between `write` and `_` in + // `write_file` (both are `\w` in regex), so it failed to recognize + // the canonical write-tool naming convention. The custom anchor + // catches `write_file`/`write-file`/`write` but rejects + // `prewrite_check`/`downloader`. const writeIntent = - /write|create|edit|replace|save|update/.test(toolNameLower) || + /(?:^|[_-])(write|create|edit|replace|save|update|overwrite|modify|patch|generate)(?:$|[_-])/.test( + toolNameLower, + ) || !!oldText; const contentField = explicitNewText === undefined && writeIntent diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 9eed57b9a62..09d7e4a716c 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -151,6 +151,7 @@ function applyDaemonTranscriptEvent( // an uncaught issue. Throttled at the call site is the consumer's // job — this fires once per dropped event. if (typeof console !== 'undefined' && console.warn) { + // eslint-disable-next-line no-console -- intentional diagnostic for awaitingResync silent-drop, per wenshao R5 console.warn( `[daemon-ui] dropping event \`${event.type}\` while awaitingResync; ` + `state may be stale until session reconnect (lastResyncRequired: ${ @@ -299,7 +300,7 @@ function handleStateResyncRequired( * single-event range for `gap == 1`. Round all edge cases to natural * phrasing so the diagnostic stays readable. wenshao R4 (qwen3.7-max). */ -function formatMissedRange( +export function formatMissedRange( lastDeliveredId: number, earliestAvailableId: number, ): string { diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 15a4466b623..57b055359cf 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -660,7 +660,15 @@ export interface DaemonTranscriptSidechannelState { export interface DaemonTranscriptState extends DaemonTranscriptSidechannelState { - blocks: DaemonTranscriptBlock[]; + // wenshao R5 (deepseek-v4-pro): `blocks` is frozen at the dispatch + // boundary in `reduceDaemonTranscriptEvents` (defense against + // consumer in-place mutation poisoning the shared snapshot under + // lazy COW). Match the runtime contract at the type level so + // consumers get a compile-time error for `state.blocks.sort()` / + // `.push()` instead of a runtime `TypeError`. Internal reducer + // mutation goes through `takeBlocksOwnership` which casts away + // readonly after copying — the only place that's allowed. + blocks: readonly DaemonTranscriptBlock[]; lastEventId?: number; activeUserBlockId?: string; activeAssistantBlockId?: string; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 251af8ca89c..354ed837312 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1246,13 +1246,18 @@ describe('daemon UI normalizer and transcript reducer', () => { }, }); + // wenshao R5 (qwen3.7-max): unrecognized daemon events now emit a + // single `debug` block (was status + debug). The text prefix + // ` (unrecognized daemon event)` carries the same + // information without doubling block consumption. expect(events).toMatchObject([ - { type: 'status' }, { type: 'debug', text: expect.stringContaining('[redacted]') as string, }, ]); + expect(events).toHaveLength(1); + expect(events[0]?.type).toBe('debug'); // `DaemonUiStatusEvent` has `type: 'status' | 'debug'` — both share a // `text` field. Cast through the union variant (not Extract on a // sub-literal, which yields `never`). @@ -4599,3 +4604,182 @@ describe('ensureSafeImageUrl tightened to data:image/* (audit follow-up)', () => ); }); }); + +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); + }); +}); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 35156bd6643..e020fbc1992 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, @@ -462,8 +461,20 @@ export function useDaemonTranscriptBlocks(): readonly DaemonTranscriptBlock[] { } 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 { From 971a69d14c5386ea977b7d810da03d6fbafe0015 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 24 May 2026 02:38:23 +0800 Subject: [PATCH 23/24] =?UTF-8?q?fix(daemon-ui):=20wenshao=20R6=20?= =?UTF-8?q?=E2=80=94=20recovery=20flow=20chicken-and-egg=20+=20pending=20p?= =?UTF-8?q?ointer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Criticals from R6 review (4351217188) all pointing at real bugs introduced by R4/R5 work — not false positives. Fixes plus regression tests. ## Critical 1 — same-session reconnect never clears the latch When the daemon emitted `state_resync_required`, the reducer set `awaitingResync = true`. The webui provider dispatched `assistant.done { reason: 'reconnected' }` after re-attaching SSE but never called `store.clearAwaitingResync()`. Result: events flowed in on the fresh stream but every one got dropped by the `applyDaemonTranscriptEvent` passthrough guard. Transcript appeared permanently frozen with no diagnostic clue (the `console.warn` fired on each drop, but the user wouldn't necessarily check DevTools). Fix: in `DaemonSessionProvider.tsx`, after dispatching the synthetic `reconnected` `assistant.done`, check `awaitingResync` and clear it BEFORE the new SSE event loop starts. ## Critical 2 — updateCurrentToolPointer breaks on undefined status In `upsertToolBlock`, a new tool block is created with `status: event.status ?? 'pending'`. But `updateCurrentToolPointer` was called with raw `event.status` — when undefined, the function's own `if (status === undefined) return;` guard short-circuited without ever pointing at the new (visually-pending) block. Result: `selectCurrentTool` returned `undefined` for daemon events that omitted the explicit `status` field, while the block sat at "pending" in the UI — invisible to the current-tool selector. Fix: pass the EFFECTIVE status (`event.status ?? 'pending'`) so the pointer logic mirrors the actual stored status. ## Critical 3 — clearAwaitingResync flow chicken-and-egg The earlier (R4) JSDoc documented the recovery flow as: "re-subscribe with `Last-Event-ID: 0`, then call clearAwaitingResync after replay drains." But while the latch is true, EVERY non-passthrough event is dropped at `applyDaemonTranscriptEvent`. So during the replay drain, zero events made it into state, and clearing the latch afterward did nothing — transcript permanently empty. Correct flow: clear FIRST, then stream events. Updated JSDoc on both `types.ts` interface and `store.ts` impl to document this clearly. Added a regression test (`clearAwaitingResync AFTER dispatching events: events ARE dropped`) that pins the correct flow in code. ## Regression tests (+3) - `undefined status` creates pending block AND sets currentToolCallId - clear-then-dispatch ✓ events flow - dispatch-then-clear ✗ events dropped (correct flow documentation) ## Validation | | | |---|---| | SDK tests | **175/175** (was 172, +3) | | WebUI tests | **9/9** | | SDK typecheck | clean | | WebUI typecheck | clean | ## Note on doudouOUC heads-up #4469 (main → daemon_mode_b_main sync, 45 commits since 2026-05-19) will land soon. doudouOUC's note says rebase should be smooth (no daemon-ui surface conflicts). Will rebase on the cron's next pass after #4469 merges. Generated with AI Co-authored-by: Claude Opus 4.7 --- .../sdk-typescript/src/daemon/ui/store.ts | 30 +++-- .../src/daemon/ui/transcript.ts | 12 +- .../sdk-typescript/src/daemon/ui/types.ts | 15 ++- .../sdk-typescript/test/unit/daemonUi.test.ts | 108 ++++++++++++++++++ .../src/daemon/DaemonSessionProvider.tsx | 11 ++ 5 files changed, 160 insertions(+), 16 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index ec4a44793a7..a5e4e260b8c 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -68,16 +68,26 @@ export function createDaemonTranscriptStore( }); scheduleNotify(); }, - // wenshao R4 (qwen3.7-max): explicit recovery from the - // `awaitingResync` one-way latch. After the client receives a - // `session.state_resync_required` event, it should: - // 1. Drop local state if a full replay isn't feasible, OR - // 2. Re-subscribe with `Last-Event-ID: 0` to receive a full - // replay, then call `clearAwaitingResync()` once the replay - // stream has drained. - // Without this API the latch could only be cleared by `reset()`, - // which forces session-id reset semantics — wrong shape for the - // same-session-with-replay recovery flow. + // wenshao R4-R6 (qwen3.7-max): explicit recovery from the + // `awaitingResync` one-way latch. + // + // RECOVERY FLOW (correct order — wenshao R6 caught a flow bug): + // 1. Daemon emits `session.state_resync_required`; reducer sets + // `state.awaitingResync = true` and starts dropping events. + // 2. Consumer decides on recovery strategy and calls EITHER: + // a. `reset()` — clean slate, discard local blocks + // b. `clearAwaitingResync()` — keep local blocks, accept + // new events. Call BEFORE the new SSE stream starts + // delivering events (or BEFORE a `Last-Event-ID: 0` + // replay starts), otherwise the replay events get + // dropped by the latch guard. + // 3. Re-subscribe to SSE; events flow normally. + // + // (The earlier JSDoc said "after replay drains" — that was wrong. + // While the latch is set, every replay event is dropped, so the + // window between latch-clear and stream-start is what receives + // events. Clear early; if dispatch order misses something the + // daemon will eventually emit a new `state_resync_required`.) clearAwaitingResync() { if (!state.awaitingResync) return; state = { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 09d7e4a716c..b7199a229d2 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -498,7 +498,17 @@ function upsertToolBlock( } } } - updateCurrentToolPointer(state, event.toolCallId, event.status); + // wenshao R6 (qwen3.7-max): pass the EFFECTIVE status — the block + // was just created with `event.status ?? 'pending'`. If we pass + // raw `event.status === undefined`, `updateCurrentToolPointer` early- + // returns and the block sits as visually-pending but currentToolCallId + // never points at it. Effective-status keeps the pointer in sync + // with what was actually written to the block. + updateCurrentToolPointer( + state, + event.toolCallId, + event.status ?? 'pending', + ); clearActiveText(state); } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 57b055359cf..5995493cae1 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -695,11 +695,16 @@ export interface DaemonTranscriptStore { reset(seed?: Partial): void; /** * Clear the `awaitingResync` latch that gets set when the daemon emits - * `session.state_resync_required`. Call this after re-subscribing to - * SSE with `Last-Event-ID: 0` and the replay stream has fully drained - * (or after dropping state via your own flow). Without this API, the - * latch could only be cleared by `reset()`, which forces session-id - * change semantics that don't fit same-session reconnect. + * `session.state_resync_required`. + * + * **Recovery flow (call BEFORE the new SSE stream starts):** + * 1. Receive `session.state_resync_required` event → latch sets + * 2. Call `clearAwaitingResync()` (keep blocks) OR `reset()` (clean slate) + * 3. Re-subscribe to SSE (optionally with `Last-Event-ID: 0` for replay) + * + * (R6 review caught a flow bug — the earlier JSDoc said "after replay + * drains" but while the latch is set every replay event is dropped. + * Clear FIRST, then stream events.) */ clearAwaitingResync(): void; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 354ed837312..a4c5512c0b1 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -4783,3 +4783,111 @@ describe('R5 review batch — coverage additions', () => { 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'); + }); +}); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index e020fbc1992..8728801faf3 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -154,6 +154,17 @@ export function DaemonSessionProvider({ 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; From 473614d022006b32528d9350a0982dc93a8b2453 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Sun, 24 May 2026 08:40:56 +0800 Subject: [PATCH 24/24] =?UTF-8?q?fix(daemon-ui):=20wenshao=20R7=20?= =?UTF-8?q?=E2=80=94=20escapeMarkdownText=20covers=20`<`=20+=20details=20U?= =?UTF-8?q?RL=20sanitization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two items from wenshao R7 (one inline Suggestion + one Verification-PASS finding). Both gate-checked as real; fixed. ## escapeMarkdownText: add `<` to escape set Markdown rendered through markdown-it with `html: true` would previously pass through raw `` / `