From a7229d90b6cc79eecbb1d60e18590a4f9adae96e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Tue, 19 May 2026 22:12:39 +0800 Subject: [PATCH 01/17] feat(daemon): add shared UI transcript layer --- docs/developers/daemon-client-adapters/tui.md | 96 -- .../daemon-client-adapters/web-ui.md | 118 +++ package-lock.json | 1 + .../src/ui/daemon/DaemonTuiAdapter.test.ts | 969 ------------------ .../cli/src/ui/daemon/DaemonTuiAdapter.ts | 905 ---------------- packages/sdk-typescript/package.json | 5 + packages/sdk-typescript/scripts/build.js | 74 +- packages/sdk-typescript/src/daemon/index.ts | 47 + .../sdk-typescript/src/daemon/ui/index.ts | 56 + .../src/daemon/ui/normalizer.ts | 388 +++++++ .../sdk-typescript/src/daemon/ui/store.ts | 85 ++ .../sdk-typescript/src/daemon/ui/terminal.ts | 103 ++ .../src/daemon/ui/toolPreview.ts | 123 +++ .../src/daemon/ui/transcript.ts | 456 +++++++++ .../sdk-typescript/src/daemon/ui/types.ts | 258 +++++ .../sdk-typescript/src/daemon/ui/utils.ts | 113 ++ .../sdk-typescript/test/unit/daemonUi.test.ts | 652 ++++++++++++ packages/webui/package.json | 1 + .../components/toolcalls/GenericToolCall.tsx | 16 +- .../components/toolcalls/ShellToolCall.tsx | 5 +- .../components/toolcalls/ThinkToolCall.tsx | 6 +- .../toolcalls/UpdatedPlanToolCall.tsx | 1 + .../src/components/toolcalls/shared/types.ts | 7 +- .../src/components/toolcalls/shared/utils.ts | 3 + .../src/daemon/DaemonSessionProvider.tsx | 419 ++++++++ packages/webui/src/daemon/index.ts | 21 + .../src/daemon/transcriptAdapter.test.ts | 167 +++ .../webui/src/daemon/transcriptAdapter.ts | 277 +++++ packages/webui/src/index.ts | 19 + packages/webui/src/types/toolCall.ts | 7 +- packages/webui/tsconfig.json | 7 +- packages/webui/vite.config.ts | 19 +- 32 files changed, 3430 insertions(+), 1994 deletions(-) delete mode 100644 docs/developers/daemon-client-adapters/tui.md create mode 100644 docs/developers/daemon-client-adapters/web-ui.md delete mode 100644 packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts delete mode 100644 packages/cli/src/ui/daemon/DaemonTuiAdapter.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/index.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/normalizer.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/store.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/terminal.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/toolPreview.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/transcript.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/types.ts create mode 100644 packages/sdk-typescript/src/daemon/ui/utils.ts create mode 100644 packages/sdk-typescript/test/unit/daemonUi.test.ts create mode 100644 packages/webui/src/daemon/DaemonSessionProvider.tsx create mode 100644 packages/webui/src/daemon/index.ts create mode 100644 packages/webui/src/daemon/transcriptAdapter.test.ts create mode 100644 packages/webui/src/daemon/transcriptAdapter.ts diff --git a/docs/developers/daemon-client-adapters/tui.md b/docs/developers/daemon-client-adapters/tui.md deleted file mode 100644 index c9d223b9262..00000000000 --- a/docs/developers/daemon-client-adapters/tui.md +++ /dev/null @@ -1,96 +0,0 @@ -# TUI Daemon Adapter Draft - -## Goal - -Add a flag-gated TUI transport that talks to `qwen serve` through -`DaemonSessionClient` instead of creating an in-process `Config` + agent -runtime. - -This is a dogfood path for Mode B client migration. It must not replace the -default TUI path until output sinks, typed daemon events, session-scoped -permission, and lifecycle diagnostics are stable. - -## Proposed Entry Point - -```bash -QWEN_DAEMON_URL=http://127.0.0.1:4170 qwen --experimental-daemon-tui -``` - -Optional: - -```bash -QWEN_DAEMON_TOKEN=... QWEN_DAEMON_WORKSPACE=/repo qwen --experimental-daemon-tui -``` - -The CLI should refuse this mode unless both are true: - -- `QWEN_DAEMON_URL` or `--daemon-url` is set. -- `GET /capabilities` advertises `session_create`, `session_prompt`, and - `session_events`. - -## Minimal Flow - -1. Create `DaemonClient` with daemon URL and token. -2. Fetch `/capabilities`. -3. Create or attach with `DaemonSessionClient.createOrAttach()`. -4. Subscribe to `session.events()`. -5. Submit user prompts through `session.prompt()`. -6. Route cancel through `session.cancel()`. -7. Route model switch through `session.setModel()`. -8. Route permission votes through `session.respondToPermission()`. - -## Rendering Contract - -The first implementation adds `DaemonTuiAdapter`, a locally verifiable reducer -and transport spike. It maps only these daemon events: - -| Daemon event | TUI handling | -| ---------------------------------------- | -------------------------------------------- | -| `session_update` / `agent_message_chunk` | Append assistant text | -| `session_update` / `agent_thought_chunk` | Append thinking text | -| `session_update` / `tool_call` | Show tool call lifecycle | -| `permission_request` | Show existing confirmation UI where possible | -| `permission_resolved` | Close or update confirmation UI | -| `model_switched` | Update footer/model display | -| `session_died` | Show disconnected state and stop streaming | - -Unknown events must be ignored, not fatal. Typed event reducers will land in a -later protocol PR. - -The adapter is not wired into the default Ink app yet. Existing interactive TUI, -JSONL, stream-json, and dual-output behavior remains unchanged. - -## Explicit Non-Goals - -- Do not remove the current TUI in-process runtime. -- Do not change JSONL, stream-json, or dual-output behavior in this PR. -- Do not expose file CRUD, MCP management, memory CRUD, or provider/auth - mutation through TUI yet. -- Do not make browser/web direct-to-daemon assumptions; this is terminal only. - -## Merge Safety - -- Default off. -- Additive code path. -- No existing CLI flags change behavior. -- If the daemon is unavailable, the experimental path fails before starting the - TUI and tells the user to run `qwen serve`. - -## Validation Plan - -- Unit-test event-to-TUI-state mapping with synthetic daemon events. -- Unit-test prompt, cancel, model switch, and permission vote forwarding. -- Unit-test flag/env parsing when the feature flag is wired. -- Smoke-test against a local `qwen serve`: - - prompt text streams into the TUI - - cancel resolves the active prompt - - permission request can be accepted or rejected - - reconnect sends the tracked `Last-Event-ID` - -## Blockers Before Default Migration - -- Typed daemon event schema. -- Session-scoped permission route. -- Output sink refactor for JSONL / stream-json / dual-output parity. -- Session lifecycle close/delete semantics. -- Runtime diagnostics for MCP, skills, providers, and workspace env. diff --git a/docs/developers/daemon-client-adapters/web-ui.md b/docs/developers/daemon-client-adapters/web-ui.md new file mode 100644 index 00000000000..2aa022fa371 --- /dev/null +++ b/docs/developers/daemon-client-adapters/web-ui.md @@ -0,0 +1,118 @@ +# Daemon Web UI Adapter + +## Goal + +Web chat and web terminal clients should consume `qwen serve` through the +daemon HTTP/SSE APIs and render a client-side transcript. Native local TUI, +channel, and IDE integrations keep their existing default paths for now. + +## Shared UI Contract + +Use the TypeScript SDK daemon UI exports as the common boundary: + +```ts +import { + DaemonClient, + DaemonSessionClient, + createDaemonTranscriptStore, + normalizeDaemonEvent, +} from '@qwen-code/sdk/daemon'; +``` + +The split is: + +- `DaemonClient` handles daemon HTTP routes. +- `DaemonSessionClient` owns session creation/attachment and SSE replay. +- `normalizeDaemonEvent()` converts daemon wire events into UI events. +- `createDaemonTranscriptStore()` reduces UI events into transcript blocks. + +React clients can use the optional `@qwen-code/webui` binding: + +```tsx +import { + DaemonSessionProvider, + useDaemonActions, + useDaemonConnection, + useDaemonPendingPermissions, + useDaemonTranscriptBlocks, +} from '@qwen-code/webui'; +``` + +Minimal React shape: + +```tsx +function App() { + return ( + + + + + ); +} + +function Transcript() { + const blocks = useDaemonTranscriptBlocks(); + return blocks.map((block) => ); +} +``` + +The provider creates or attaches a daemon session, subscribes to SSE, keeps the +last event id on `DaemonSessionClient`, and reconnects the stream by default. +Callers can disable that with `autoReconnect={false}` for tests or custom +connection management. + +## Browser Deployment Shapes + +### Same-Origin Local POC + +A daemon-served page can call the daemon directly because the page and API share +one origin. This is the preferred early POC shape for local web chat and web +terminal validation. + +### Remote Web Chat / Web Terminal + +A production remote web app should normally talk to a backend-for-frontend. The +BFF owns daemon URL, token, workspace routing, and session metadata, then +forwards browser-safe app events to the browser. This keeps bearer tokens out of +browser storage and lets the deployment decide which daemon/workspace a user is +allowed to reach. + +### Local Browser Against Local Daemon + +A separate local dev server is cross-origin from `qwen serve`; it must either +proxy daemon routes through the same origin or be served by the daemon. The +daemon intentionally rejects arbitrary browser `Origin` requests. + +## Rendering Responsibilities + +The shared transcript model is semantic, not visual. UI clients decide how to +render: + +- user and assistant message blocks +- collapsed thought blocks +- tool status cards +- shell output blocks +- permission request controls +- status/error/debug blocks + +The web terminal is a browser-native semantic renderer. It should look and feel +terminal-like with monospace layout, scrollback, prompt input, shortcuts, and +streaming blocks, but it is not a raw PTY proxy and does not require server-side +Ink rendering. + +## Merge Safety + +- The native `qwen` TUI remains direct and unchanged. +- `--acp`, channel, and IDE paths remain unchanged by default. +- The SDK UI core is additive. +- The WebUI React binding is optional and only runs in clients that import it. +- Removed daemon TUI spike code should not be treated as a product migration. + +## Follow-Ups + +- Add a daemon-served local `/web` POC or equivalent same-origin web app. +- Build first-class chat and terminal renderers on top of transcript blocks. +- Add richer typed events only where existing daemon events are too low-level + for stable browser UI behavior. +- Consider a dedicated `@qwen-code/daemon-ui-core` package if non-SDK consumers + need the UI core as an independent dependency. diff --git a/package-lock.json b/package-lock.json index 4ddeda80f13..b626c4ef4da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22264,6 +22264,7 @@ "version": "0.15.11", "license": "MIT", "dependencies": { + "@qwen-code/sdk": "~0.1.7", "markdown-it": "^14.1.0" }, "devDependencies": { diff --git a/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts b/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts deleted file mode 100644 index 098d606999c..00000000000 --- a/packages/cli/src/ui/daemon/DaemonTuiAdapter.test.ts +++ /dev/null @@ -1,969 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import { describe, expect, it, vi } from 'vitest'; -import type { - ContentBlock, - RequestPermissionRequest, -} from '@agentclientprotocol/sdk'; -import { - createDaemonTuiReducerState, - DaemonTuiAdapter, - reduceDaemonEventToTuiUpdates, - type DaemonTuiEvent, - type DaemonTuiSessionClient, -} from './DaemonTuiAdapter.js'; -import { ToolCallStatus } from '../types.js'; - -class EventQueue implements AsyncGenerator { - private events: DaemonTuiEvent[] = []; - private waiters: Array<{ - resolve: (value: IteratorResult) => void; - reject: (error: unknown) => void; - }> = []; - private closed = false; - private failure: unknown; - - async next(): Promise> { - if (this.failure) { - throw this.failure; - } - const event = this.events.shift(); - if (event) { - return { done: false, value: event }; - } - if (this.closed) { - return { done: true, value: undefined }; - } - return await new Promise((resolve, reject) => { - this.waiters.push({ resolve, reject }); - }); - } - - async return(): Promise> { - this.close(); - return { done: true, value: undefined }; - } - - async throw(error?: unknown): Promise> { - this.close(); - throw error; - } - - [Symbol.asyncIterator](): AsyncGenerator { - return this; - } - - push(event: DaemonTuiEvent): void { - const waiter = this.waiters.shift(); - if (waiter) { - waiter.resolve({ done: false, value: event }); - return; - } - this.events.push(event); - } - - close(): void { - this.closed = true; - for (const waiter of this.waiters.splice(0)) { - waiter.resolve({ done: true, value: undefined }); - } - } - - fail(error: unknown): void { - this.failure = error; - for (const waiter of this.waiters.splice(0)) { - waiter.reject(error); - } - } -} - -interface FakeSession extends DaemonTuiSessionClient { - prompt: ReturnType; - events: ReturnType; - cancel: ReturnType; - setModel: ReturnType; - respondToPermission: ReturnType; -} - -function createFakeSession(events: EventQueue): FakeSession { - return { - sessionId: 'session-1', - workspaceCwd: '/repo', - lastEventId: undefined, - prompt: vi.fn().mockResolvedValue({ stopReason: 'end_turn' }), - events: vi.fn((opts?: { signal?: AbortSignal }) => { - opts?.signal?.addEventListener('abort', () => events.close(), { - once: true, - }); - return events; - }), - cancel: vi.fn().mockResolvedValue(undefined), - setModel: vi.fn().mockResolvedValue({}), - respondToPermission: vi.fn().mockResolvedValue(true), - }; -} - -async function waitFor(assertion: () => void): Promise { - let lastError: unknown; - for (let i = 0; i < 20; i += 1) { - try { - assertion(); - return; - } catch (error) { - lastError = error; - await new Promise((resolve) => setTimeout(resolve, 0)); - } - } - throw lastError; -} - -describe('reduceDaemonEventToTuiUpdates', () => { - it('maps assistant, thought, tool, model, and disconnect daemon events', () => { - expect( - reduceDaemonEventToTuiUpdates({ - id: 0, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'user_message_chunk', - content: { type: 'text', text: 'hello' }, - }, - }, - }), - ).toEqual([]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 1, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: '\u202e\x9b31mhe\rllo\x00' }, - }, - }, - }), - ).toEqual([ - { - type: 'history', - item: { type: 'gemini_content', text: 'hello' }, - daemonEventId: 1, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 2, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'agent_thought_chunk', - content: { type: 'text', text: 'thinking' }, - }, - }, - }), - ).toEqual([ - { - type: 'history', - item: { type: 'gemini_thought_content', text: 'thinking' }, - daemonEventId: 2, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 20, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'plan', - entries: [ - { - status: '\x1b]0;bad\x07pending', - content: '\x1b[31mfinish this\x1b[0m', - }, - ], - }, - }, - }), - ).toEqual([ - { - type: 'history', - item: { type: 'info', text: '1. [pending] finish this' }, - daemonEventId: 20, - }, - ]); - - const toolUpdates = reduceDaemonEventToTuiUpdates({ - id: 3, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'tool-1', - kind: '\x1b]0;bad\x07read_file', - title: '\x1b[31mRead file\x1b[0m', - status: 'completed', - rawOutput: '\x1b[31m3 lines\x1b[0m', - }, - }, - }); - expect(toolUpdates).toHaveLength(1); - expect(toolUpdates[0]).toMatchObject({ - type: 'tool_group_update', - item: { - type: 'tool_group', - tools: [ - { - callId: 'tool-1', - name: 'read_file', - description: 'Read file', - status: ToolCallStatus.Success, - resultDisplay: '3 lines', - }, - ], - }, - daemonEventId: 3, - }); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 4, - v: 1, - type: 'model_switched', - data: { - sessionId: 'session-1', - modelId: '\x1b]0;bad\x07qwen3-coder-plus', - }, - }), - ).toEqual([ - { - type: 'model_switched', - modelId: 'qwen3-coder-plus', - daemonEventId: 4, - }, - { - type: 'history', - item: { - type: 'info', - text: 'Model switched to qwen3-coder-plus', - }, - daemonEventId: 4, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 5, - v: 1, - type: 'session_died', - data: { - sessionId: 'session-1', - reason: '\x1b]0;bad title\x07\x1b[31magent exited\x1b[0m', - }, - }), - ).toEqual([ - { type: 'disconnected', reason: 'agent exited', daemonEventId: 5 }, - { - type: 'history', - item: { - type: 'error', - text: 'Daemon session disconnected: agent exited', - }, - daemonEventId: 5, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 6, - v: 1, - type: 'client_evicted', - data: { reason: 'queue_overflow' }, - }), - ).toEqual([ - { type: 'disconnected', reason: 'queue_overflow', daemonEventId: 6 }, - { - type: 'history', - item: { - type: 'error', - text: 'Daemon session disconnected: queue_overflow', - }, - daemonEventId: 6, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 7, - v: 1, - type: 'stream_error', - data: { error: '\x1bPignored\x1b\\\x1b[31mstream failed\x1b[0m' }, - }), - ).toEqual([ - { type: 'disconnected', reason: 'stream failed', daemonEventId: 7 }, - { - type: 'history', - item: { - type: 'error', - text: 'Daemon session disconnected: stream failed', - }, - daemonEventId: 7, - }, - ]); - }); - - it('accumulates tool updates and preserves structured result displays', () => { - const state = createDaemonTuiReducerState(); - const fileDiff = { - fileDiff: '\x1b[31m--- a\n+++ b\x1b[0m', - fileName: '\u202ea.txt', - originalContent: 'a\r', - newContent: 'b\x9b31m', - }; - const sanitizedFileDiff = { - fileDiff: '--- a\n+++ b', - fileName: 'a.txt', - originalContent: 'a', - newContent: 'b', - }; - - expect( - reduceDaemonEventToTuiUpdates( - { - id: 1, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'tool-1', - kind: 'read_file', - title: 'Read file', - status: 'running', - }, - }, - }, - state, - ), - ).toMatchObject([ - { - type: 'tool_group_update', - item: { - type: 'tool_group', - tools: [{ callId: 'tool-1', status: ToolCallStatus.Executing }], - }, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates( - { - id: 2, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'tool-1', - status: 'completed', - rawOutput: fileDiff, - }, - }, - }, - state, - ), - ).toMatchObject([ - { - type: 'tool_group_update', - item: { - type: 'tool_group', - tools: [ - { - callId: 'tool-1', - name: 'read_file', - description: 'Read file', - status: ToolCallStatus.Success, - resultDisplay: sanitizedFileDiff, - }, - ], - }, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates( - { - id: 3, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'tool-2', - kind: 'shell', - status: 'unexpected', - }, - }, - }, - state, - ), - ).toMatchObject([ - { - type: 'tool_group_update', - item: { - type: 'tool_group', - tools: [ - { callId: 'tool-1' }, - { callId: 'tool-2', status: ToolCallStatus.Error }, - ], - }, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates( - { - id: 4, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'tool-3', - kind: 'shell', - status: 'failed', - content: [{ content: { text: 'command failed' } }], - }, - }, - }, - state, - ), - ).toMatchObject([ - { - type: 'tool_group_update', - item: { - type: 'tool_group', - tools: [ - { callId: 'tool-1' }, - { callId: 'tool-2' }, - { - callId: 'tool-3', - status: ToolCallStatus.Error, - resultDisplay: 'command failed', - }, - ], - }, - }, - ]); - - for (let i = 0; i < 130; i += 1) { - reduceDaemonEventToTuiUpdates( - { - id: 100 + i, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: `evict-${i}`, - kind: 'shell', - status: 'completed', - }, - }, - }, - state, - ); - } - expect(state.toolCallsById.size).toBe(128); - expect(state.toolCallsById.has('tool-1')).toBe(false); - expect(state.toolCallsById.has('evict-129')).toBe(true); - }); - - it('maps permission lifecycle events without auto-voting', () => { - const request: RequestPermissionRequest & { requestId: string } = { - requestId: 'req-1', - sessionId: 'session-1', - toolCall: { - toolCallId: 'tool-1', - title: '\x1b[31mEdit file\x1b[0m', - kind: 'edit', - rawInput: {}, - }, - options: [ - { - optionId: 'proceed_once', - kind: 'allow_once', - name: '\x1b]0;bad\x07Allow', - }, - ], - } as RequestPermissionRequest & { requestId: string }; - const sanitizedRequest = { - ...request, - toolCall: { ...request.toolCall, title: 'Edit file' }, - options: [ - { optionId: 'proceed_once', kind: 'allow_once', name: 'Allow' }, - ], - }; - - expect( - reduceDaemonEventToTuiUpdates({ - id: 6, - v: 1, - type: 'permission_request', - data: request, - }), - ).toEqual([ - { - type: 'permission_request', - requestId: 'req-1', - request: sanitizedRequest, - daemonEventId: 6, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 7, - v: 1, - type: 'permission_resolved', - data: { - requestId: 'req-1', - outcome: { outcome: 'selected', optionId: '\x1b[31mproceed_once' }, - }, - }), - ).toEqual([ - { - type: 'permission_resolved', - requestId: 'req-1', - outcome: { outcome: 'selected', optionId: 'proceed_once' }, - daemonEventId: 7, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 8, - v: 1, - type: 'permission_resolved', - data: { requestId: 'req-1', outcome: { outcome: 'selected' } }, - }), - ).toEqual([ - { - type: 'permission_resolved', - requestId: 'req-1', - outcome: undefined, - daemonEventId: 8, - }, - ]); - - expect( - reduceDaemonEventToTuiUpdates({ - id: 9, - v: 1, - type: 'permission_request', - data: { requestId: 'req-bad' }, - }), - ).toEqual([]); - }); - - it('returns no UI updates for unknown daemon event types', () => { - expect( - reduceDaemonEventToTuiUpdates({ - id: 99, - v: 1, - type: 'new_daemon_event', - data: {}, - }), - ).toEqual([]); - expect( - reduceDaemonEventToTuiUpdates({ - id: 100, - v: 1, - type: 'new_daemon_event', - data: {}, - }), - ).toEqual([]); - }); -}); - -describe('DaemonTuiAdapter', () => { - it('pumps daemon events into TUI updates and tracks replay state', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - Object.defineProperty(session, 'lastEventId', { value: 3 }); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - events.push({ - id: 10, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'agent_message_chunk', - content: { type: 'text', text: 'hello' }, - }, - }, - }); - - await waitFor(() => - expect(onUpdate).toHaveBeenCalledWith({ - type: 'history', - item: { type: 'gemini_content', text: 'hello' }, - daemonEventId: 10, - }), - ); - expect(adapter.lastEventId).toBe(10); - expect(session.events).toHaveBeenCalledWith({ - signal: expect.any(AbortSignal), - lastEventId: 3, - resume: true, - }); - - await adapter.stop(); - }); - - it('emits disconnected when the event stream ends or fails', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - events.close(); - await waitFor(() => - expect(onUpdate).toHaveBeenCalledWith({ - type: 'disconnected', - reason: 'event stream ended', - }), - ); - - const failingEvents = new EventQueue(); - failingEvents.fail(new Error('\x1b[31mboom\x1b[0m')); - const failingSession = createFakeSession(failingEvents); - const onFailingUpdate = vi.fn(); - const failingAdapter = new DaemonTuiAdapter({ - session: failingSession, - onUpdate: onFailingUpdate, - }); - - failingAdapter.start(); - await waitFor(() => - expect(onFailingUpdate).toHaveBeenCalledWith({ - type: 'disconnected', - reason: 'boom', - }), - ); - - const throwingEvents = new EventQueue(); - const throwingSession = createFakeSession(throwingEvents); - const throwingAdapter = new DaemonTuiAdapter({ - session: throwingSession, - onUpdate: () => { - throw new Error('\x1b]0;bad\x07render failed'); - }, - }); - throwingAdapter.start(); - throwingEvents.close(); - await expect(throwingAdapter.stop()).resolves.toBeUndefined(); - }); - - it('reports unsupported daemon protocol versions once and advances replay state', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - events.push({ id: 41, v: 2 as 1, type: 'future_event', data: {} }); - events.push({ id: 42, v: 2 as 1, type: 'future_event', data: {} }); - - await waitFor(() => expect(adapter.lastEventId).toBe(42)); - const unsupportedUpdates = onUpdate.mock.calls.filter( - ([update]) => - update.type === 'history' && - update.item.type === 'error' && - update.item.text.includes('Unsupported daemon protocol version'), - ); - expect(unsupportedUpdates).toHaveLength(1); - - events.close(); - await adapter.stop(); - }); - - it('forwards prompt, cancel, model switch, and permission votes', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - await adapter.sendPrompt('hello daemon'); - expect(session.prompt).toHaveBeenCalledWith( - { - prompt: [{ type: 'text', text: 'hello daemon' }], - }, - expect.any(AbortSignal), - ); - expect(onUpdate).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'turn_complete' }), - ); - - const blocks: ContentBlock[] = [{ type: 'text', text: 'structured' }]; - await adapter.sendPrompt(blocks); - expect(session.prompt).toHaveBeenLastCalledWith( - { prompt: blocks }, - expect.any(AbortSignal), - ); - - await adapter.cancel(); - await adapter.setModel('qwen3-coder-plus'); - await adapter.approvePermission('req-1', 'proceed_once'); - await adapter.rejectPermission('req-2'); - - expect(session.cancel).toHaveBeenCalledOnce(); - expect(session.setModel).toHaveBeenCalledWith('qwen3-coder-plus'); - expect(session.respondToPermission).toHaveBeenNthCalledWith(1, 'req-1', { - outcome: { outcome: 'selected', optionId: 'proceed_once' }, - }); - expect(session.respondToPermission).toHaveBeenNthCalledWith(2, 'req-2', { - outcome: { outcome: 'cancelled' }, - }); - - await adapter.stop(); - }); - - it('reports prompt failures without fabricating turn completion', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - session.prompt.mockRejectedValue(new Error('\x1b[31mdaemon down\x1b[0m')); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - await expect(adapter.sendPrompt('hello daemon')).rejects.toThrow( - 'daemon down', - ); - expect(onUpdate).toHaveBeenCalledWith({ - type: 'disconnected', - reason: 'daemon down', - }); - expect(onUpdate).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'turn_complete' }), - ); - - events.close(); - }); - - it('requires a running event pump before sending control RPCs', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const adapter = new DaemonTuiAdapter({ session, onUpdate: vi.fn() }); - - await expect(adapter.sendPrompt('hello')).rejects.toThrow( - 'Daemon TUI adapter is not running', - ); - await expect(adapter.cancel()).rejects.toThrow( - 'Daemon TUI adapter is not running', - ); - - events.close(); - }); - - it('restarts after start is requested while stop is draining the pump', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - const stopPromise = adapter.stop(); - adapter.start(); - await stopPromise; - - await waitFor(() => expect(session.events).toHaveBeenCalledTimes(2)); - await adapter.stop(); - }); - - it('forces idle when the event pump ignores abort during stop', async () => { - vi.useFakeTimers(); - const events = new EventQueue(); - const session = createFakeSession(events); - const hangingEvents: AsyncGenerator = { - next: vi.fn(() => new Promise>(() => {})), - return: vi.fn(async () => ({ done: true as const, value: undefined })), - throw: vi.fn(async (error?: unknown) => { - throw error; - }), - [Symbol.asyncIterator]() { - return this; - }, - }; - session.events.mockReturnValue(hangingEvents); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - const stopPromise = adapter.stop(); - adapter.start(); - await vi.advanceTimersByTimeAsync(5_000); - await stopPromise; - - expect(session.events).toHaveBeenCalledTimes(2); - vi.useRealTimers(); - }); - - it('clears accumulated tool state before each prompt', async () => { - const events = new EventQueue(); - const session = createFakeSession(events); - const onUpdate = vi.fn(); - const adapter = new DaemonTuiAdapter({ session, onUpdate }); - - adapter.start(); - events.push({ - id: 1, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'old-tool', - kind: 'shell', - status: 'completed', - }, - }, - }); - await waitFor(() => - expect(onUpdate).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_group_update', - item: expect.objectContaining({ - tools: [expect.objectContaining({ callId: 'old-tool' })], - }), - }), - ), - ); - - await adapter.sendPrompt('next turn'); - events.push({ - id: 2, - v: 1, - type: 'session_update', - data: { - sessionId: 'session-1', - update: { - sessionUpdate: 'tool_call_update', - toolCallId: 'new-tool', - kind: 'grep', - status: 'running', - }, - }, - }); - - await waitFor(() => { - const lastToolUpdate = onUpdate.mock.calls - .map(([update]) => update) - .filter((update) => update.type === 'tool_group_update') - .at(-1); - expect(lastToolUpdate).toMatchObject({ - item: { - tools: [expect.objectContaining({ callId: 'new-tool' })], - }, - }); - expect(lastToolUpdate?.item.tools).toHaveLength(1); - }); - - await adapter.stop(); - }); - - it('reports daemon control failures without dropping the event stream', async () => { - const cancelEvents = new EventQueue(); - const cancelSession = createFakeSession(cancelEvents); - const cancelUpdates = vi.fn(); - const cancelAdapter = new DaemonTuiAdapter({ - session: cancelSession, - onUpdate: cancelUpdates, - }); - cancelAdapter.start(); - cancelSession.cancel.mockRejectedValueOnce( - new Error('\x1b[31mcancel down\x1b[0m'), - ); - await expect(cancelAdapter.cancel()).rejects.toThrow('cancel down'); - expect(cancelUpdates).toHaveBeenCalledWith({ - type: 'history', - item: { type: 'error', text: 'Daemon RPC failed: cancel down' }, - }); - expect(cancelUpdates).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'disconnected' }), - ); - - const modelEvents = new EventQueue(); - const modelSession = createFakeSession(modelEvents); - const modelUpdates = vi.fn(); - const modelAdapter = new DaemonTuiAdapter({ - session: modelSession, - onUpdate: modelUpdates, - }); - modelAdapter.start(); - modelSession.setModel.mockRejectedValueOnce(new Error('model down')); - await expect(modelAdapter.setModel('qwen3-coder-plus')).rejects.toThrow( - 'model down', - ); - expect(modelUpdates).toHaveBeenCalledWith({ - type: 'history', - item: { type: 'error', text: 'Daemon RPC failed: model down' }, - }); - expect(modelUpdates).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'disconnected' }), - ); - - const voteEvents = new EventQueue(); - const voteSession = createFakeSession(voteEvents); - const voteUpdates = vi.fn(); - const voteAdapter = new DaemonTuiAdapter({ - session: voteSession, - onUpdate: voteUpdates, - }); - voteAdapter.start(); - voteSession.respondToPermission.mockRejectedValueOnce( - new Error('vote down'), - ); - await expect( - voteAdapter.approvePermission('req-1', 'proceed_once'), - ).rejects.toThrow('vote down'); - expect(voteUpdates).toHaveBeenCalledWith({ - type: 'history', - item: { type: 'error', text: 'Daemon RPC failed: vote down' }, - }); - expect(voteUpdates).not.toHaveBeenCalledWith( - expect.objectContaining({ type: 'disconnected' }), - ); - - cancelEvents.close(); - modelEvents.close(); - voteEvents.close(); - }); -}); diff --git a/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts b/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts deleted file mode 100644 index d5aa3ddd3cc..00000000000 --- a/packages/cli/src/ui/daemon/DaemonTuiAdapter.ts +++ /dev/null @@ -1,905 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen Team - * SPDX-License-Identifier: Apache-2.0 - */ - -import type { - ContentBlock, - RequestPermissionRequest, - RequestPermissionResponse, -} from '@agentclientprotocol/sdk'; -import { createDebugLogger } from '@qwen-code/qwen-code-core'; -import { - ToolCallStatus, - type HistoryItemToolGroup, - type HistoryItemWithoutId, - type IndividualToolCallDisplay, -} from '../types.js'; - -export interface DaemonTuiEvent { - id?: number; - v: 1; - type: string; - data: unknown; - originatorClientId?: string; -} - -export interface DaemonTuiPromptResult { - stopReason?: string; - [key: string]: unknown; -} - -export interface DaemonTuiSessionClient { - readonly sessionId: string; - readonly workspaceCwd: string; - readonly lastEventId?: number; - prompt( - req: { prompt: ContentBlock[] }, - signal?: AbortSignal, - ): Promise; - events(opts?: { - signal?: AbortSignal; - lastEventId?: number; - resume?: boolean; - }): AsyncGenerator; - cancel(): Promise; - setModel(modelId: string): Promise>; - respondToPermission( - requestId: string, - response: RequestPermissionResponse, - ): Promise; -} - -export type DaemonTuiUpdate = - | { - type: 'history'; - item: HistoryItemWithoutId; - daemonEventId?: number; - } - | { - type: 'permission_request'; - requestId: string; - request: RequestPermissionRequest; - daemonEventId?: number; - } - | { - type: 'tool_group_update'; - item: HistoryItemToolGroup; - daemonEventId?: number; - } - | { - type: 'permission_resolved'; - requestId: string; - outcome?: unknown; - daemonEventId?: number; - } - | { - type: 'model_switched'; - modelId: string; - daemonEventId?: number; - } - | { - type: 'disconnected'; - reason: string; - daemonEventId?: number; - }; - -export interface DaemonTuiAdapterOptions { - session: DaemonTuiSessionClient; - onUpdate: (update: DaemonTuiUpdate) => void; -} - -export interface DaemonTuiReducerState { - toolCallsById: Map; - toolCallOrder: string[]; -} - -export function createDaemonTuiReducerState(): DaemonTuiReducerState { - return { toolCallsById: new Map(), toolCallOrder: [] }; -} - -function clearDaemonTuiReducerState(state: DaemonTuiReducerState): void { - state.toolCallsById.clear(); - state.toolCallOrder.length = 0; -} - -const MAX_TOOL_CALLS = 128; -const MAX_PLAN_ENTRIES = 200; -const MAX_DISPLAY_TEXT_LENGTH = 20_000; -const MAX_UNKNOWN_EVENT_TYPES = 100; -const MAX_UNSUPPORTED_PROTOCOL_VERSIONS = 20; -const STOP_TIMEOUT_MS = 5_000; -const ESC = String.fromCharCode(27); -const OSC_RE = new RegExp(`${ESC}\\][\\s\\S]*?(?:\\x07|${ESC}\\\\)`, 'g'); -const DCS_RE = new RegExp(`${ESC}[PX^_][\\s\\S]*?${ESC}\\\\`, 'g'); -const CSI_RE = new RegExp(`${ESC}\\[[0-?]*[ -/]*[@-~]`, 'g'); -const C1_RE = new RegExp(`${ESC}[@-Z\\\\-_]`, 'g'); -const C1_CSI_RE = /\x9b[0-?]*[ -/]*[@-~]/g; -const C1_STRING_RE = /[\x90\x98\x9e\x9f][\s\S]*?\x9c/g; -const BIDI_CONTROL_RE = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; -const UNKNOWN_EVENT_TYPES = new Set(); -const UNSUPPORTED_PROTOCOL_VERSIONS = new Set(); -const debugLogger = createDebugLogger('DAEMON_TUI_ADAPTER'); - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; -} - -function getString(value: unknown): string | undefined { - return typeof value === 'string' ? value : undefined; -} - -function getTextContent(content: unknown): string | undefined { - if (!isRecord(content)) { - return undefined; - } - return getString(content['text']); -} - -function getSessionUpdate(data: unknown): Record | undefined { - if (!isRecord(data) || !isRecord(data['update'])) { - return undefined; - } - return data['update']; -} - -function formatPlan(entries: unknown): string | undefined { - if (!Array.isArray(entries)) { - return undefined; - } - const lines = entries - .slice(0, MAX_PLAN_ENTRIES) - .filter(isRecord) - .map((entry, index) => { - const content = getString(entry['content']) ?? ''; - const status = getString(entry['status']) ?? 'pending'; - return `${index + 1}. [${sanitizeDisplayText(status)}] ${sanitizeDisplayText(content)}`; - }) - .filter((line) => line.trim().length > 0); - return lines.length > 0 ? lines.join('\n') : undefined; -} - -function mapToolStatus(status: unknown): ToolCallStatus { - switch (status) { - case 'pending': - return ToolCallStatus.Pending; - case 'confirming': - return ToolCallStatus.Confirming; - case 'in_progress': - case 'running': - return ToolCallStatus.Executing; - case 'completed': - case 'success': - return ToolCallStatus.Success; - case 'failed': - case 'error': - return ToolCallStatus.Error; - case 'canceled': - case 'cancelled': - return ToolCallStatus.Canceled; - default: - return ToolCallStatus.Error; - } -} - -function sanitizeReason(reason: string): string { - const withoutAnsi = stripControlSequences(reason); - let sanitized = ''; - for (const char of withoutAnsi) { - const code = char.charCodeAt(0); - if ((code < 32 && code !== 10) || code === 127 || isC1Control(code)) { - continue; - } - sanitized += char; - if (sanitized.length >= 500) { - break; - } - } - return sanitized; -} - -function sanitizeDisplayText(text: string): string { - const stripped = stripControlSequences(text); - let sanitized = ''; - for (const char of stripped) { - const code = char.charCodeAt(0); - if ( - (code < 32 && code !== 9 && code !== 10) || - code === 127 || - isC1Control(code) - ) { - continue; - } - sanitized += char; - if (sanitized.length >= MAX_DISPLAY_TEXT_LENGTH) { - break; - } - } - return sanitized; -} - -function stripControlSequences(value: string): string { - return value - .replace(BIDI_CONTROL_RE, '') - .replace(OSC_RE, '') - .replace(DCS_RE, '') - .replace(C1_STRING_RE, '') - .replace(C1_CSI_RE, '') - .replace(CSI_RE, '') - .replace(C1_RE, ''); -} - -function isC1Control(code: number): boolean { - return code >= 0x80 && code <= 0x9f; -} - -function sanitizeDaemonValue(value: unknown): unknown { - if (typeof value === 'string') { - return sanitizeDisplayText(value); - } - if (Array.isArray(value)) { - return value.map((item) => sanitizeDaemonValue(item)); - } - if (isRecord(value)) { - return Object.fromEntries( - Object.entries(value).map(([key, entryValue]) => [ - key, - sanitizeDaemonValue(entryValue), - ]), - ); - } - return value; -} - -function createSanitizedDaemonError(error: unknown): Error { - const message = sanitizeReason( - error instanceof Error ? error.message : String(error), - ); - return new Error(`Daemon RPC failed: ${message}`); -} - -function formatToolResultDisplay( - value: unknown, -): IndividualToolCallDisplay['resultDisplay'] { - if (value === undefined || value === null) { - return undefined; - } - if (typeof value === 'string') { - return sanitizeDisplayText(value); - } - if ( - isRecord(value) && - (typeof value['fileDiff'] === 'string' || - 'ansiOutput' in value || - value['type'] === 'todo_list' || - value['type'] === 'plan_summary' || - value['type'] === 'task_execution' || - value['type'] === 'mcp_tool_progress') - ) { - return sanitizeDaemonValue( - value, - ) as IndividualToolCallDisplay['resultDisplay']; - } - try { - return sanitizeDisplayText(JSON.stringify(value)); - } catch { - return sanitizeDisplayText(String(value)); - } -} - -function formatToolContentText(value: unknown): string | undefined { - if (!Array.isArray(value)) { - return undefined; - } - const parts = value - .map((item) => { - if (!isRecord(item)) { - return undefined; - } - const content = item['content']; - if (isRecord(content)) { - const text = getString(content['text']); - return text === undefined ? undefined : sanitizeDisplayText(text); - } - const text = getString(item['text']); - return text === undefined ? undefined : sanitizeDisplayText(text); - }) - .filter((part): part is string => part !== undefined && part.length > 0); - return parts.length > 0 ? parts.join('\n') : undefined; -} - -function terminalUpdates( - event: DaemonTuiEvent, - reason: string, -): DaemonTuiUpdate[] { - const sanitizedReason = sanitizeReason(reason); - return [ - { - type: 'disconnected', - reason: sanitizedReason, - daemonEventId: event.id, - }, - { - type: 'history', - item: { - type: 'error', - text: `Daemon session disconnected: ${sanitizedReason}`, - }, - daemonEventId: event.id, - }, - ]; -} - -function toolUpdateToHistoryItem( - update: Record, - state?: DaemonTuiReducerState, -): HistoryItemToolGroup | undefined { - const toolCallId = getString(update['toolCallId']); - if (!toolCallId) { - return undefined; - } - - const title = getString(update['title']); - const kind = getString(update['kind']); - const safeToolCallId = sanitizeDisplayText(toolCallId); - const safeTitle = - title === undefined ? undefined : sanitizeDisplayText(title); - const safeKind = kind === undefined ? undefined : sanitizeDisplayText(kind); - const rawOutput = formatToolResultDisplay(update['rawOutput']); - const contentOutput = formatToolContentText(update['content']); - const previous = state?.toolCallsById.get(toolCallId); - const tool: IndividualToolCallDisplay = { - callId: safeToolCallId, - name: safeKind ?? safeTitle ?? previous?.name ?? safeToolCallId, - description: - safeTitle ?? safeKind ?? previous?.description ?? safeToolCallId, - resultDisplay: rawOutput ?? contentOutput ?? previous?.resultDisplay, - status: - update['status'] == null - ? (previous?.status ?? ToolCallStatus.Pending) - : mapToolStatus(update['status']), - // Confirmation UI is driven by daemon permission_request events. The - // in-process ToolCallConfirmationDetails shape contains callbacks and is - // not directly serializable across the daemon boundary. - confirmationDetails: previous?.confirmationDetails, - }; - - if (state && !state.toolCallsById.has(toolCallId)) { - state.toolCallOrder.push(toolCallId); - } - state?.toolCallsById.set(toolCallId, tool); - if (state) { - while (state.toolCallOrder.length > MAX_TOOL_CALLS) { - const oldest = state.toolCallOrder.shift(); - if (oldest !== undefined) { - state.toolCallsById.delete(oldest); - } - } - } - return { - type: 'tool_group', - tools: Array.from(state?.toolCallsById.values() ?? [tool]), - }; -} - -function isPermissionRequestData( - value: unknown, -): value is RequestPermissionRequest & { requestId: string } { - return ( - isRecord(value) && - typeof value['requestId'] === 'string' && - typeof value['sessionId'] === 'string' && - isRecord(value['toolCall']) && - typeof value['toolCall']['toolCallId'] === 'string' && - typeof value['toolCall']['kind'] === 'string' && - Array.isArray(value['options']) && - value['options'].every( - (option) => isRecord(option) && typeof option['optionId'] === 'string', - ) - ); -} - -function sanitizePermissionRequest( - request: RequestPermissionRequest & { requestId: string }, -): RequestPermissionRequest & { requestId: string } { - const sanitizedToolCall = sanitizeDaemonValue( - request.toolCall, - ) as typeof request.toolCall; - return { - ...request, - toolCall: { - ...sanitizedToolCall, - toolCallId: request.toolCall.toolCallId, - }, - options: request.options.map((option) => ({ - ...option, - name: - typeof option.name === 'string' - ? sanitizeDisplayText(option.name) - : option.name, - })), - }; -} - -function sanitizePermissionOutcome(value: unknown): unknown | undefined { - if (!isRecord(value)) { - return undefined; - } - const outcome = value['outcome']; - if (outcome === 'cancelled') { - return { outcome }; - } - if (outcome === 'selected' && typeof value['optionId'] === 'string') { - return { outcome, optionId: sanitizeDisplayText(value['optionId']) }; - } - return undefined; -} - -function warnUnknownEventTypeOnce(event: DaemonTuiEvent): void { - const eventType = sanitizeDisplayText(event.type); - if (UNKNOWN_EVENT_TYPES.has(eventType)) { - return; - } - if (UNKNOWN_EVENT_TYPES.size >= MAX_UNKNOWN_EVENT_TYPES) { - return; - } - UNKNOWN_EVENT_TYPES.add(eventType); - debugLogger.warn('[DaemonTuiAdapter] Unknown daemon event type:', { - eventType, - eventId: event.id, - }); -} - -function shouldReportUnsupportedProtocolVersion(version: unknown): boolean { - const sanitizedVersion = sanitizeDisplayText(String(version)); - if (UNSUPPORTED_PROTOCOL_VERSIONS.has(sanitizedVersion)) { - return false; - } - if (UNSUPPORTED_PROTOCOL_VERSIONS.size >= MAX_UNSUPPORTED_PROTOCOL_VERSIONS) { - return false; - } - UNSUPPORTED_PROTOCOL_VERSIONS.add(sanitizedVersion); - return true; -} - -export function reduceDaemonEventToTuiUpdates( - event: DaemonTuiEvent, - state?: DaemonTuiReducerState, -): DaemonTuiUpdate[] { - switch (event.type) { - case 'session_update': { - const update = getSessionUpdate(event.data); - const sessionUpdate = getString(update?.['sessionUpdate']); - const text = getTextContent(update?.['content']); - - if (sessionUpdate === 'user_message_chunk') { - return []; - } - - if (sessionUpdate === 'agent_message_chunk' && text) { - return [ - { - type: 'history', - item: { type: 'gemini_content', text: sanitizeDisplayText(text) }, - daemonEventId: event.id, - }, - ]; - } - - if (sessionUpdate === 'agent_thought_chunk' && text) { - return [ - { - type: 'history', - item: { - type: 'gemini_thought_content', - text: sanitizeDisplayText(text), - }, - daemonEventId: event.id, - }, - ]; - } - - if ( - update && - (sessionUpdate === 'tool_call' || sessionUpdate === 'tool_call_update') - ) { - const item = toolUpdateToHistoryItem(update, state); - return item - ? [{ type: 'tool_group_update', item, daemonEventId: event.id }] - : []; - } - - if (sessionUpdate === 'plan') { - const text = formatPlan(update?.['entries']); - return text - ? [ - { - type: 'history', - item: { type: 'info', text }, - daemonEventId: event.id, - }, - ] - : []; - } - - return []; - } - - case 'permission_request': { - if (!isPermissionRequestData(event.data)) { - return []; - } - const request = sanitizePermissionRequest(event.data); - return [ - { - type: 'permission_request', - requestId: request.requestId, - request, - daemonEventId: event.id, - }, - ]; - } - - case 'permission_resolved': { - if ( - !isRecord(event.data) || - typeof event.data['requestId'] !== 'string' - ) { - return []; - } - const outcome = sanitizePermissionOutcome(event.data['outcome']); - return [ - { - type: 'permission_resolved', - requestId: event.data['requestId'], - outcome, - daemonEventId: event.id, - }, - ]; - } - - case 'model_switched': { - if (!isRecord(event.data) || typeof event.data['modelId'] !== 'string') { - return []; - } - const modelId = sanitizeDisplayText(event.data['modelId']); - return [ - { - type: 'model_switched', - modelId, - daemonEventId: event.id, - }, - { - type: 'history', - item: { - type: 'info', - text: `Model switched to ${modelId}`, - }, - daemonEventId: event.id, - }, - ]; - } - - case 'session_died': { - const reason = - isRecord(event.data) && typeof event.data['reason'] === 'string' - ? event.data['reason'] - : 'session_died'; - return terminalUpdates(event, reason); - } - - case 'client_evicted': { - const reason = - isRecord(event.data) && typeof event.data['reason'] === 'string' - ? event.data['reason'] - : 'client_evicted'; - return terminalUpdates(event, reason); - } - - case 'stream_error': { - const reason = - isRecord(event.data) && typeof event.data['error'] === 'string' - ? event.data['error'] - : 'stream_error'; - return terminalUpdates(event, reason); - } - - default: - warnUnknownEventTypeOnce(event); - return []; - } -} - -export class DaemonTuiAdapter { - private readonly session: DaemonTuiSessionClient; - private readonly onUpdate: (update: DaemonTuiUpdate) => void; - private readonly reducerState = createDaemonTuiReducerState(); - private eventController: AbortController | null = null; - private eventPump: Promise | null = null; - private lastSeenEventId: number | undefined; - private lifecycle: 'idle' | 'running' | 'stopping' = 'idle'; - private restartAfterStop = false; - private pumpGeneration = 0; - private busy = false; - - constructor(options: DaemonTuiAdapterOptions) { - this.session = options.session; - this.onUpdate = options.onUpdate; - this.lastSeenEventId = options.session.lastEventId; - } - - start(): void { - if (this.lifecycle === 'running') { - return; - } - if (this.lifecycle === 'stopping') { - this.restartAfterStop = true; - return; - } - this.startPump(); - } - - private startPump(): void { - this.eventController = new AbortController(); - this.lifecycle = 'running'; - const generation = ++this.pumpGeneration; - this.eventPump = this.pumpEvents(this.eventController.signal, generation); - } - - async stop(): Promise { - if (this.lifecycle === 'idle') { - return; - } - this.lifecycle = 'stopping'; - this.eventController?.abort(); - if (this.eventPump) { - try { - const drained = await this.waitForPumpToDrain(this.eventPump); - if (!drained && this.lifecycle === 'stopping') { - debugLogger.error( - '[DaemonTuiAdapter] Event pump did not drain within timeout; forcing idle', - ); - this.forceIdleAfterPumpTimeout(); - } - } catch { - /* pump errors are converted into updates */ - } - } - } - - async sendPrompt( - prompt: string | ContentBlock[], - ): Promise { - this.assertRunning(); - if (this.busy) { - throw new Error('A prompt is already in progress'); - } - this.busy = true; - clearDaemonTuiReducerState(this.reducerState); - const promptBlocks = - typeof prompt === 'string' - ? ([{ type: 'text', text: prompt }] as ContentBlock[]) - : prompt; - try { - const result = await this.session.prompt( - { prompt: promptBlocks }, - this.eventController?.signal, - ); - return typeof result.stopReason === 'string' - ? { ...result, stopReason: sanitizeReason(result.stopReason) } - : result; - } catch (error) { - this.reportDaemonFailure(error, { disconnect: true }); - throw createSanitizedDaemonError(error); - } finally { - this.busy = false; - } - } - - async cancel(): Promise { - this.assertRunning(); - try { - await this.session.cancel(); - } catch (error) { - this.reportDaemonFailure(error); - throw createSanitizedDaemonError(error); - } - } - - async setModel(modelId: string): Promise> { - this.assertRunning(); - try { - return await this.session.setModel(modelId); - } catch (error) { - this.reportDaemonFailure(error); - throw createSanitizedDaemonError(error); - } - } - - async approvePermission( - requestId: string, - optionId: string, - ): Promise { - this.assertRunning(); - try { - return await this.session.respondToPermission(requestId, { - outcome: { outcome: 'selected', optionId }, - }); - } catch (error) { - this.reportDaemonFailure(error); - throw createSanitizedDaemonError(error); - } - } - - async rejectPermission(requestId: string): Promise { - this.assertRunning(); - try { - return await this.session.respondToPermission(requestId, { - outcome: { outcome: 'cancelled' }, - }); - } catch (error) { - this.reportDaemonFailure(error); - throw createSanitizedDaemonError(error); - } - } - - get currentSessionId(): string { - return this.session.sessionId; - } - - get workspaceCwd(): string { - return this.session.workspaceCwd; - } - - get lastEventId(): number | undefined { - return this.lastSeenEventId ?? this.session.lastEventId; - } - - private async pumpEvents( - signal: AbortSignal, - generation: number, - ): Promise { - try { - const resumeId = this.lastSeenEventId ?? this.session.lastEventId; - for await (const event of this.session.events({ - signal, - lastEventId: resumeId, - resume: true, - })) { - if (signal.aborted) { - break; - } - if (event.id !== undefined) { - this.lastSeenEventId = event.id; - } - if ((event as { v?: unknown }).v !== 1) { - if ( - !shouldReportUnsupportedProtocolVersion( - (event as { v?: unknown }).v, - ) - ) { - continue; - } - this.emit({ - type: 'history', - item: { - type: 'error', - text: `Unsupported daemon protocol version: ${sanitizeDisplayText( - String((event as { v?: unknown }).v), - )}`, - }, - daemonEventId: event.id, - }); - continue; - } - for (const update of reduceDaemonEventToTuiUpdates( - event, - this.reducerState, - )) { - this.emit(update); - } - } - if (!signal.aborted) { - this.emit({ - type: 'disconnected', - reason: 'event stream ended', - }); - this.emit({ - type: 'history', - item: { type: 'info', text: 'Daemon event stream ended' }, - }); - } - } catch (error) { - if (!signal.aborted) { - const message = sanitizeReason( - error instanceof Error ? error.message : String(error), - ); - this.emit({ type: 'disconnected', reason: message }); - } - } finally { - if (this.pumpGeneration === generation) { - this.eventController = null; - this.eventPump = null; - const shouldRestart = this.restartAfterStop; - this.restartAfterStop = false; - this.lifecycle = 'idle'; - if (shouldRestart) { - this.start(); - } - } - } - } - - private reportDaemonFailure( - error: unknown, - options: { disconnect?: boolean } = {}, - ): void { - const message = sanitizeReason( - error instanceof Error ? error.message : String(error), - ); - if (options.disconnect && this.lifecycle === 'running') { - this.lifecycle = 'stopping'; - this.eventController?.abort(); - this.emit({ type: 'disconnected', reason: message }); - return; - } - this.emit({ - type: 'history', - item: { type: 'error', text: `Daemon RPC failed: ${message}` }, - }); - } - - private emit(update: DaemonTuiUpdate): void { - try { - this.onUpdate(update); - } catch { - /* isolate renderer callback failures from the daemon event pump */ - } - } - - private assertRunning(): void { - if (this.lifecycle !== 'running') { - throw new Error('Daemon TUI adapter is not running'); - } - } - - private async waitForPumpToDrain(pump: Promise): Promise { - let timedOut = false; - let timeout: ReturnType | undefined; - try { - await Promise.race([ - pump.then( - () => undefined, - () => undefined, - ), - new Promise((resolve) => { - timeout = setTimeout(() => { - timedOut = true; - resolve(); - }, STOP_TIMEOUT_MS); - timeout.unref?.(); - }), - ]); - } finally { - if (timeout !== undefined) { - clearTimeout(timeout); - } - } - return !timedOut; - } - - private forceIdleAfterPumpTimeout(): void { - const staleController = this.eventController; - staleController?.abort(); - this.pumpGeneration += 1; - const shouldRestart = this.restartAfterStop; - this.restartAfterStop = false; - this.eventController = null; - this.eventPump = null; - this.lifecycle = 'idle'; - if (shouldRestart) { - this.start(); - } - } -} diff --git a/packages/sdk-typescript/package.json b/packages/sdk-typescript/package.json index d7205d8c21e..07931f73bcb 100644 --- a/packages/sdk-typescript/package.json +++ b/packages/sdk-typescript/package.json @@ -12,6 +12,11 @@ "import": "./dist/index.mjs", "require": "./dist/index.cjs" }, + "./daemon": { + "types": "./dist/daemon/index.d.ts", + "import": "./dist/daemon/index.js", + "require": "./dist/daemon/index.cjs" + }, "./package.json": "./package.json" }, "files": [ diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 90c6f4277e2..91a4cefbd80 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -5,7 +5,14 @@ */ import { execSync } from 'node:child_process'; -import { rmSync, mkdirSync, existsSync, cpSync } from 'node:fs'; +import { + rmSync, + mkdirSync, + existsSync, + cpSync, + readFileSync, + statSync, +} from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import esbuild from 'esbuild'; @@ -13,6 +20,7 @@ import esbuild from 'esbuild'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); const rootDir = join(__dirname, '..'); +const MAX_DAEMON_BROWSER_BUNDLE_BYTES = 100 * 1024; rmSync(join(rootDir, 'dist'), { recursive: true, force: true }); mkdirSync(join(rootDir, 'dist'), { recursive: true }); @@ -81,6 +89,42 @@ await esbuild.build({ treeShaking: true, }); +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'daemon', 'index.ts')], + bundle: true, + format: 'esm', + platform: 'browser', + target: 'es2022', + outfile: join(rootDir, 'dist', 'daemon', 'index.js'), + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + +assertBrowserSafeBundle(join(rootDir, 'dist', 'daemon', 'index.js')); + +await esbuild.build({ + entryPoints: [join(rootDir, 'src', 'daemon', 'index.ts')], + bundle: true, + format: 'cjs', + platform: 'node', + target: 'node22', + outfile: join(rootDir, 'dist', 'daemon', 'index.cjs'), + sourcemap: false, + minify: true, + minifyWhitespace: true, + minifyIdentifiers: true, + minifySyntax: true, + legalComments: 'none', + keepNames: false, + treeShaking: true, +}); + // Copy LICENSE from root directory to dist const licenseSource = join(rootDir, '..', '..', 'LICENSE'); const licenseTarget = join(rootDir, 'dist', 'LICENSE'); @@ -91,3 +135,31 @@ if (existsSync(licenseSource)) { console.warn('Could not copy LICENSE:', error.message); } } + +function assertBrowserSafeBundle(filePath) { + const size = statSync(filePath).size; + if (size > MAX_DAEMON_BROWSER_BUNDLE_BYTES) { + throw new Error( + `Browser daemon SDK bundle is ${size} bytes; expected <= ${MAX_DAEMON_BROWSER_BUNDLE_BYTES}`, + ); + } + + const contents = readFileSync(filePath, 'utf8'); + const forbidden = [ + 'node:', + 'require("fs")', + "require('fs')", + 'require("path")', + "require('path')", + 'require("http")', + "require('http')", + 'require("https")', + "require('https')", + ]; + const found = forbidden.find((token) => contents.includes(token)); + if (found) { + throw new Error( + `Browser daemon SDK bundle contains Node-only token ${found}`, + ); + } +} diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index bbfd6099a79..7f3fc49ed23 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -36,6 +36,53 @@ export { reduceDaemonSessionEvents, } from './events.js'; export { parseSseStream, SseFramingError } from './sse.js'; +export { + appendLocalUserTranscriptMessage, + createDaemonToolPreview, + createDaemonTranscriptState, + createDaemonTranscriptStore, + daemonUiEventToTerminalText, + getOutputText as getDaemonUiOutputText, + getSessionUpdatePayload, + normalizeDaemonEvent, + rebuildDaemonTranscriptBlockIndex, + reduceDaemonTranscriptEvents, + sanitizeTerminalText as sanitizeDaemonTerminalText, + selectPendingPermissionBlocks, + selectTranscriptBlocks, + stringifyJson as stringifyDaemonUiJson, + stripOscSequences as stripDaemonOscSequences, + transcriptBlockToTerminalText, +} from './ui/index.js'; +export type { + DaemonShellTranscriptBlock, + DaemonStatusTranscriptBlock, + DaemonTextTranscriptBlock, + DaemonToolPreview, + DaemonToolTranscriptBlock, + DaemonTranscriptBlock, + DaemonTranscriptBlockKind, + DaemonTranscriptQuestion, + DaemonTranscriptQuestionOption, + DaemonTranscriptReducerOptions, + DaemonTranscriptState, + DaemonTranscriptStore, + DaemonUiAssistantDoneEvent, + DaemonUiErrorEvent, + DaemonUiEvent, + DaemonUiEventBase, + DaemonUiEventType, + DaemonUiModelChangedEvent, + DaemonUiPermissionOption, + DaemonUiPermissionRequestEvent, + DaemonUiPermissionResolvedEvent, + DaemonUiSessionActions, + DaemonUiShellOutputEvent, + DaemonUiStatusEvent, + DaemonUiTextEvent, + DaemonUiToolUpdateEvent, + NormalizeDaemonEventOptions, +} from './ui/index.js'; export { DAEMON_APPROVAL_MODES, DAEMON_ERROR_KINDS, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts new file mode 100644 index 00000000000..91dc61d4438 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { normalizeDaemonEvent, getSessionUpdatePayload } from './normalizer.js'; +export { createDaemonToolPreview } from './toolPreview.js'; +export { + appendLocalUserTranscriptMessage, + createDaemonTranscriptState, + rebuildDaemonTranscriptBlockIndex, + reduceDaemonTranscriptEvents, + selectPendingPermissionBlocks, + selectTranscriptBlocks, +} from './transcript.js'; +export { createDaemonTranscriptStore } from './store.js'; +export { + daemonUiEventToTerminalText, + transcriptBlockToTerminalText, +} from './terminal.js'; +export { + getOutputText, + sanitizeTerminalText, + stringifyJson, + stripOscSequences, +} from './utils.js'; +export type { + DaemonShellTranscriptBlock, + DaemonStatusTranscriptBlock, + DaemonTextTranscriptBlock, + DaemonToolPreview, + DaemonToolTranscriptBlock, + DaemonTranscriptBlock, + DaemonTranscriptBlockKind, + DaemonTranscriptQuestion, + DaemonTranscriptQuestionOption, + DaemonTranscriptReducerOptions, + DaemonTranscriptState, + DaemonTranscriptStore, + DaemonUiAssistantDoneEvent, + DaemonUiErrorEvent, + DaemonUiEvent, + DaemonUiEventBase, + DaemonUiEventType, + DaemonUiModelChangedEvent, + DaemonUiPermissionOption, + DaemonUiPermissionRequestEvent, + DaemonUiPermissionResolvedEvent, + DaemonUiSessionActions, + DaemonUiShellOutputEvent, + DaemonUiStatusEvent, + DaemonUiTextEvent, + DaemonUiToolUpdateEvent, + NormalizeDaemonEventOptions, +} from './types.js'; diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts new file mode 100644 index 00000000000..bf901ca8f35 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -0,0 +1,388 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DaemonEvent } from '../types.js'; +import type { + DaemonUiEvent, + DaemonUiPermissionOption, + NormalizeDaemonEventOptions, +} from './types.js'; +import { + getOutputText, + getString, + getTextContent, + isRecord, + stringifyJson, +} from './utils.js'; + +const MAX_DETAILS_LENGTH = 4096; + +export function normalizeDaemonEvent( + event: DaemonEvent, + opts: NormalizeDaemonEventOptions = {}, +): DaemonUiEvent[] { + const base = createBase(event, opts); + switch (event.type) { + case 'session_update': + return normalizeSessionUpdate(event, base, opts); + case 'shell_output': { + const text = getOutputText(event.data); + const stream = getShellStream(event.data); + return text + ? [ + { + ...base, + type: 'shell.output', + text, + ...(stream ? { stream } : {}), + }, + ] + : []; + } + case 'permission_request': + return normalizePermissionRequest(event, base); + case 'permission_resolved': + case 'permission_already_resolved': + return normalizePermissionResolved(event, base); + case 'model_switched': + return [ + { + ...base, + type: 'model.changed', + modelId: getString(event.data, 'modelId') ?? 'unknown', + }, + ]; + case 'model_switch_failed': + return [ + { + ...base, + type: 'error', + recoverable: true, + text: + getString(event.data, 'error') ?? + 'Model switch failed (no details available)', + }, + ]; + case 'session_died': + return [ + { + ...base, + type: 'error', + text: + getString(event.data, 'reason') ?? + 'Session died (no details available)', + }, + ]; + case 'session_closed': + return [ + { + ...base, + type: 'error', + recoverable: false, + text: `Session closed: ${getString(event.data, 'reason') ?? 'closed'}`, + }, + ]; + case 'client_evicted': + return [ + { + ...base, + type: 'error', + recoverable: true, + text: + getString(event.data, 'reason') ?? + 'SSE client evicted (no details available)', + }, + ]; + case 'slow_client_warning': + return [ + { + ...base, + type: 'status', + text: 'SSE stream is lagging', + }, + ]; + case 'stream_error': + return [ + { + ...base, + type: 'error', + recoverable: true, + text: + getString(event.data, 'error') ?? + 'SSE stream error (no details available)', + }, + ]; + default: + return [ + { + ...base, + type: 'debug', + text: `${event.type}: ${stringifyJson(event.data)}`, + }, + ]; + } +} + +function createBase( + event: DaemonEvent, + opts: NormalizeDaemonEventOptions, +): Pick { + return { + ...(event.id !== undefined ? { eventId: event.id } : {}), + ...(event.originatorClientId + ? { originatorClientId: event.originatorClientId } + : {}), + ...(opts.includeRawEvent ? { rawEvent: event } : {}), + }; +} + +function normalizeSessionUpdate( + event: DaemonEvent, + base: Pick, + opts: NormalizeDaemonEventOptions, +): DaemonUiEvent[] { + const update = getSessionUpdatePayload(event.data); + if (!update) { + return [ + { + ...base, + type: 'debug', + text: `session_update: ${stringifyJson(event.data)}`, + }, + ]; + } + + const kind = getString(update, 'sessionUpdate'); + switch (kind) { + case 'user_message_chunk': { + if ( + opts.suppressOwnUserEcho && + opts.clientId && + event.originatorClientId === opts.clientId + ) { + return []; + } + const text = getTextContent(update['content']); + return text ? [{ ...base, type: 'user.text.delta', text }] : []; + } + case 'agent_message_chunk': { + const text = getTextContent(update['content']); + return text ? [{ ...base, type: 'assistant.text.delta', text }] : []; + } + case 'agent_thought_chunk': { + const text = getTextContent(update['content']); + return text ? [{ ...base, type: 'thought.text.delta', text }] : []; + } + case 'tool_call': + case 'tool_call_update': + return [normalizeToolUpdate(update, base)]; + case 'shell_output': + case 'tool_output': { + const text = getOutputText(update); + const stream = getShellStream(update) ?? getShellStream(event.data); + return text + ? [ + { + ...base, + type: 'shell.output', + text, + ...(stream ? { stream } : {}), + }, + ] + : []; + } + case 'available_commands_update': { + const commands = Array.isArray(update['availableCommands']) + ? update['availableCommands'] + : []; + return [ + { + ...base, + type: 'status', + text: `Available commands updated (${commands.length})`, + }, + ]; + } + default: + return [ + { + ...base, + type: 'debug', + text: `${kind ?? 'session_update'}: ${stringifyJson(update)}`, + }, + ]; + } +} + +function normalizeToolUpdate( + update: Record, + base: Pick, +): DaemonUiEvent { + const metadata = isRecord(update['_meta']) ? update['_meta'] : undefined; + const toolName = + getString(update, 'toolName') ?? + getString(update, 'name') ?? + (metadata ? getString(metadata, 'toolName') : undefined) ?? + (metadata ? getString(metadata, 'name') : undefined); + const toolKind = getString(update, 'kind'); + const title = getString(update, 'title') ?? toolName ?? toolKind; + const rawInput = update['rawInput'] ?? update['input'] ?? update['args']; + const rawOutput = update['rawOutput'] ?? update['output'] ?? update['result']; + const toolCallId = getString(update, 'toolCallId'); + const status = getString(update, 'status'); + if (!toolCallId) { + return { + ...base, + type: 'error', + recoverable: true, + text: `Tool update missing toolCallId${title ? ` (${title})` : ''}`, + }; + } + return { + ...base, + type: 'tool.update', + toolCallId, + ...(status ? { status } : {}), + ...(title ? { title } : {}), + ...(toolName ? { toolName } : {}), + ...(toolKind ? { toolKind } : {}), + ...(rawInput !== undefined ? { rawInput } : {}), + ...(rawOutput !== undefined ? { rawOutput } : {}), + ...(rawInput !== undefined + ? { details: capDetails(stringifyJson(rawInput)) } + : rawOutput !== undefined + ? { details: capDetails(getOutputText(rawOutput)) } + : {}), + }; +} + +function capDetails(details: string): string { + if (details.length <= MAX_DETAILS_LENGTH) return details; + return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; +} + +function normalizePermissionRequest( + event: DaemonEvent, + base: Pick, +): DaemonUiEvent[] { + if (!isRecord(event.data)) { + return [ + { + ...base, + type: 'debug', + text: `permission_request: ${stringifyJson(event.data)}`, + }, + ]; + } + + const requestId = getString(event.data, 'requestId'); + if (!requestId) { + return [ + { + ...base, + type: 'debug', + text: `permission_request: ${stringifyJson(event.data)}`, + }, + ]; + } + + const toolCall = event.data['toolCall']; + return [ + { + ...base, + type: 'permission.request', + requestId, + sessionId: getString(event.data, 'sessionId'), + title: describeToolCall(toolCall), + options: normalizePermissionOptions(event.data['options']), + toolCall, + }, + ]; +} + +function normalizePermissionResolved( + event: DaemonEvent, + base: Pick, +): DaemonUiEvent[] { + const requestId = getString(event.data, 'requestId'); + if (!requestId) { + return [ + { + ...base, + type: 'debug', + text: `${event.type}: ${stringifyJson(event.data)}`, + }, + ]; + } + return [ + { + ...base, + type: 'permission.resolved', + requestId, + outcome: describePermissionOutcome(event.data), + }, + ]; +} + +export function getSessionUpdatePayload( + value: unknown, +): Record | undefined { + if (!isRecord(value)) return undefined; + const update = value['update']; + return isRecord(update) ? update : value; +} + +function normalizePermissionOptions( + value: unknown, +): DaemonUiPermissionOption[] { + if (!Array.isArray(value)) return []; + return value.flatMap((option): DaemonUiPermissionOption[] => { + if (!isRecord(option)) return []; + const optionId = getString(option, 'optionId'); + if (!optionId) return []; + return [ + { + optionId, + label: + getString(option, 'label') ?? + getString(option, 'title') ?? + getString(option, 'name') ?? + optionId, + ...(getString(option, 'description') + ? { description: getString(option, 'description') } + : {}), + raw: option, + }, + ]; + }); +} + +function describePermissionOutcome(value: unknown): string { + if (!isRecord(value)) return stringifyJson(value); + const outcome = value['outcome']; + if (isRecord(outcome)) { + const kind = getString(outcome, 'outcome') ?? 'selected'; + const optionId = getString(outcome, 'optionId'); + return optionId ? `${kind}:${optionId}` : kind; + } + return stringifyJson(value); +} + +function describeToolCall(value: unknown): string { + if (!isRecord(value)) return 'Tool permission'; + return ( + getString(value, 'title') ?? + getString(value, 'name') ?? + getString(value, 'kind') ?? + getString(value, 'toolName') ?? + 'Tool permission' + ); +} + +function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { + const stream = getString(value, 'stream'); + return stream === 'stdout' || stream === 'stderr' ? stream : undefined; +} diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts new file mode 100644 index 00000000000..4f3fae3d2c1 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -0,0 +1,85 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonTranscriptState, + DaemonTranscriptStore, + DaemonUiEvent, +} from './types.js'; +import { + appendLocalUserTranscriptMessage, + createDaemonTranscriptState, + rebuildDaemonTranscriptBlockIndex, + reduceDaemonTranscriptEvents, +} from './transcript.js'; + +export function createDaemonTranscriptStore( + seed: Partial = {}, +): DaemonTranscriptStore { + let state = createState(seed); + const listeners = new Set<() => void>(); + let notifyScheduled = false; + + const notify = () => { + for (const listener of listeners) listener(); + }; + const scheduleNotify = () => { + if (notifyScheduled) return; + notifyScheduled = true; + queueMicrotask(() => { + notifyScheduled = false; + notify(); + }); + }; + + return { + getSnapshot() { + return state; + }, + subscribe(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }, + dispatch(event: DaemonUiEvent | DaemonUiEvent[]) { + const events = Array.isArray(event) ? event : [event]; + if (events.length === 0) return; + state = reduceDaemonTranscriptEvents(state, events); + scheduleNotify(); + }, + appendLocalUserMessage(text: string) { + state = appendLocalUserTranscriptMessage(state, text); + scheduleNotify(); + }, + reset(nextSeed: Partial = {}) { + state = createState({ + maxBlocks: nextSeed.maxBlocks ?? state.maxBlocks, + ...nextSeed, + }); + scheduleNotify(); + }, + }; +} + +function createState( + seed: Partial, +): DaemonTranscriptState { + const blocks = seed.blocks ? [...seed.blocks] : []; + return { + ...createDaemonTranscriptState({ + maxBlocks: seed.maxBlocks, + now: seed.now, + }), + ...seed, + blocks, + blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks), + toolBlockByCallId: { ...(seed.toolBlockByCallId ?? {}) }, + permissionBlockByRequestId: { + ...(seed.permissionBlockByRequestId ?? {}), + }, + }; +} diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts new file mode 100644 index 00000000000..b5167b5ac1d --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DaemonTranscriptBlock, DaemonUiEvent } from './types.js'; +import { sanitizeTerminalText } from './utils.js'; + +export function daemonUiEventToTerminalText(event: DaemonUiEvent): string { + switch (event.type) { + case 'user.text.delta': + return terminalLine('qwen', event.text, '38;5;42'); + case 'assistant.text.delta': + return sanitizeTerminalText(event.text).replace(/\r?\n/g, '\r\n'); + case 'assistant.done': + return ''; + case 'thought.text.delta': + return terminalLine('thought', event.text, '2'); + case 'tool.update': + return terminalLine( + `tool ${event.status}`, + `${event.title}${event.details ? ` ${event.details}` : ''}`, + '38;5;75', + ); + case 'shell.output': + return terminalBlock('shell', event.text, '38;5;244'); + case 'permission.request': { + const options = event.options.map((option) => option.label).join(' / '); + return terminalLine( + 'permission', + `${event.title}${options ? ` [${options}]` : ''}`, + '33', + ); + } + case 'permission.resolved': + return terminalLine('permission', event.outcome, '33'); + case 'model.changed': + return terminalLine('model', event.modelId, '36'); + case 'status': + case 'debug': + return terminalLine(event.type, event.text, '2'); + case 'error': + return terminalLine('error', event.text, '31'); + default: + return assertNever(event); + } +} + +export function transcriptBlockToTerminalText( + block: DaemonTranscriptBlock, +): string { + switch (block.kind) { + case 'user': + return terminalLine('qwen', block.text, '38;5;42'); + case 'assistant': + return sanitizeTerminalText(block.text).replace(/\r?\n/g, '\r\n'); + case 'thought': + return terminalLine('thought', block.text, '2'); + case 'tool': + return terminalLine( + `tool ${block.status}`, + `${block.title}${block.details ? ` ${block.details}` : ''}`, + '38;5;75', + ); + case 'shell': + return terminalBlock('shell', block.text, '38;5;244'); + case 'permission': { + const options = block.options.map((option) => option.label).join(' / '); + const suffix = block.resolved ? ` resolved=${block.resolved}` : ''; + return terminalLine( + 'permission', + `${block.title}${options ? ` [${options}]` : ''}${suffix}`, + '33', + ); + } + case 'status': + case 'debug': + return terminalLine(block.kind, block.text, '2'); + case 'error': + return terminalLine('error', block.text, '31'); + default: + return assertNever(block); + } +} + +function assertNever(value: never): string { + void value; + return ''; +} + +function terminalLine(label: string, text: string, sgr: string): string { + return `\r\n${terminalLabel(label, sgr)}${sanitizeTerminalText(text).replace(/\r?\n/g, '\r\n')}\r\n`; +} + +function terminalBlock(label: string, text: string, sgr: string): string { + if (!text) return ''; + return `\r\n${terminalLabel(label, sgr)}${sanitizeTerminalText(text).replace(/\r?\n/g, '\r\n')}\r\n`; +} + +function terminalLabel(label: string, sgr: string): string { + return `\x1b[${sgr}m${sanitizeTerminalText(label)}>\x1b[0m `; +} diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts new file mode 100644 index 00000000000..55b064754e3 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -0,0 +1,123 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonToolPreview, + DaemonTranscriptQuestion, + DaemonTranscriptQuestionOption, +} from './types.js'; +import { getFirstString, isRecord, stringifyJson } from './utils.js'; + +const MAX_TOOL_PREVIEW_DEPTH = 8; + +export function createDaemonToolPreview( + input: unknown, + opts: { title?: string; toolName?: string; toolKind?: string } = {}, + depth = 0, +): DaemonToolPreview { + if (depth > MAX_TOOL_PREVIEW_DEPTH) { + const summary = opts.title ?? opts.toolName ?? opts.toolKind; + return { kind: 'generic', ...(summary ? { summary } : {}) }; + } + + if (isRecord(input)) { + const nestedInput = input['rawInput'] ?? input['input'] ?? input['args']; + if (nestedInput !== undefined && nestedInput !== input) { + const nested = createDaemonToolPreview( + nestedInput, + { + title: opts.title ?? getFirstString(input, ['title']), + toolName: + opts.toolName ?? getFirstString(input, ['toolName', 'name']), + toolKind: opts.toolKind ?? getFirstString(input, ['kind']), + }, + depth + 1, + ); + if (nested.kind !== 'generic' || !nested.summary) return nested; + } + } + + const askUserQuestions = extractAskUserQuestions(input); + if (askUserQuestions.length > 0) { + return { kind: 'ask_user_question', questions: askUserQuestions }; + } + + if (isRecord(input)) { + const command = getFirstString(input, ['command', 'cmd']); + if (command) { + const cwd = getFirstString(input, [ + 'cwd', + 'directory', + 'workingDirectory', + ]); + return { kind: 'command', command, ...(cwd ? { cwd } : {}) }; + } + + const rows = collectPreviewRows(input); + if (rows.length > 0) { + return { kind: 'key_value', rows }; + } + } + + const summary = opts.title ?? opts.toolName ?? opts.toolKind; + return { kind: 'generic', ...(summary ? { summary } : {}) }; +} + +function extractAskUserQuestions(input: unknown): DaemonTranscriptQuestion[] { + if (!isRecord(input) || !Array.isArray(input['questions'])) return []; + return input['questions'].filter(isRecord).map((question) => { + const header = getFirstString(question, ['header', 'title', 'label']); + const prompt = + getFirstString(question, ['question', 'prompt', 'text']) ?? 'Question'; + const options = Array.isArray(question['options']) + ? question['options'].filter(isRecord).map(normalizeQuestionOption) + : []; + return { + ...(header ? { header } : {}), + question: prompt, + options, + raw: question, + }; + }); +} + +function normalizeQuestionOption( + option: Record, +): DaemonTranscriptQuestionOption { + const label = getFirstString(option, ['label', 'title', 'value']) ?? 'Option'; + const description = getFirstString(option, ['description', 'detail', 'text']); + return { + label, + ...(description ? { description } : {}), + raw: option, + }; +} + +function collectPreviewRows( + input: Record, +): Array<{ label: string; value: string }> { + const rows: Array<{ label: string; value: string }> = []; + const candidates: Array<[string, readonly string[]]> = [ + ['Path', ['path', 'filePath', 'file_path', 'absolutePath']], + ['Cwd', ['cwd', 'directory', 'workingDirectory']], + ['Query', ['query', 'pattern', 'search']], + ['Note', ['description', 'reason']], + ]; + + for (const [label, keys] of candidates) { + const value = getFirstString(input, keys); + if (value) rows.push({ label, value }); + } + + if (rows.length > 0) return rows; + + for (const [key, value] of Object.entries(input).slice(0, 4)) { + if (value === undefined || value === null || Array.isArray(value)) continue; + if (isRecord(value)) continue; + rows.push({ label: key, value: stringifyJson(value) }); + } + return rows; +} diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts new file mode 100644 index 00000000000..9ab00386c7b --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -0,0 +1,456 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonShellTranscriptBlock, + DaemonStatusTranscriptBlock, + DaemonTextTranscriptBlock, + DaemonToolTranscriptBlock, + DaemonTranscriptBlock, + DaemonTranscriptReducerOptions, + DaemonTranscriptState, + DaemonUiEvent, +} from './types.js'; +import { createDaemonToolPreview } from './toolPreview.js'; + +const DEFAULT_MAX_BLOCKS = 1_000; + +export function createDaemonTranscriptState( + opts: DaemonTranscriptReducerOptions = {}, +): DaemonTranscriptState { + return { + blocks: [], + blockIndexById: {}, + toolBlockByCallId: {}, + permissionBlockByRequestId: {}, + nextOrdinal: 1, + now: opts.now ?? Date.now(), + maxBlocks: opts.maxBlocks ?? DEFAULT_MAX_BLOCKS, + }; +} + +export function appendLocalUserTranscriptMessage( + state: DaemonTranscriptState, + text: string, + opts: DaemonTranscriptReducerOptions = {}, +): DaemonTranscriptState { + const next = cloneTranscriptState(state, opts); + const block = createTextBlock(next, 'user', text); + appendBlock(next, block); + next.activeUserBlockId = block.id; + next.activeAssistantBlockId = undefined; + next.activeThoughtBlockId = undefined; + return trimTranscriptState(next); +} + +export function reduceDaemonTranscriptEvents( + state: DaemonTranscriptState, + events: readonly DaemonUiEvent[], + opts: DaemonTranscriptReducerOptions = {}, +): DaemonTranscriptState { + if (events.length === 0) return state; + const next = cloneTranscriptState(state, opts); + for (const event of events) applyDaemonTranscriptEvent(next, event); + return trimTranscriptState(next); +} + +export function rebuildDaemonTranscriptBlockIndex( + blocks: readonly DaemonTranscriptBlock[], +): Record { + const blockIndexById: Record = {}; + blocks.forEach((block, index) => { + blockIndexById[block.id] = index; + }); + return blockIndexById; +} + +function applyDaemonTranscriptEvent( + next: DaemonTranscriptState, + event: DaemonUiEvent, +): void { + if (event.eventId !== undefined) { + next.lastEventId = Math.max(next.lastEventId ?? 0, event.eventId); + } + + switch (event.type) { + case 'user.text.delta': + appendTextDelta(next, 'user', 'activeUserBlockId', event.text, event); + break; + case 'assistant.text.delta': + appendTextDelta( + next, + 'assistant', + 'activeAssistantBlockId', + event.text, + event, + ); + break; + case 'assistant.done': + finishAssistant(next); + break; + case 'thought.text.delta': + appendTextDelta( + next, + 'thought', + 'activeThoughtBlockId', + event.text, + event, + ); + break; + case 'tool.update': + upsertToolBlock(next, event); + break; + case 'shell.output': + appendShellBlock(next, event); + break; + case 'permission.request': + upsertPermissionBlock(next, event); + break; + case 'permission.resolved': + resolvePermissionBlock(next, event); + break; + case 'model.changed': + appendStatusBlock( + next, + 'status', + `Model switched: ${event.modelId}`, + event, + ); + break; + case 'status': + case 'debug': + case 'error': + appendStatusBlock(next, event.type, event.text, event); + break; + default: + break; + } +} + +export function selectTranscriptBlocks( + state: DaemonTranscriptState, +): readonly DaemonTranscriptBlock[] { + return state.blocks; +} + +export function selectPendingPermissionBlocks( + state: DaemonTranscriptState, +): ReadonlyArray> { + return state.blocks.filter( + (block): block is Extract => + block.kind === 'permission' && block.resolved === undefined, + ); +} + +function appendTextDelta( + state: DaemonTranscriptState, + kind: 'user' | 'assistant' | 'thought', + activeKey: + | 'activeUserBlockId' + | 'activeAssistantBlockId' + | 'activeThoughtBlockId', + text: string, + event: DaemonUiEvent, +): void { + const existing = getWritableBlockById(state, state[activeKey]); + if (existing && existing.kind === kind) { + existing.text += text; + existing.updatedAt = state.now; + if (event.eventId !== undefined) existing.eventId = event.eventId; + if (kind === 'assistant') existing.streaming = true; + return; + } + + const block = createTextBlock(state, kind, text, event.eventId); + if (kind === 'assistant') block.streaming = true; + if (kind === 'thought') block.collapsed = true; + appendBlock(state, block); + state[activeKey] = block.id; + if (kind !== 'user') state.activeUserBlockId = undefined; + if (kind !== 'assistant') state.activeAssistantBlockId = undefined; + if (kind !== 'thought') state.activeThoughtBlockId = undefined; +} + +function finishAssistant(state: DaemonTranscriptState): void { + const existing = getWritableBlockById(state, state.activeAssistantBlockId); + if (existing?.kind === 'assistant') { + existing.streaming = false; + existing.updatedAt = state.now; + } + state.activeAssistantBlockId = undefined; +} + +function upsertToolBlock( + state: DaemonTranscriptState, + event: Extract, +): void { + const existingId = state.toolBlockByCallId[event.toolCallId]; + const existing = getWritableBlockById(state, existingId); + if (existing?.kind === 'tool') { + if (event.title !== undefined) existing.title = event.title; + if (event.status !== undefined) existing.status = event.status; + if (event.rawInput !== undefined) { + existing.preview = createDaemonToolPreview(event.rawInput, { + title: event.title, + toolName: event.toolName, + toolKind: event.toolKind, + }); + } + existing.updatedAt = state.now; + if (event.eventId !== undefined) existing.eventId = event.eventId; + if (event.details) existing.details = event.details; + if (event.rawInput !== undefined) existing.rawInput = event.rawInput; + if (event.rawOutput !== undefined) existing.rawOutput = event.rawOutput; + if (event.toolName) existing.toolName = event.toolName; + if (event.toolKind) existing.toolKind = event.toolKind; + return; + } + + const block: DaemonToolTranscriptBlock = { + id: allocateBlockId(state, 'tool'), + kind: 'tool', + toolCallId: event.toolCallId, + title: event.title ?? event.toolName ?? event.toolKind ?? 'Tool', + status: event.status ?? 'pending', + preview: createDaemonToolPreview(event.rawInput, { + title: event.title, + toolName: event.toolName, + toolKind: event.toolKind, + }), + createdAt: state.now, + updatedAt: state.now, + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.details ? { details: event.details } : {}), + ...(event.rawInput !== undefined ? { rawInput: event.rawInput } : {}), + ...(event.rawOutput !== undefined ? { rawOutput: event.rawOutput } : {}), + ...(event.toolName ? { toolName: event.toolName } : {}), + ...(event.toolKind ? { toolKind: event.toolKind } : {}), + }; + appendBlock(state, block); + state.toolBlockByCallId[event.toolCallId] = block.id; + clearActiveText(state); +} + +function appendShellBlock( + state: DaemonTranscriptState, + event: Extract, +): void { + if (!event.text) return; + const last = state.blocks[state.blocks.length - 1]; + if (last?.kind === 'shell' && last.stream === event.stream) { + const writable = getWritableBlockById(state, last.id); + if (writable?.kind === 'shell') { + writable.text += event.text; + writable.updatedAt = state.now; + if (event.eventId !== undefined) writable.eventId = event.eventId; + } + return; + } + + const block: DaemonShellTranscriptBlock = { + id: allocateBlockId(state, 'shell'), + kind: 'shell', + text: event.text, + createdAt: state.now, + updatedAt: state.now, + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.stream ? { stream: event.stream } : {}), + }; + appendBlock(state, block); + clearActiveText(state); +} + +function upsertPermissionBlock( + state: DaemonTranscriptState, + event: Extract, +): void { + const existingId = state.permissionBlockByRequestId[event.requestId]; + const existing = getWritableBlockById(state, existingId); + const preview = createDaemonToolPreview(event.toolCall, { + title: event.title, + }); + if (existing?.kind === 'permission') { + existing.title = event.title; + existing.options = event.options.map((option) => ({ ...option })); + existing.toolCall = event.toolCall; + existing.preview = preview; + existing.updatedAt = state.now; + if (event.eventId !== undefined) existing.eventId = event.eventId; + return; + } + + const block: Extract = { + id: allocateBlockId(state, 'permission'), + kind: 'permission', + requestId: event.requestId, + title: event.title, + options: event.options.map((option) => ({ ...option })), + preview, + createdAt: state.now, + updatedAt: state.now, + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + ...(event.sessionId ? { sessionId: event.sessionId } : {}), + ...(event.toolCall !== undefined ? { toolCall: event.toolCall } : {}), + }; + appendBlock(state, block); + state.permissionBlockByRequestId[event.requestId] = block.id; + clearActiveText(state); +} + +function resolvePermissionBlock( + state: DaemonTranscriptState, + event: Extract, +): void { + const existing = getWritableBlockById( + state, + state.permissionBlockByRequestId[event.requestId], + ); + if (existing?.kind === 'permission') { + existing.resolved = event.outcome; + existing.updatedAt = state.now; + if (event.eventId !== undefined) existing.eventId = event.eventId; + return; + } + const block: Extract = { + id: allocateBlockId(state, 'permission'), + kind: 'permission', + requestId: event.requestId, + title: `Permission resolved: ${event.requestId}`, + options: [], + preview: { kind: 'generic', summary: event.outcome }, + resolved: event.outcome, + createdAt: state.now, + updatedAt: state.now, + ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), + }; + appendBlock(state, block); + state.permissionBlockByRequestId[event.requestId] = block.id; + clearActiveText(state); +} + +function appendStatusBlock( + state: DaemonTranscriptState, + kind: 'status' | 'error' | 'debug', + text: string, + event?: DaemonUiEvent, +): void { + const block: DaemonStatusTranscriptBlock = { + id: allocateBlockId(state, kind), + kind, + text, + createdAt: state.now, + updatedAt: state.now, + ...(event?.eventId !== undefined ? { eventId: event.eventId } : {}), + }; + appendBlock(state, block); + clearActiveText(state); +} + +function createTextBlock( + state: DaemonTranscriptState, + kind: 'user' | 'assistant' | 'thought', + text: string, + eventId?: number, +): DaemonTextTranscriptBlock { + return { + id: allocateBlockId(state, kind), + kind, + text, + createdAt: state.now, + updatedAt: state.now, + ...(eventId !== undefined ? { eventId } : {}), + }; +} + +function cloneTranscriptState( + state: DaemonTranscriptState, + opts: DaemonTranscriptReducerOptions, +): DaemonTranscriptState { + return { + ...state, + now: opts.now ?? Date.now(), + maxBlocks: opts.maxBlocks ?? state.maxBlocks, + blocks: [...state.blocks], + blockIndexById: rebuildDaemonTranscriptBlockIndex(state.blocks), + toolBlockByCallId: { ...state.toolBlockByCallId }, + permissionBlockByRequestId: { ...state.permissionBlockByRequestId }, + }; +} + +function trimTranscriptState( + state: DaemonTranscriptState, +): DaemonTranscriptState { + if (state.blocks.length <= state.maxBlocks) return state; + const blocks = state.blocks.slice(-state.maxBlocks); + const keptIds = new Set(blocks.map((block) => block.id)); + state.blocks = blocks; + state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks); + for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) { + if (!keptIds.has(blockId)) delete state.toolBlockByCallId[toolCallId]; + } + for (const [requestId, blockId] of Object.entries( + state.permissionBlockByRequestId, + )) { + if (!keptIds.has(blockId)) + delete state.permissionBlockByRequestId[requestId]; + } + if (!keptIds.has(state.activeUserBlockId ?? '')) { + state.activeUserBlockId = undefined; + } + if (!keptIds.has(state.activeAssistantBlockId ?? '')) { + state.activeAssistantBlockId = undefined; + } + if (!keptIds.has(state.activeThoughtBlockId ?? '')) { + state.activeThoughtBlockId = undefined; + } + return state; +} + +function appendBlock( + state: DaemonTranscriptState, + block: DaemonTranscriptBlock, +): void { + state.blockIndexById[block.id] = state.blocks.length; + state.blocks.push(block); +} + +function getWritableBlockById( + state: DaemonTranscriptState, + blockId: string | undefined, +): DaemonTranscriptBlock | undefined { + if (!blockId) return undefined; + const index = state.blockIndexById[blockId]; + if (index === undefined) return undefined; + const block = state.blocks[index]; + if (!block || block.id !== blockId) return undefined; + const cloned = cloneBlockForWrite(block); + state.blocks[index] = cloned; + return cloned; +} + +function cloneBlockForWrite( + block: DaemonTranscriptBlock, +): DaemonTranscriptBlock { + if (block.kind === 'permission') { + return { + ...block, + options: block.options.map((option) => ({ ...option })), + }; + } + return { ...block }; +} + +function allocateBlockId(state: DaemonTranscriptState, prefix: string): string { + const id = `${prefix}-${state.nextOrdinal}`; + state.nextOrdinal += 1; + return id; +} + +function clearActiveText(state: DaemonTranscriptState): void { + finishAssistant(state); + state.activeUserBlockId = undefined; + state.activeThoughtBlockId = undefined; +} diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts new file mode 100644 index 00000000000..98395987380 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -0,0 +1,258 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { DaemonEvent, PermissionResponse } from '../types.js'; + +export type DaemonUiEventType = + | 'user.text.delta' + | 'assistant.text.delta' + | 'assistant.done' + | 'thought.text.delta' + | 'tool.update' + | 'shell.output' + | 'permission.request' + | 'permission.resolved' + | 'model.changed' + | 'status' + | 'error' + | 'debug'; + +export interface DaemonUiEventBase { + type: DaemonUiEventType; + eventId?: number; + originatorClientId?: string; + rawEvent?: DaemonEvent; +} + +export interface DaemonUiTextEvent extends DaemonUiEventBase { + type: 'user.text.delta' | 'assistant.text.delta' | 'thought.text.delta'; + text: string; +} + +export interface DaemonUiAssistantDoneEvent extends DaemonUiEventBase { + type: 'assistant.done'; + reason?: string; +} + +export interface DaemonUiToolUpdateEvent extends DaemonUiEventBase { + type: 'tool.update'; + toolCallId: string; + title?: string; + status?: string; + toolName?: string; + toolKind?: string; + details?: string; + rawInput?: unknown; + rawOutput?: unknown; +} + +export interface DaemonUiShellOutputEvent extends DaemonUiEventBase { + type: 'shell.output'; + text: string; + stream?: 'stdout' | 'stderr'; +} + +export interface DaemonUiPermissionOption { + optionId: string; + label: string; + description?: string; + raw: unknown; +} + +export interface DaemonUiPermissionRequestEvent extends DaemonUiEventBase { + type: 'permission.request'; + requestId: string; + sessionId?: string; + title: string; + options: DaemonUiPermissionOption[]; + toolCall?: unknown; +} + +export interface DaemonUiPermissionResolvedEvent extends DaemonUiEventBase { + type: 'permission.resolved'; + requestId: string; + outcome: string; +} + +export interface DaemonUiModelChangedEvent extends DaemonUiEventBase { + type: 'model.changed'; + modelId: string; +} + +export interface DaemonUiStatusEvent extends DaemonUiEventBase { + type: 'status' | 'debug'; + text: string; +} + +export interface DaemonUiErrorEvent extends DaemonUiEventBase { + type: 'error'; + text: string; + recoverable?: boolean; +} + +export type DaemonUiEvent = + | DaemonUiTextEvent + | DaemonUiAssistantDoneEvent + | DaemonUiToolUpdateEvent + | DaemonUiShellOutputEvent + | DaemonUiPermissionRequestEvent + | DaemonUiPermissionResolvedEvent + | DaemonUiModelChangedEvent + | DaemonUiStatusEvent + | DaemonUiErrorEvent; + +export interface NormalizeDaemonEventOptions { + /** + * Client id returned by `DaemonSessionClient`. Used only for optional + * optimistic-echo suppression; the raw stream remains unchanged. + */ + clientId?: string; + /** + * When a UI app already appended the user's own prompt optimistically, + * suppress the matching `user_message_chunk` echo from the daemon. + */ + suppressOwnUserEcho?: boolean; + /** Keep raw daemon event envelopes on each UI event for debug panels. */ + includeRawEvent?: boolean; +} + +export interface DaemonTranscriptQuestionOption { + label: string; + description?: string; + raw: unknown; +} + +export interface DaemonTranscriptQuestion { + header?: string; + question: string; + options: DaemonTranscriptQuestionOption[]; + raw: unknown; +} + +export type DaemonToolPreview = + | { + kind: 'ask_user_question'; + questions: DaemonTranscriptQuestion[]; + } + | { + kind: 'command'; + command: string; + cwd?: string; + } + | { + kind: 'key_value'; + rows: Array<{ label: string; value: string }>; + } + | { + kind: 'generic'; + summary?: string; + }; + +export type DaemonTranscriptBlockKind = + | 'user' + | 'assistant' + | 'thought' + | 'tool' + | 'shell' + | 'permission' + | 'status' + | 'error' + | 'debug'; + +export interface DaemonTranscriptBlockBase { + id: string; + kind: DaemonTranscriptBlockKind; + eventId?: number; + createdAt: number; + updatedAt: number; +} + +export interface DaemonTextTranscriptBlock extends DaemonTranscriptBlockBase { + kind: 'user' | 'assistant' | 'thought'; + text: string; + streaming?: boolean; + collapsed?: boolean; +} + +export interface DaemonToolTranscriptBlock extends DaemonTranscriptBlockBase { + kind: 'tool'; + toolCallId: string; + title: string; + status: string; + toolName?: string; + toolKind?: string; + preview: DaemonToolPreview; + details?: string; + rawInput?: unknown; + rawOutput?: unknown; +} + +export interface DaemonShellTranscriptBlock extends DaemonTranscriptBlockBase { + kind: 'shell'; + text: string; + stream?: 'stdout' | 'stderr'; +} + +export interface DaemonPermissionTranscriptBlock + extends DaemonTranscriptBlockBase { + kind: 'permission'; + requestId: string; + sessionId?: string; + title: string; + options: DaemonUiPermissionOption[]; + toolCall?: unknown; + preview: DaemonToolPreview; + resolved?: string; +} + +export interface DaemonStatusTranscriptBlock extends DaemonTranscriptBlockBase { + kind: 'status' | 'error' | 'debug'; + text: string; +} + +export type DaemonTranscriptBlock = + | DaemonTextTranscriptBlock + | DaemonToolTranscriptBlock + | DaemonShellTranscriptBlock + | DaemonPermissionTranscriptBlock + | DaemonStatusTranscriptBlock; + +export interface DaemonTranscriptState { + blocks: DaemonTranscriptBlock[]; + lastEventId?: number; + activeUserBlockId?: string; + activeAssistantBlockId?: string; + activeThoughtBlockId?: string; + blockIndexById: Record; + toolBlockByCallId: Record; + permissionBlockByRequestId: Record; + nextOrdinal: number; + now: number; + maxBlocks: number; +} + +export interface DaemonTranscriptReducerOptions { + maxBlocks?: number; + now?: number; +} + +export interface DaemonTranscriptStore { + getSnapshot(): DaemonTranscriptState; + subscribe(listener: () => void): () => void; + dispatch(event: DaemonUiEvent | DaemonUiEvent[]): void; + appendLocalUserMessage(text: string): void; + reset(seed?: Partial): void; +} + +export interface DaemonUiSessionActions { + sendPrompt(text: string): Promise; + cancel(): Promise; + setModel(modelId: string): Promise; + respondToPermission( + requestId: string, + response: PermissionResponse, + ): Promise; +} diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts new file mode 100644 index 00000000000..e413420e873 --- /dev/null +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function getString(value: unknown, key: string): string | undefined { + if (!isRecord(value)) return undefined; + const entry = value[key]; + return typeof entry === 'string' ? entry : undefined; +} + +export function getFirstString( + value: unknown, + keys: readonly string[], +): string | undefined { + if (!isRecord(value)) return undefined; + for (const key of keys) { + const entry = value[key]; + if (typeof entry === 'string' && entry.trim().length > 0) { + return entry; + } + } + return undefined; +} + +export function stringifyJson(value: unknown): string { + if (value === undefined) return ''; + if (typeof value === 'string') return value; + try { + return JSON.stringify(value, null, 2) ?? String(value); + } catch { + return String(value); + } +} + +export function getTextContent(value: unknown): string { + if (typeof value === 'string') return value; + if (!isRecord(value)) return ''; + const text = value['text']; + return typeof text === 'string' ? text : ''; +} + +const MAX_OUTPUT_TEXT_DEPTH = 64; + +export function getOutputText(value: unknown, depth = 0): string { + if (depth > MAX_OUTPUT_TEXT_DEPTH) return '[output truncated]'; + if (typeof value === 'string') return value; + if (Array.isArray(value)) { + return value + .map((entry) => getOutputText(entry, depth + 1)) + .filter(Boolean) + .join('\n'); + } + if (!isRecord(value)) return value === undefined ? '' : stringifyJson(value); + + for (const key of ['text', 'output', 'stdout', 'stderr', 'rawOutput']) { + const entry = value[key]; + if (typeof entry === 'string') return entry; + } + + const content = value['content']; + if (content !== undefined) { + const nested = getOutputText(content, depth + 1); + if (nested) return nested; + } + + return stringifyJson(value); +} + +export function sanitizeTerminalText(text: string): string { + return text + .replace(bidiControlPattern, '') + .replace(oscSequencePattern, '') + .replace(dcsSequencePattern, '') + .replace(csiSequencePattern, '') + .replace(controlCharactersPattern, ''); +} + +export function stripOscSequences(text: string): string { + return text.replace(oscSequencePattern, ''); +} + +const nul = String.fromCharCode(0x00); +const backspace = String.fromCharCode(0x08); +const verticalTab = String.fromCharCode(0x0b); +const formFeed = String.fromCharCode(0x0c); +const shiftOut = String.fromCharCode(0x0e); +const unitSeparator = String.fromCharCode(0x1f); +const deleteChar = String.fromCharCode(0x7f); +const c1End = String.fromCharCode(0x9f); +const escapeChar = String.fromCharCode(0x1b); +const bell = String.fromCharCode(0x07); + +const controlCharactersPattern = new RegExp( + `[${nul}-${backspace}${verticalTab}${formFeed}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`, + 'g', +); + +const oscSequencePattern = new RegExp( + `${escapeChar}\\][\\s\\S]*?(?:${bell}|${escapeChar}\\\\)`, + 'g', +); +const dcsSequencePattern = new RegExp( + `${escapeChar}P[\\s\\S]*?${escapeChar}\\\\`, + 'g', +); +const csiSequencePattern = new RegExp(`${escapeChar}\\[[0-?]*[ -/]*[@-~]`, 'g'); +const bidiControlPattern = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts new file mode 100644 index 00000000000..54a491ead63 --- /dev/null +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -0,0 +1,652 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import { + appendLocalUserTranscriptMessage, + createDaemonToolPreview, + createDaemonTranscriptState, + createDaemonTranscriptStore, + daemonUiEventToTerminalText, + getOutputText, + normalizeDaemonEvent, + reduceDaemonTranscriptEvents, + sanitizeTerminalText, + selectPendingPermissionBlocks, +} from '../../src/daemon/ui/index.js'; + +describe('daemon UI normalizer and transcript reducer', () => { + it('normalizes daemon stream chunks and merges assistant transcript blocks', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = appendLocalUserTranscriptMessage(state, 'hello', { now: 2 }); + + const first = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hi ' }, + }, + }, + }); + const second = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'there' }, + }, + }, + }); + + state = reduceDaemonTranscriptEvents(state, [...first, ...second], { + now: 3, + }); + + expect(state.lastEventId).toBe(2); + expect(state.blocks).toMatchObject([ + { kind: 'user', text: 'hello' }, + { kind: 'assistant', text: 'hi there', streaming: true }, + ]); + }); + + it('marks assistant streaming complete only on explicit done events', () => { + const events = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'done' }, + _meta: { usage: { outputTokens: 1 } }, + }, + }, + }); + + expect(events).toMatchObject([{ type: 'assistant.text.delta' }]); + + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + events, + { now: 2 }, + ); + + expect(state.activeAssistantBlockId).toBe('assistant-1'); + expect(state.blocks).toMatchObject([ + { kind: 'assistant', text: 'done', streaming: true }, + ]); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.done', reason: 'stop' }], + { now: 3 }, + ); + + expect(state.activeAssistantBlockId).toBeUndefined(); + expect(state.blocks).toMatchObject([ + { kind: 'assistant', text: 'done', streaming: false }, + ]); + }); + + it('surfaces missing toolCallId as a recoverable error', () => { + const events = normalizeDaemonEvent({ + id: 21, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + title: 'Run command', + status: 'running', + rawInput: { command: 'npm test' }, + }, + }, + }); + + expect(events).toMatchObject([ + { + type: 'error', + recoverable: true, + text: expect.stringContaining('missing toolCallId') as string, + }, + ]); + }); + + it('surfaces session_closed as a visible terminal error', () => { + const events = normalizeDaemonEvent({ + id: 23, + v: 1, + type: 'session_closed', + data: { reason: 'idle timeout' }, + }); + + expect(events).toMatchObject([ + { + type: 'error', + recoverable: false, + text: 'Session closed: idle timeout', + }, + ]); + }); + + it('suppresses only matching own user echoes', () => { + const event = { + id: 22, + v: 1, + type: 'session_update', + originatorClientId: 'client-a', + data: { + update: { + sessionUpdate: 'user_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + } as const; + + expect( + normalizeDaemonEvent(event, { + clientId: 'client-a', + suppressOwnUserEcho: true, + }), + ).toEqual([]); + expect( + normalizeDaemonEvent(event, { + clientId: 'client-b', + suppressOwnUserEcho: true, + }), + ).toMatchObject([{ type: 'user.text.delta', text: 'hello' }]); + expect( + normalizeDaemonEvent(event, { + clientId: 'client-a', + suppressOwnUserEcho: false, + }), + ).toMatchObject([{ type: 'user.text.delta', text: 'hello' }]); + }); + + it('projects AskUserQuestion into a semantic tool preview', () => { + const events = normalizeDaemonEvent({ + id: 3, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'ask-1', + name: 'AskUserQuestion', + title: 'Ask user 1 question', + status: 'completed', + rawInput: { + questions: [ + { + header: '城市', + question: '你想查询哪个城市的天气?', + options: [ + { label: '北京', description: '查询北京今日天气' }, + { label: '上海', description: '查询上海今日天气' }, + ], + }, + ], + }, + }, + }, + }); + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 10 }), + events, + { now: 10 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + toolCallId: 'ask-1', + preview: { + kind: 'ask_user_question', + questions: [ + { + header: '城市', + question: '你想查询哪个城市的天气?', + options: [ + { label: '北京', description: '查询北京今日天气' }, + { label: '上海', description: '查询上海今日天气' }, + ], + }, + ], + }, + }, + ]); + }); + + it('tracks pending and resolved permissions', () => { + let state = createDaemonTranscriptState({ now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 4, + v: 1, + type: 'permission_request', + data: { + requestId: 'perm-1', + sessionId: 'session-1', + toolCall: { name: 'Bash', command: 'npm test' }, + options: [{ optionId: 'allow', label: 'Allow' }], + }, + }), + { now: 2 }, + ); + + expect(selectPendingPermissionBlocks(state)).toHaveLength(1); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 5, + v: 1, + type: 'permission_resolved', + data: { + requestId: 'perm-1', + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + }), + { now: 3 }, + ); + + expect(selectPendingPermissionBlocks(state)).toHaveLength(0); + expect(state.blocks).toMatchObject([ + { + kind: 'permission', + requestId: 'perm-1', + resolved: 'selected:allow', + }, + ]); + }); + + it('upserts tool blocks and trims stale indexes', () => { + let state = createDaemonTranscriptState({ maxBlocks: 1, now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 6, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-1', + title: 'Run command', + status: 'running', + rawInput: { command: 'npm test' }, + }, + }, + }), + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 7, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + title: 'Run command', + status: 'completed', + rawOutput: 'ok', + }, + }, + }), + { now: 3 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + toolCallId: 'tool-1', + status: 'completed', + rawOutput: 'ok', + }, + ]); + expect(state.blockIndexById).toEqual({ 'tool-1': 0 }); + + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'trim tool' }], + { now: 4 }, + ); + + expect(state.blocks).toMatchObject([{ kind: 'status', text: 'trim tool' }]); + expect(state.blockIndexById).toEqual({ 'status-2': 0 }); + expect(state.toolBlockByCallId).toEqual({}); + }); + + it('preserves rich tool preview and status on output-only updates', () => { + let state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: 41, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-preserve', + title: 'Run command', + status: 'running', + rawInput: { command: 'npm test' }, + }, + }, + }), + { now: 2 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 42, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-preserve', + title: 'Run command', + rawOutput: 'ok', + }, + }, + }), + { now: 3 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + status: 'running', + preview: { kind: 'command', command: 'npm test' }, + rawOutput: 'ok', + }, + ]); + }); + + it('caps verbose tool details from raw input and output', () => { + const [inputEvent] = normalizeDaemonEvent({ + id: 44, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'large-input', + title: 'Large input', + rawInput: { text: 'x'.repeat(5000) }, + }, + }, + }); + const [outputEvent] = normalizeDaemonEvent({ + id: 45, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'large-output', + title: 'Large output', + rawOutput: 'y'.repeat(5000), + }, + }, + }); + + expect(inputEvent).toMatchObject({ + type: 'tool.update', + details: expect.stringContaining('[truncated]') as string, + }); + expect(outputEvent).toMatchObject({ + type: 'tool.update', + details: expect.stringContaining('[truncated]') as string, + }); + expect( + inputEvent && 'details' in inputEvent ? inputEvent.details?.length : 0, + ).toBeLessThan(4200); + expect( + outputEvent && 'details' in outputEvent ? outputEvent.details?.length : 0, + ).toBeLessThan(4200); + }); + + it('marks active assistant block complete when a tool interrupts the stream', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'assistant.text.delta', text: 'Before tool' }, + { + type: 'tool.update', + toolCallId: 'tool-after-text', + title: 'Run command', + status: 'running', + }, + { type: 'assistant.done', reason: 'stop' }, + ], + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'assistant', + text: 'Before tool', + streaming: false, + }, + { + kind: 'tool', + toolCallId: 'tool-after-text', + }, + ]); + expect(state.activeAssistantBlockId).toBeUndefined(); + }); + + it('splits thought blocks across assistant text boundaries', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'thought.text.delta', text: 'first thought' }, + { type: 'assistant.text.delta', text: 'answer' }, + { type: 'thought.text.delta', text: 'second thought' }, + ], + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { kind: 'thought', text: 'first thought' }, + { kind: 'assistant', text: 'answer' }, + { kind: 'thought', text: 'second thought' }, + ]); + }); + + it('redacts raw daemon payloads from fallback error text', () => { + const [event] = normalizeDaemonEvent({ + id: 43, + v: 1, + type: 'session_died', + data: { token: 'secret-token' }, + }); + + expect(event).toMatchObject({ + type: 'error', + text: 'Session died (no details available)', + }); + expect(event && 'text' in event ? event.text : '').not.toContain( + 'secret-token', + ); + }); + + it('caps recursive output extraction depth', () => { + let nested: unknown = 'leaf'; + for (let i = 0; i < 70; i += 1) { + nested = { content: nested }; + } + + expect(getOutputText(nested)).toBe('[output truncated]'); + }); + + it('keeps orphan permission resolutions visible after request trimming', () => { + let state = createDaemonTranscriptState({ maxBlocks: 1, now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 31, + v: 1, + type: 'permission_request', + data: { + requestId: 'perm-trimmed', + toolCall: { name: 'Bash', command: 'npm test' }, + options: [{ optionId: 'allow', label: 'Allow' }], + }, + }), + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'trim permission' }], + { now: 3 }, + ); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 32, + v: 1, + type: 'permission_resolved', + data: { + requestId: 'perm-trimmed', + outcome: { outcome: 'selected', optionId: 'allow' }, + }, + }), + { now: 4 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'permission', + requestId: 'perm-trimmed', + resolved: 'selected:allow', + }, + ]); + }); + + it('preserves shell output streams while normalizing events', () => { + const [stdout] = normalizeDaemonEvent({ + id: 8, + v: 1, + type: 'shell_output', + data: { stream: 'stdout', text: 'out' }, + }); + const [stderr] = normalizeDaemonEvent({ + id: 9, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'shell_output', + stream: 'stderr', + text: 'err', + }, + }, + }); + + expect(stdout).toMatchObject({ type: 'shell.output', stream: 'stdout' }); + expect(stderr).toMatchObject({ type: 'shell.output', stream: 'stderr' }); + }); + + it('merges consecutive same-stream shell output blocks only', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'shell.output', text: 'out-1', stream: 'stdout' }, + { type: 'shell.output', text: 'out-2', stream: 'stdout' }, + { type: 'shell.output', text: 'err-1', stream: 'stderr' }, + ], + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { kind: 'shell', text: 'out-1out-2', stream: 'stdout' }, + { kind: 'shell', text: 'err-1', stream: 'stderr' }, + ]); + }); + + it('provides a batched framework-free external store', async () => { + const store = createDaemonTranscriptStore(); + let calls = 0; + const unsubscribe = store.subscribe(() => { + calls += 1; + }); + + store.dispatch({ + type: 'status', + text: 'ready', + }); + store.dispatch({ + type: 'status', + text: 'still ready', + }); + + expect(calls).toBe(0); + await Promise.resolve(); + + expect(calls).toBe(1); + expect(store.getSnapshot().blocks).toMatchObject([ + { kind: 'status', text: 'ready' }, + { kind: 'status', text: 'still ready' }, + ]); + + unsubscribe(); + store.dispatch({ type: 'status', text: 'ignored listener' }); + await Promise.resolve(); + expect(calls).toBe(1); + }); + + it('renders UI events to sanitized terminal text', () => { + const output = daemonUiEventToTerminalText({ + type: 'shell.output', + text: '\u001b]0;bad\u0007ok\x00', + }); + + expect(output).toContain('shell'); + expect(output).toContain('ok'); + expect(output).not.toContain('bad'); + expect(output).not.toContain('\x00'); + }); + + it('strips terminal control and bidi spoofing sequences', () => { + const output = sanitizeTerminalText( + '\u202etxt.exe\u001b[31mred\u001bPhidden\u001b\\ok', + ); + + expect(output).toContain('txt.exe'); + expect(output).toContain('red'); + expect(output).toContain('ok'); + expect(output).not.toContain('\u202e'); + expect(output).not.toContain('\u001b['); + expect(output).not.toContain('hidden'); + }); + + it('caps nested tool preview traversal depth', () => { + let nested: unknown = { command: 'npm test' }; + for (let i = 0; i < 20; i += 1) { + nested = { rawInput: nested }; + } + + expect(createDaemonToolPreview(nested)).toMatchObject({ + kind: 'generic', + }); + }); +}); diff --git a/packages/webui/package.json b/packages/webui/package.json index e7be195a509..3cbe6c75d7b 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -44,6 +44,7 @@ "react-dom": "^18.0.0 || ^19.0.0" }, "dependencies": { + "@qwen-code/sdk": "~0.1.7", "markdown-it": "^14.1.0" }, "devDependencies": { diff --git a/packages/webui/src/components/toolcalls/GenericToolCall.tsx b/packages/webui/src/components/toolcalls/GenericToolCall.tsx index 5e92ee8653b..b73820c754b 100644 --- a/packages/webui/src/components/toolcalls/GenericToolCall.tsx +++ b/packages/webui/src/components/toolcalls/GenericToolCall.tsx @@ -14,6 +14,7 @@ import { LocationsList, safeTitle, groupContent, + mapToolStatusToContainerStatus, } from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; import { getToolDisplayLabel } from './labelUtils.js'; @@ -107,10 +108,7 @@ export const GenericToolCall: FC = ({ } // Short output - compact format - const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = - toolCall.status === 'in_progress' || toolCall.status === 'pending' - ? 'loading' - : 'success'; + const statusFlag = mapToolStatusToContainerStatus(toolCall.status); return ( = ({ // Success with files: show operation + file list in compact format if (locations && locations.length > 0) { - const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = - toolCall.status === 'in_progress' || toolCall.status === 'pending' - ? 'loading' - : 'success'; + const statusFlag = mapToolStatusToContainerStatus(toolCall.status); return ( = ({ // No output - show just the operation if (operationText) { - const statusFlag: 'success' | 'error' | 'warning' | 'loading' | 'default' = - toolCall.status === 'in_progress' || toolCall.status === 'pending' - ? 'loading' - : 'success'; + const statusFlag = mapToolStatusToContainerStatus(toolCall.status); return ( = ({ | 'default' = errors.length > 0 || (variant === 'execute' && toolCall.status === 'failed') ? 'error' - : toolCall.status === 'in_progress' || toolCall.status === 'pending' - ? 'loading' - : 'success'; + : mapToolStatusToContainerStatus(toolCall.status); // Error case if (errors.length > 0) { diff --git a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx index a1986915441..17c47e7f8ae 100644 --- a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx @@ -12,6 +12,7 @@ import { ToolCallCard, ToolCallRow, groupContent, + mapToolStatusToContainerStatus, } from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; @@ -65,10 +66,7 @@ export const ThinkToolCall: FC = ({ } // Short thoughts - compact format - const status = - toolCall.status === 'pending' || toolCall.status === 'in_progress' - ? 'loading' - : 'default'; + const status = mapToolStatusToContainerStatus(toolCall.status); return ( { * Map a tool call status to a ToolCallContainer status (bullet color) * - pending/in_progress -> loading * - completed -> success + * - cancelled -> warning * - failed -> error * - default fallback */ @@ -276,6 +277,8 @@ export const mapToolStatusToContainerStatus = ( return 'loading'; case 'failed': return 'error'; + case 'cancelled': + return 'warning'; case 'completed': return 'success'; default: diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx new file mode 100644 index 00000000000..d3ad2eef458 --- /dev/null +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -0,0 +1,419 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createContext, + useContext, + useEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, + type ReactNode, +} from 'react'; +import { + DaemonClient, + DaemonHttpError, + DaemonSessionClient, + createDaemonTranscriptStore, + normalizeDaemonEvent, + selectPendingPermissionBlocks, + type CreateSessionRequest, + type DaemonTranscriptBlock, + type DaemonTranscriptState, + type DaemonTranscriptStore, + type DaemonUiSessionActions, + type PermissionResponse, + type PromptResult, + type SetModelResult, +} from '@qwen-code/sdk/daemon'; + +export type DaemonConnectionStatus = + | 'idle' + | 'connecting' + | 'connected' + | 'disconnected' + | 'error'; + +export interface DaemonConnectionState { + status: DaemonConnectionStatus; + sessionId?: string; + workspaceCwd?: string; + error?: string; +} + +export interface DaemonSessionProviderProps { + baseUrl: string; + token?: string; + workspaceCwd?: string; + createSessionRequest?: Omit; + maxQueued?: number; + suppressOwnUserEcho?: boolean; + includeRawEvent?: boolean; + autoConnect?: boolean; + autoReconnect?: boolean; + reconnectDelayMs?: number; + maxReconnectDelayMs?: number; + children: ReactNode; +} + +export interface DaemonSessionContextValue { + store: DaemonTranscriptStore; + connection: DaemonConnectionState; + actions: DaemonUiSessionActions; +} + +const DaemonStoreContext = createContext( + undefined, +); +const DaemonConnectionContext = createContext< + DaemonConnectionState | undefined +>(undefined); +const DaemonActionsContext = createContext( + undefined, +); + +export function DaemonSessionProvider({ + baseUrl, + token, + workspaceCwd, + createSessionRequest, + maxQueued = 1024, + suppressOwnUserEcho = true, + includeRawEvent = false, + autoConnect = true, + autoReconnect = true, + reconnectDelayMs = 1_000, + maxReconnectDelayMs = 10_000, + children, +}: DaemonSessionProviderProps) { + const store = useMemo(() => createDaemonTranscriptStore(), []); + const sessionRef = useRef(undefined); + const lastSessionIdRef = useRef(undefined); + const promptAbortRef = useRef(undefined); + const promptBusyRef = useRef(false); + const modelServiceId = createSessionRequest?.modelServiceId; + const sessionScope = createSessionRequest?.sessionScope; + const [connection, setConnection] = useState({ + status: autoConnect ? 'connecting' : 'idle', + }); + + useEffect(() => { + if (!autoConnect) return undefined; + const abort = new AbortController(); + let disposed = false; + + const run = async () => { + const client = new DaemonClient({ baseUrl, token }); + let session: DaemonSessionClient | undefined; + let reconnectAttempt = 0; + + while (!disposed && !abort.signal.aborted) { + try { + if (!session) { + setConnection({ status: 'connecting' }); + const caps = await client.capabilities(); + const nextSession = await DaemonSessionClient.createOrAttach( + client, + { + ...(modelServiceId !== undefined ? { modelServiceId } : {}), + ...(sessionScope !== undefined ? { sessionScope } : {}), + workspaceCwd: workspaceCwd ?? caps.workspaceCwd, + }, + ); + const previousSessionId = lastSessionIdRef.current; + if ( + previousSessionId !== undefined && + nextSession.sessionId !== previousSessionId + ) { + store.reset(); + } + session = nextSession; + lastSessionIdRef.current = session.sessionId; + sessionRef.current = session; + } + + setConnection({ + status: 'connected', + sessionId: session.sessionId, + workspaceCwd: session.workspaceCwd, + }); + + let sawEvent = false; + for await (const event of session.events({ + signal: abort.signal, + maxQueued, + })) { + if (!sawEvent) { + sawEvent = true; + reconnectAttempt = 0; + } + try { + const uiEvents = normalizeDaemonEvent(event, { + clientId: session.clientId, + suppressOwnUserEcho, + includeRawEvent, + }); + store.dispatch(uiEvents); + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + store.dispatch({ + type: 'error', + text: `Skipped malformed daemon event: ${message}`, + recoverable: true, + }); + } + } + if (!disposed && !abort.signal.aborted) { + session = undefined; + sessionRef.current = undefined; + } + } catch (error) { + if (disposed || abort.signal.aborted) return; + const message = + error instanceof Error ? error.message : String(error); + store.dispatch({ type: 'error', text: message, recoverable: true }); + const sessionMissing = + error instanceof DaemonHttpError && error.status === 404; + if (sessionMissing) { + session = undefined; + sessionRef.current = undefined; + } + if (!autoReconnect) { + sessionRef.current = undefined; + setConnection({ + status: 'error', + ...(session + ? { + sessionId: session.sessionId, + workspaceCwd: session.workspaceCwd, + } + : {}), + error: message, + }); + return; + } + setConnection({ + status: 'disconnected', + ...(session + ? { + sessionId: session.sessionId, + workspaceCwd: session.workspaceCwd, + } + : {}), + error: message, + }); + } + + if (!autoReconnect) { + sessionRef.current = undefined; + setConnection((current) => ({ + ...current, + status: 'disconnected', + })); + return; + } + + reconnectAttempt += 1; + const delayMs = getReconnectDelayMs( + reconnectAttempt, + reconnectDelayMs, + maxReconnectDelayMs, + ); + setConnection((current) => ({ + ...current, + status: 'disconnected', + error: `Reconnecting in ${delayMs}ms`, + })); + await delay(delayMs, abort.signal); + } + }; + + void run(); + return () => { + disposed = true; + abort.abort(); + promptAbortRef.current?.abort(); + promptAbortRef.current = undefined; + promptBusyRef.current = false; + sessionRef.current = undefined; + }; + }, [ + autoConnect, + autoReconnect, + baseUrl, + token, + workspaceCwd, + modelServiceId, + sessionScope, + maxQueued, + suppressOwnUserEcho, + includeRawEvent, + reconnectDelayMs, + maxReconnectDelayMs, + store, + ]); + + const actions = useMemo( + () => ({ + async sendPrompt(text: string): Promise { + const session = requireSession(sessionRef.current); + if (promptBusyRef.current) { + throw new Error('A prompt is already in progress'); + } + promptBusyRef.current = true; + const promptAbort = new AbortController(); + promptAbortRef.current = promptAbort; + store.appendLocalUserMessage(text); + try { + const result = await session.prompt( + { + prompt: [{ type: 'text', text }], + }, + promptAbort.signal, + ); + store.dispatch({ type: 'assistant.done', reason: result.stopReason }); + return result; + } catch (error) { + const message = + error instanceof Error ? error.message : String(error); + store.dispatch({ + type: 'error', + text: `Prompt failed: ${message}`, + recoverable: true, + }); + throw error; + } finally { + if (promptAbortRef.current === promptAbort) { + promptAbortRef.current = undefined; + } + promptBusyRef.current = false; + } + }, + async cancel(): Promise { + await requireSession(sessionRef.current).cancel(); + }, + async setModel(modelId: string): Promise { + return await requireSession(sessionRef.current).setModel(modelId); + }, + async respondToPermission( + requestId: string, + response: PermissionResponse, + ): Promise { + return await requireSession( + sessionRef.current, + ).respondToSessionPermission(requestId, response); + }, + }), + [store], + ); + + return ( + + + + {children} + + + + ); +} + +export function useDaemonSession(): DaemonSessionContextValue { + return { + store: useDaemonTranscriptStore(), + connection: useDaemonConnection(), + actions: useDaemonActions(), + }; +} + +export function useDaemonTranscriptStore(): DaemonTranscriptStore { + const store = useContext(DaemonStoreContext); + if (!store) { + throw new Error( + 'useDaemonTranscriptStore must be used within DaemonSessionProvider', + ); + } + return store; +} + +export function useDaemonTranscriptState(): DaemonTranscriptState { + const store = useDaemonTranscriptStore(); + return useSyncExternalStore( + store.subscribe, + store.getSnapshot, + store.getSnapshot, + ); +} + +export function useDaemonTranscriptBlocks(): readonly DaemonTranscriptBlock[] { + return useDaemonTranscriptState().blocks; +} + +export function useDaemonPendingPermissions() { + return selectPendingPermissionBlocks(useDaemonTranscriptState()); +} + +export function useDaemonActions(): DaemonUiSessionActions { + const actions = useContext(DaemonActionsContext); + if (!actions) { + throw new Error( + 'useDaemonActions must be used within DaemonSessionProvider', + ); + } + return actions; +} + +export function useDaemonConnection(): DaemonConnectionState { + const connection = useContext(DaemonConnectionContext); + if (!connection) { + throw new Error( + 'useDaemonConnection must be used within DaemonSessionProvider', + ); + } + return connection; +} + +function requireSession( + session: DaemonSessionClient | undefined, +): DaemonSessionClient { + if (!session) { + throw new Error('Daemon session is not connected'); + } + return session; +} + +function getReconnectDelayMs( + attempt: number, + reconnectDelayMs: number, + maxReconnectDelayMs: number, +): number { + const base = + Number.isFinite(reconnectDelayMs) && reconnectDelayMs > 0 + ? reconnectDelayMs + : 1_000; + const max = + Number.isFinite(maxReconnectDelayMs) && maxReconnectDelayMs > 0 + ? Math.max(base, maxReconnectDelayMs) + : base; + const exponential = base * 2 ** Math.max(0, attempt - 1); + return Math.min(exponential, max); +} + +function delay(delayMs: number, signal: AbortSignal): Promise { + if (delayMs <= 0) return Promise.resolve(); + if (signal.aborted) return Promise.resolve(); + return new Promise((resolve) => { + const timer = setTimeout(finish, delayMs); + function finish() { + clearTimeout(timer); + signal.removeEventListener('abort', finish); + resolve(); + } + signal.addEventListener('abort', finish, { once: true }); + }); +} diff --git a/packages/webui/src/daemon/index.ts b/packages/webui/src/daemon/index.ts new file mode 100644 index 00000000000..58c261d83d0 --- /dev/null +++ b/packages/webui/src/daemon/index.ts @@ -0,0 +1,21 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + DaemonSessionProvider, + useDaemonActions, + useDaemonConnection, + useDaemonPendingPermissions, + useDaemonSession, + useDaemonTranscriptBlocks, + useDaemonTranscriptState, + useDaemonTranscriptStore, + type DaemonConnectionState, + type DaemonConnectionStatus, + type DaemonSessionContextValue, + type DaemonSessionProviderProps, +} from './DaemonSessionProvider.js'; +export { daemonTranscriptToUnifiedMessages } from './transcriptAdapter.js'; diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts new file mode 100644 index 00000000000..ed8bfac8056 --- /dev/null +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -0,0 +1,167 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from 'vitest'; +import type { DaemonTranscriptBlock } from '@qwen-code/sdk/daemon'; +import { daemonTranscriptToUnifiedMessages } from './transcriptAdapter.js'; + +describe('daemonTranscriptToUnifiedMessages', () => { + it('keeps system errors separate from assistant messages', () => { + const [message] = daemonTranscriptToUnifiedMessages([ + { + id: 'error-1', + kind: 'error', + text: 'SSE stream error', + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(message).toMatchObject({ + type: 'tool_call', + toolCall: { + kind: 'system_error', + status: 'failed', + rawOutput: 'SSE stream error', + }, + }); + }); + + it('preserves cancelled and unknown daemon tool statuses', () => { + const messages = daemonTranscriptToUnifiedMessages([ + createToolBlock('cancelled-tool', 'cancelled'), + createToolBlock('future-tool', 'waiting_for_input'), + ]); + + expect(messages.map((message) => message.toolCall?.status)).toEqual([ + 'cancelled', + 'in_progress', + ]); + }); + + it('maps permission resolution outcomes to user-visible statuses', () => { + const messages = daemonTranscriptToUnifiedMessages([ + createPermissionBlock('pending-permission'), + createPermissionBlock('allowed-permission', 'selected:allow'), + createPermissionBlock('cancelled-permission', 'cancelled'), + createPermissionBlock('denied-permission', 'denied'), + ]); + + expect(messages.map((message) => message.toolCall?.status)).toEqual([ + 'pending', + 'completed', + 'cancelled', + 'failed', + ]); + }); + + it('sanitizes daemon-sourced text before passing it to React messages', () => { + const messages = daemonTranscriptToUnifiedMessages([ + { + id: 'assistant-1', + kind: 'assistant', + text: '\u202etxt.exe\u001b[31mred\x00', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'tool-1', + kind: 'tool', + toolCallId: 'tool-1', + title: '\u202eRun', + status: 'completed', + preview: { kind: 'generic' }, + rawInput: { command: '\u202enpm test' }, + rawOutput: '\u001b]0;bad\u0007ok', + createdAt: 2, + updatedAt: 2, + }, + ]); + + expect(messages[0]?.content).toBe('txt.exered'); + expect(messages[1]?.toolCall).toMatchObject({ + title: 'Run', + rawInput: { command: 'npm test' }, + rawOutput: 'ok', + }); + }); + + it('computes grouping after filtering debug blocks and renders status', () => { + const messages = daemonTranscriptToUnifiedMessages([ + { + id: 'user-1', + kind: 'user', + text: 'hi', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'debug-1', + kind: 'debug', + text: 'internal', + createdAt: 2, + updatedAt: 2, + }, + { + id: 'status-1', + kind: 'status', + text: 'connecting', + createdAt: 3, + updatedAt: 3, + }, + { + id: 'assistant-1', + kind: 'assistant', + text: 'hello', + createdAt: 4, + updatedAt: 4, + }, + ]); + + expect(messages).toMatchObject([ + { id: 'user-1', isFirst: true, isLast: false }, + { + id: 'status-1', + type: 'tool_call', + toolCall: { kind: 'status', rawOutput: 'connecting' }, + }, + { id: 'assistant-1', isFirst: false, isLast: true }, + ]); + }); +}); + +function createToolBlock( + toolCallId: string, + status: string, +): Extract { + return { + id: toolCallId, + kind: 'tool', + toolCallId, + title: 'Tool', + status, + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + }; +} + +function createPermissionBlock( + requestId: string, + resolved?: string, +): Extract { + return { + id: requestId, + kind: 'permission', + requestId, + title: 'Permission', + options: [], + preview: { kind: 'generic' }, + createdAt: 1, + updatedAt: 1, + ...(resolved !== undefined ? { resolved } : {}), + }; +} diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts new file mode 100644 index 00000000000..98f6405db1a --- /dev/null +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -0,0 +1,277 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + DaemonTranscriptBlock, + DaemonToolTranscriptBlock, +} from '@qwen-code/sdk/daemon'; +import type { UnifiedMessage } from '../adapters/types.js'; +import type { + ToolCallData, + ToolCallStatus, +} from '../components/toolcalls/shared/index.js'; + +export function daemonTranscriptToUnifiedMessages( + blocks: readonly DaemonTranscriptBlock[], +): UnifiedMessage[] { + const visibleBlocks = blocks.filter((block) => block.kind !== 'debug'); + return visibleBlocks.flatMap((block, index, arr): UnifiedMessage[] => { + const prev = arr[index - 1]; + const next = arr[index + 1]; + const isFirst = !prev || prev.kind === 'user'; + const isLast = !next || next.kind === 'user'; + const timestamp = block.createdAt; + + switch (block.kind) { + case 'user': + return [ + { + id: block.id, + type: 'user', + timestamp, + content: sanitizeDisplayText(block.text), + isFirst, + isLast, + }, + ]; + case 'assistant': + return [ + { + id: block.id, + type: 'assistant', + timestamp, + content: sanitizeDisplayText(block.text), + isFirst, + isLast, + }, + ]; + case 'thought': + return [ + { + id: block.id, + type: 'thinking', + timestamp, + content: sanitizeDisplayText(block.text), + isFirst, + isLast, + }, + ]; + case 'tool': + return [ + { + id: block.id, + type: 'tool_call', + timestamp, + toolCall: daemonToolBlockToToolCallData(block), + isFirst, + isLast, + }, + ]; + case 'permission': + return [ + { + id: block.id, + type: 'tool_call', + timestamp, + toolCall: { + toolCallId: block.requestId, + kind: 'permission', + title: sanitizeDisplayText(block.title), + status: normalizePermissionStatus(block.resolved), + rawInput: sanitizeDaemonValue(block.toolCall) as + | object + | undefined, + }, + isFirst, + isLast, + }, + ]; + case 'shell': + return [ + { + id: block.id, + type: 'tool_call', + timestamp, + toolCall: { + toolCallId: block.id, + kind: 'bash', + title: 'Shell output', + status: 'completed', + rawOutput: sanitizeDisplayText(block.text), + }, + isFirst, + isLast, + }, + ]; + case 'error': + return [ + { + id: block.id, + type: 'tool_call', + timestamp, + toolCall: { + toolCallId: block.id, + kind: 'system_error', + title: 'System error', + status: 'failed', + rawOutput: sanitizeDisplayText(block.text), + content: [ + { + type: 'content', + content: { + type: 'error', + text: sanitizeDisplayText(block.text), + error: sanitizeDisplayText(block.text), + }, + }, + ], + }, + isFirst, + isLast, + }, + ]; + case 'status': + return [ + { + id: block.id, + type: 'tool_call', + timestamp, + toolCall: { + toolCallId: block.id, + kind: 'status', + title: 'Status', + status: 'completed', + rawOutput: sanitizeDisplayText(block.text), + }, + isFirst, + isLast, + }, + ]; + default: + return []; + } + }); +} + +function daemonToolBlockToToolCallData( + block: DaemonToolTranscriptBlock, +): ToolCallData { + return { + toolCallId: block.toolCallId, + kind: block.toolKind ?? block.toolName ?? 'tool', + title: sanitizeDisplayText(block.title), + status: normalizeToolStatus(block.status), + rawInput: sanitizeDaemonValue(block.rawInput) as + | object + | string + | undefined, + rawOutput: sanitizeDaemonValue(block.rawOutput), + }; +} + +function normalizeToolStatus(status: string): ToolCallStatus { + switch (status) { + case 'pending': + case 'confirming': + return 'pending'; + case 'in_progress': + case 'running': + return 'in_progress'; + case 'completed': + case 'success': + return 'completed'; + case 'canceled': + case 'cancelled': + return 'cancelled'; + case 'failed': + case 'error': + return 'failed'; + default: + return 'in_progress'; + } +} + +function normalizePermissionStatus( + resolved: string | undefined, +): ToolCallStatus { + if (!resolved) return 'pending'; + const normalized = resolved.toLowerCase(); + if ( + normalized.includes('cancel') || + normalized.includes('abort') || + normalized.includes('dismiss') + ) { + return 'cancelled'; + } + if ( + normalized.includes('deny') || + normalized.includes('denied') || + normalized.includes('reject') || + normalized.includes('blocked') || + normalized.includes('error') || + normalized.includes('fail') + ) { + return 'failed'; + } + return 'completed'; +} + +function sanitizeDaemonValue(value: unknown, depth = 0): unknown { + if (typeof value === 'string') return sanitizeDisplayText(value); + if (depth > 16) return value; + if (Array.isArray(value)) { + return value.map((entry) => sanitizeDaemonValue(entry, depth + 1)); + } + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + sanitizeDaemonValue(entry, depth + 1), + ]), + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function sanitizeDisplayText(text: string): string { + return text + .replace(BIDI_CONTROL_PATTERN, '') + .replace(OSC_SEQUENCE_PATTERN, '') + .replace(DCS_SEQUENCE_PATTERN, '') + .replace(CSI_SEQUENCE_PATTERN, '') + .replace(CONTROL_CHARACTERS_PATTERN, ''); +} + +const nul = String.fromCharCode(0x00); +const backspace = String.fromCharCode(0x08); +const verticalTab = String.fromCharCode(0x0b); +const formFeed = String.fromCharCode(0x0c); +const shiftOut = String.fromCharCode(0x0e); +const unitSeparator = String.fromCharCode(0x1f); +const deleteChar = String.fromCharCode(0x7f); +const c1End = String.fromCharCode(0x9f); +const escapeChar = String.fromCharCode(0x1b); +const bell = String.fromCharCode(0x07); + +const CONTROL_CHARACTERS_PATTERN = new RegExp( + `[${nul}-${backspace}${verticalTab}${formFeed}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`, + 'g', +); +const OSC_SEQUENCE_PATTERN = new RegExp( + `${escapeChar}\\][\\s\\S]*?(?:${bell}|${escapeChar}\\\\)`, + 'g', +); +const DCS_SEQUENCE_PATTERN = new RegExp( + `${escapeChar}P[\\s\\S]*?${escapeChar}\\\\`, + 'g', +); +const CSI_SEQUENCE_PATTERN = new RegExp( + `${escapeChar}\\[[0-?]*[ -/]*[@-~]`, + 'g', +); +const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts index 567d5970729..7538c635b0d 100644 --- a/packages/webui/src/index.ts +++ b/packages/webui/src/index.ts @@ -14,6 +14,25 @@ import './styles/components.css'; // Shared UI Components Export // Export all shared components from this package +// Daemon UI bindings +export { + DaemonSessionProvider, + daemonTranscriptToUnifiedMessages, + useDaemonActions, + useDaemonConnection, + useDaemonPendingPermissions, + useDaemonSession, + useDaemonTranscriptBlocks, + useDaemonTranscriptState, + useDaemonTranscriptStore, +} from './daemon/index'; +export type { + DaemonConnectionState, + DaemonConnectionStatus, + DaemonSessionContextValue, + DaemonSessionProviderProps, +} from './daemon/index'; + // Context export { PlatformContext, diff --git a/packages/webui/src/types/toolCall.ts b/packages/webui/src/types/toolCall.ts index 37762fb8a78..c6f17521231 100644 --- a/packages/webui/src/types/toolCall.ts +++ b/packages/webui/src/types/toolCall.ts @@ -7,7 +7,12 @@ /** * Tool call status */ -export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed'; +export type ToolCallStatus = + | 'pending' + | 'in_progress' + | 'completed' + | 'failed' + | 'cancelled'; /** * Tool call location reference diff --git a/packages/webui/tsconfig.json b/packages/webui/tsconfig.json index 3e0be85f6d4..a9d9985aae0 100644 --- a/packages/webui/tsconfig.json +++ b/packages/webui/tsconfig.json @@ -14,7 +14,12 @@ "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, - "noFallthroughCasesInSwitch": true + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@qwen-code/sdk": ["../sdk-typescript/src/index.ts"], + "@qwen-code/sdk/daemon": ["../sdk-typescript/src/daemon/index.ts"] + } }, "include": ["src"], "exclude": ["node_modules", "dist", "**/*.stories.tsx"] diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts index 9a571eab3bc..b6ec279c38e 100644 --- a/packages/webui/vite.config.ts +++ b/packages/webui/vite.config.ts @@ -20,6 +20,15 @@ import { resolve } from 'path'; * - CSS: dist/styles.css (optional styles) */ export default defineConfig({ + resolve: { + alias: { + '@qwen-code/sdk/daemon': resolve( + __dirname, + '../sdk-typescript/src/daemon/index.ts', + ), + '@qwen-code/sdk': resolve(__dirname, '../sdk-typescript/src/index.ts'), + }, + }, plugins: [ react(), dts({ @@ -42,9 +51,17 @@ export default defineConfig({ }, }, rollupOptions: { - external: ['react', 'react-dom', 'react/jsx-runtime'], + external: [ + '@qwen-code/sdk', + '@qwen-code/sdk/daemon', + 'react', + 'react-dom', + 'react/jsx-runtime', + ], output: { globals: { + '@qwen-code/sdk': 'QwenCodeSdk', + '@qwen-code/sdk/daemon': 'QwenCodeSdkDaemon', react: 'React', 'react-dom': 'ReactDOM', 'react/jsx-runtime': 'ReactJSXRuntime', From 5870557f051aa6a5c5e37106f99d5bc6b6e588e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 13:10:19 +0800 Subject: [PATCH 02/17] fix(daemon): address ui review feedback --- .../src/daemon/ui/normalizer.ts | 9 +- .../sdk-typescript/src/daemon/ui/terminal.ts | 14 +- .../src/daemon/ui/transcript.ts | 20 ++- .../sdk-typescript/src/daemon/ui/utils.ts | 4 +- .../sdk-typescript/test/unit/daemonUi.test.ts | 157 ++++++++++++++++-- .../components/toolcalls/ThinkToolCall.tsx | 8 +- .../src/daemon/DaemonSessionProvider.test.tsx | 144 ++++++++++++++++ .../src/daemon/DaemonSessionProvider.tsx | 82 +++++++-- .../src/daemon/transcriptAdapter.test.ts | 39 ++++- .../webui/src/daemon/transcriptAdapter.ts | 57 +++---- 10 files changed, 454 insertions(+), 80 deletions(-) create mode 100644 packages/webui/src/daemon/DaemonSessionProvider.test.tsx diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index bf901ca8f35..4c110989fc2 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -11,6 +11,7 @@ import type { NormalizeDaemonEventOptions, } from './types.js'; import { + getFirstString, getOutputText, getString, getTextContent, @@ -71,6 +72,7 @@ export function normalizeDaemonEvent( { ...base, type: 'error', + recoverable: false, text: getString(event.data, 'reason') ?? 'Session died (no details available)', @@ -119,8 +121,8 @@ export function normalizeDaemonEvent( return [ { ...base, - type: 'debug', - text: `${event.type}: ${stringifyJson(event.data)}`, + type: 'status', + text: `${event.type} (unrecognized daemon event)`, }, ]; } @@ -363,12 +365,13 @@ function normalizePermissionOptions( function describePermissionOutcome(value: unknown): string { if (!isRecord(value)) return stringifyJson(value); const outcome = value['outcome']; + if (typeof outcome === 'string') return outcome; if (isRecord(outcome)) { const kind = getString(outcome, 'outcome') ?? 'selected'; const optionId = getString(outcome, 'optionId'); return optionId ? `${kind}:${optionId}` : kind; } - return stringifyJson(value); + return getFirstString(value, ['status', 'reason']) ?? stringifyJson(value); } function describeToolCall(value: unknown): string { diff --git a/packages/sdk-typescript/src/daemon/ui/terminal.ts b/packages/sdk-typescript/src/daemon/ui/terminal.ts index b5167b5ac1d..ac26e43a0a1 100644 --- a/packages/sdk-typescript/src/daemon/ui/terminal.ts +++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts @@ -85,8 +85,18 @@ export function transcriptBlockToTerminalText( } function assertNever(value: never): string { - void value; - return ''; + const variant = value as { kind?: unknown; type?: unknown }; + const name = + typeof variant.type === 'string' + ? variant.type + : typeof variant.kind === 'string' + ? variant.kind + : 'unknown'; + return terminalLine( + 'error', + `Unhandled daemon terminal event: ${name}`, + '31', + ); } function terminalLine(label: string, text: string, sgr: string): string { diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 9ab00386c7b..0671b01ab09 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -17,6 +17,7 @@ import type { import { createDaemonToolPreview } from './toolPreview.js'; const DEFAULT_MAX_BLOCKS = 1_000; +const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; export function createDaemonTranscriptState( opts: DaemonTranscriptReducerOptions = {}, @@ -126,7 +127,7 @@ function applyDaemonTranscriptEvent( appendStatusBlock(next, event.type, event.text, event); break; default: - break; + assertNever(event); } } @@ -188,6 +189,7 @@ function upsertToolBlock( event: Extract, ): void { const existingId = state.toolBlockByCallId[event.toolCallId]; + if (existingId === TRIMMED_TOOL_BLOCK_ID) return; const existing = getWritableBlockById(state, existingId); if (existing?.kind === 'tool') { if (event.title !== undefined) existing.title = event.title; @@ -240,7 +242,11 @@ function appendShellBlock( ): void { if (!event.text) return; const last = state.blocks[state.blocks.length - 1]; - if (last?.kind === 'shell' && last.stream === event.stream) { + if ( + last?.kind === 'shell' && + event.stream !== undefined && + last.stream === event.stream + ) { const writable = getWritableBlockById(state, last.id); if (writable?.kind === 'shell') { writable.text += event.text; @@ -374,7 +380,7 @@ function cloneTranscriptState( now: opts.now ?? Date.now(), maxBlocks: opts.maxBlocks ?? state.maxBlocks, blocks: [...state.blocks], - blockIndexById: rebuildDaemonTranscriptBlockIndex(state.blocks), + blockIndexById: { ...state.blockIndexById }, toolBlockByCallId: { ...state.toolBlockByCallId }, permissionBlockByRequestId: { ...state.permissionBlockByRequestId }, }; @@ -389,7 +395,9 @@ function trimTranscriptState( state.blocks = blocks; state.blockIndexById = rebuildDaemonTranscriptBlockIndex(blocks); for (const [toolCallId, blockId] of Object.entries(state.toolBlockByCallId)) { - if (!keptIds.has(blockId)) delete state.toolBlockByCallId[toolCallId]; + if (!keptIds.has(blockId)) { + state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID; + } } for (const [requestId, blockId] of Object.entries( state.permissionBlockByRequestId, @@ -454,3 +462,7 @@ function clearActiveText(state: DaemonTranscriptState): void { state.activeUserBlockId = undefined; state.activeThoughtBlockId = undefined; } + +function assertNever(value: never): void { + void value; +} diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index e413420e873..408eef8c3c6 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -102,11 +102,11 @@ const controlCharactersPattern = new RegExp( ); const oscSequencePattern = new RegExp( - `${escapeChar}\\][\\s\\S]*?(?:${bell}|${escapeChar}\\\\)`, + `${escapeChar}\\][^${bell}${escapeChar}]*(?:${bell}|${escapeChar}\\\\)`, 'g', ); const dcsSequencePattern = new RegExp( - `${escapeChar}P[\\s\\S]*?${escapeChar}\\\\`, + `${escapeChar}P[^${escapeChar}]*${escapeChar}\\\\`, 'g', ); const csiSequencePattern = new RegExp(`${escapeChar}\\[[0-?]*[ -/]*[@-~]`, 'g'); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 54a491ead63..c66e60aa93a 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -328,7 +328,26 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.blocks).toMatchObject([{ kind: 'status', text: 'trim tool' }]); expect(state.blockIndexById).toEqual({ 'status-2': 0 }); - expect(state.toolBlockByCallId).toEqual({}); + expect(state.toolBlockByCallId['tool-1']).toBeDefined(); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 8, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + rawOutput: 'late', + }, + }, + }), + { now: 5 }, + ); + + expect(state.blocks).toMatchObject([{ kind: 'status', text: 'trim tool' }]); }); it('preserves rich tool preview and status on output-only updates', () => { @@ -481,6 +500,7 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(event).toMatchObject({ type: 'error', + recoverable: false, text: 'Session died (no details available)', }); expect(event && 'text' in event ? event.text : '').not.toContain( @@ -488,6 +508,101 @@ describe('daemon UI normalizer and transcript reducer', () => { ); }); + it('normalizes daemon lifecycle and control events', () => { + expect( + normalizeDaemonEvent({ + id: 51, + v: 1, + type: 'model_switched', + data: { modelId: 'qwen-plus' }, + }), + ).toMatchObject([{ type: 'model.changed', modelId: 'qwen-plus' }]); + expect( + normalizeDaemonEvent({ + id: 52, + v: 1, + type: 'model_switch_failed', + data: { error: 'no model' }, + }), + ).toMatchObject([{ type: 'error', recoverable: true, text: 'no model' }]); + expect( + normalizeDaemonEvent({ + id: 53, + v: 1, + type: 'client_evicted', + data: { reason: 'slow' }, + }), + ).toMatchObject([{ type: 'error', recoverable: true, text: 'slow' }]); + expect( + normalizeDaemonEvent({ + id: 54, + v: 1, + type: 'slow_client_warning', + data: {}, + }), + ).toMatchObject([{ type: 'status', text: 'SSE stream is lagging' }]); + expect( + normalizeDaemonEvent({ + id: 55, + v: 1, + type: 'stream_error', + data: { error: 'dropped' }, + }), + ).toMatchObject([{ type: 'error', recoverable: true, text: 'dropped' }]); + expect( + normalizeDaemonEvent({ + id: 56, + v: 1, + type: 'permission_already_resolved', + data: { requestId: 'perm-1', outcome: 'denied' }, + }), + ).toMatchObject([ + { type: 'permission.resolved', requestId: 'perm-1', outcome: 'denied' }, + ]); + expect( + normalizeDaemonEvent({ + id: 59, + v: 1, + type: 'permission_already_resolved', + data: { requestId: 'perm-2', status: 'already resolved' }, + }), + ).toMatchObject([ + { + type: 'permission.resolved', + requestId: 'perm-2', + outcome: 'already resolved', + }, + ]); + expect( + normalizeDaemonEvent({ + id: 57, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'available_commands_update', + availableCommands: ['help', '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)', + }, + ]); + }); + it('caps recursive output extraction depth', () => { let nested: unknown = 'leaf'; for (let i = 0; i < 70; i += 1) { @@ -573,6 +688,8 @@ describe('daemon UI normalizer and transcript reducer', () => { { type: 'shell.output', text: 'out-1', stream: 'stdout' }, { type: 'shell.output', text: 'out-2', stream: 'stdout' }, { type: 'shell.output', text: 'err-1', stream: 'stderr' }, + { type: 'shell.output', text: 'unknown-1' }, + { type: 'shell.output', text: 'unknown-2' }, ], { now: 2 }, ); @@ -580,6 +697,8 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.blocks).toMatchObject([ { kind: 'shell', text: 'out-1out-2', stream: 'stdout' }, { kind: 'shell', text: 'err-1', stream: 'stderr' }, + { kind: 'shell', text: 'unknown-1' }, + { kind: 'shell', text: 'unknown-2' }, ]); }); @@ -590,28 +709,37 @@ describe('daemon UI normalizer and transcript reducer', () => { calls += 1; }); - store.dispatch({ - type: 'status', - text: 'ready', - }); - store.dispatch({ - type: 'status', - text: 'still ready', - }); + store.appendLocalUserMessage('hello'); + store.dispatch([ + { + type: 'status', + text: 'ready', + }, + { + type: 'status', + text: 'still ready', + }, + ]); expect(calls).toBe(0); await Promise.resolve(); expect(calls).toBe(1); expect(store.getSnapshot().blocks).toMatchObject([ + { kind: 'user', text: 'hello' }, { kind: 'status', text: 'ready' }, { kind: 'status', text: 'still ready' }, ]); + store.reset(); + await Promise.resolve(); + expect(calls).toBe(2); + expect(store.getSnapshot().blocks).toEqual([]); + unsubscribe(); store.dispatch({ type: 'status', text: 'ignored listener' }); await Promise.resolve(); - expect(calls).toBe(1); + expect(calls).toBe(2); }); it('renders UI events to sanitized terminal text', () => { @@ -639,6 +767,15 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(output).not.toContain('hidden'); }); + it('sanitizes unterminated terminal control sequences without swallowing output', () => { + const output = sanitizeTerminalText( + `visible\u001b]${'x'.repeat(1000)}still-visible`, + ); + + expect(output).toContain('visible'); + expect(output).toContain('still-visible'); + }); + it('caps nested tool preview traversal depth', () => { let nested: unknown = { command: 'npm test' }; for (let i = 0; i < 20; i += 1) { diff --git a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx index 17c47e7f8ae..b391d3d5f1f 100644 --- a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx @@ -12,7 +12,6 @@ import { ToolCallCard, ToolCallRow, groupContent, - mapToolStatusToContainerStatus, } from './shared/index.js'; import type { BaseToolCallProps } from './shared/index.js'; @@ -66,7 +65,12 @@ export const ThinkToolCall: FC = ({ } // Short thoughts - compact format - const status = mapToolStatusToContainerStatus(toolCall.status); + const status = + toolCall.status === 'pending' || toolCall.status === 'in_progress' + ? 'loading' + : toolCall.status === 'failed' + ? 'error' + : 'default'; return ( { + let container: HTMLDivElement | null = null; + let root: Root | null = null; + + beforeEach(() => { + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it('exposes idle connection state without auto connect', () => { + let connection: DaemonConnectionState | undefined; + let blocks: readonly DaemonTranscriptBlock[] | undefined; + + function Harness() { + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + renderWithProvider(); + + expect(connection).toEqual({ status: 'idle' }); + expect(blocks).toEqual([]); + }); + + it('records action errors when no session is connected', async () => { + let actions: DaemonUiSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + renderWithProvider(); + const providerActions = actions; + if (!providerActions) throw new Error('actions were not initialized'); + + await act(async () => { + await expect(providerActions.sendPrompt('hi')).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + expect(blocks).toMatchObject([ + { + kind: 'error', + text: 'Prompt failed: Daemon session is not connected', + }, + ]); + + await act(async () => { + await expect(providerActions.cancel()).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + expect(blocks).toMatchObject([ + { text: 'Prompt failed: Daemon session is not connected' }, + { text: 'Cancel failed: Daemon session is not connected' }, + ]); + + await act(async () => { + await expect(providerActions.setModel('qwen-plus')).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + expect(blocks).toMatchObject([ + { text: 'Prompt failed: Daemon session is not connected' }, + { text: 'Cancel failed: Daemon session is not connected' }, + { text: 'Set model failed: Daemon session is not connected' }, + ]); + + await act(async () => { + await expect( + providerActions.respondToPermission('perm-1', { + outcome: { + outcome: 'selected', + optionId: 'allow', + }, + }), + ).rejects.toThrow('Daemon session is not connected'); + }); + expect(blocks).toMatchObject([ + { text: 'Prompt failed: Daemon session is not connected' }, + { text: 'Cancel failed: Daemon session is not connected' }, + { text: 'Set model failed: Daemon session is not connected' }, + { text: 'Permission response failed: Daemon session is not connected' }, + ]); + }); + + function renderWithProvider(children: ReactNode) { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + + {children} + , + ); + }); + } +}); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index d3ad2eef458..6cb94970775 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -169,6 +169,10 @@ export function DaemonSessionProvider({ } } if (!disposed && !abort.signal.aborted) { + store.dispatch({ + type: 'status', + text: 'SSE stream ended', + }); session = undefined; sessionRef.current = undefined; } @@ -261,9 +265,17 @@ export function DaemonSessionProvider({ const actions = useMemo( () => ({ async sendPrompt(text: string): Promise { - const session = requireSession(sessionRef.current); + const session = requireSessionForAction( + store, + sessionRef.current, + 'Prompt failed', + ); if (promptBusyRef.current) { - throw new Error('A prompt is already in progress'); + throw dispatchActionError( + store, + 'Prompt failed', + 'A prompt is already in progress', + ); } promptBusyRef.current = true; const promptAbort = new AbortController(); @@ -276,17 +288,9 @@ export function DaemonSessionProvider({ }, promptAbort.signal, ); - store.dispatch({ type: 'assistant.done', reason: result.stopReason }); return result; } catch (error) { - const message = - error instanceof Error ? error.message : String(error); - store.dispatch({ - type: 'error', - text: `Prompt failed: ${message}`, - recoverable: true, - }); - throw error; + throw dispatchActionError(store, 'Prompt failed', error); } finally { if (promptAbortRef.current === promptAbort) { promptAbortRef.current = undefined; @@ -295,18 +299,46 @@ export function DaemonSessionProvider({ } }, async cancel(): Promise { - await requireSession(sessionRef.current).cancel(); + promptAbortRef.current?.abort(); + promptAbortRef.current = undefined; + promptBusyRef.current = false; + const session = requireSessionForAction( + store, + sessionRef.current, + 'Cancel failed', + ); + try { + await session.cancel(); + } catch (error) { + throw dispatchActionError(store, 'Cancel failed', error); + } }, async setModel(modelId: string): Promise { - return await requireSession(sessionRef.current).setModel(modelId); + const session = requireSessionForAction( + store, + sessionRef.current, + 'Set model failed', + ); + try { + return await session.setModel(modelId); + } catch (error) { + throw dispatchActionError(store, 'Set model failed', error); + } }, async respondToPermission( requestId: string, response: PermissionResponse, ): Promise { - return await requireSession( + const session = requireSessionForAction( + store, sessionRef.current, - ).respondToSessionPermission(requestId, response); + 'Permission response failed', + ); + try { + return await session.respondToSessionPermission(requestId, response); + } catch (error) { + throw dispatchActionError(store, 'Permission response failed', error); + } }, }), [store], @@ -378,15 +410,31 @@ export function useDaemonConnection(): DaemonConnectionState { return connection; } -function requireSession( +function requireSessionForAction( + store: DaemonTranscriptStore, session: DaemonSessionClient | undefined, + action: string, ): DaemonSessionClient { if (!session) { - throw new Error('Daemon session is not connected'); + throw dispatchActionError(store, action, 'Daemon session is not connected'); } return session; } +function dispatchActionError( + store: DaemonTranscriptStore, + action: string, + error: unknown, +): Error { + const message = error instanceof Error ? error.message : String(error); + store.dispatch({ + type: 'error', + text: `${action}: ${message}`, + recoverable: true, + }); + return error instanceof Error ? error : new Error(message); +} + function getReconnectDelayMs( attempt: number, reconnectDelayMs: number, diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index ed8bfac8056..7bf2856866a 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -74,7 +74,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { title: '\u202eRun', status: 'completed', preview: { kind: 'generic' }, - rawInput: { command: '\u202enpm test' }, + rawInput: { '\u202ecommand': '\u202enpm test' }, rawOutput: '\u001b]0;bad\u0007ok', createdAt: 2, updatedAt: 2, @@ -89,6 +89,37 @@ describe('daemonTranscriptToUnifiedMessages', () => { }); }); + it('renders shell and status text as visible tool content', () => { + const messages = daemonTranscriptToUnifiedMessages([ + { + id: 'shell-1', + kind: 'shell', + text: '\u001b[31mstdout', + stream: 'stdout', + createdAt: 1, + updatedAt: 1, + }, + { + id: 'status-1', + kind: 'status', + text: '\u202econnected', + createdAt: 2, + updatedAt: 2, + }, + ]); + + expect(messages[0]?.toolCall).toMatchObject({ + kind: 'bash', + rawOutput: 'stdout', + content: [{ content: { text: 'stdout' } }], + }); + expect(messages[1]?.toolCall).toMatchObject({ + kind: 'status', + rawOutput: 'connected', + content: [{ content: { text: 'connected' } }], + }); + }); + it('computes grouping after filtering debug blocks and renders status', () => { const messages = daemonTranscriptToUnifiedMessages([ { @@ -126,7 +157,11 @@ describe('daemonTranscriptToUnifiedMessages', () => { { id: 'status-1', type: 'tool_call', - toolCall: { kind: 'status', rawOutput: 'connecting' }, + toolCall: { + kind: 'status', + rawOutput: 'connecting', + content: [{ content: { text: 'connecting' } }], + }, }, { id: 'assistant-1', isFirst: false, isLast: true }, ]); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 98f6405db1a..024277eb30c 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -4,9 +4,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { - DaemonTranscriptBlock, - DaemonToolTranscriptBlock, +import { + sanitizeDaemonTerminalText, + type DaemonTranscriptBlock, + type DaemonToolTranscriptBlock, } from '@qwen-code/sdk/daemon'; import type { UnifiedMessage } from '../adapters/types.js'; import type { @@ -101,6 +102,7 @@ export function daemonTranscriptToUnifiedMessages( title: 'Shell output', status: 'completed', rawOutput: sanitizeDisplayText(block.text), + content: createTextContent(block.text), }, isFirst, isLast, @@ -145,6 +147,7 @@ export function daemonTranscriptToUnifiedMessages( title: 'Status', status: 'completed', rawOutput: sanitizeDisplayText(block.text), + content: createTextContent(block.text), }, isFirst, isLast, @@ -228,7 +231,7 @@ function sanitizeDaemonValue(value: unknown, depth = 0): unknown { if (!isRecord(value)) return value; return Object.fromEntries( Object.entries(value).map(([key, entry]) => [ - key, + sanitizeDisplayText(key), sanitizeDaemonValue(entry, depth + 1), ]), ); @@ -239,39 +242,17 @@ function isRecord(value: unknown): value is Record { } function sanitizeDisplayText(text: string): string { - return text - .replace(BIDI_CONTROL_PATTERN, '') - .replace(OSC_SEQUENCE_PATTERN, '') - .replace(DCS_SEQUENCE_PATTERN, '') - .replace(CSI_SEQUENCE_PATTERN, '') - .replace(CONTROL_CHARACTERS_PATTERN, ''); + return sanitizeDaemonTerminalText(text); } -const nul = String.fromCharCode(0x00); -const backspace = String.fromCharCode(0x08); -const verticalTab = String.fromCharCode(0x0b); -const formFeed = String.fromCharCode(0x0c); -const shiftOut = String.fromCharCode(0x0e); -const unitSeparator = String.fromCharCode(0x1f); -const deleteChar = String.fromCharCode(0x7f); -const c1End = String.fromCharCode(0x9f); -const escapeChar = String.fromCharCode(0x1b); -const bell = String.fromCharCode(0x07); - -const CONTROL_CHARACTERS_PATTERN = new RegExp( - `[${nul}-${backspace}${verticalTab}${formFeed}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`, - 'g', -); -const OSC_SEQUENCE_PATTERN = new RegExp( - `${escapeChar}\\][\\s\\S]*?(?:${bell}|${escapeChar}\\\\)`, - 'g', -); -const DCS_SEQUENCE_PATTERN = new RegExp( - `${escapeChar}P[\\s\\S]*?${escapeChar}\\\\`, - 'g', -); -const CSI_SEQUENCE_PATTERN = new RegExp( - `${escapeChar}\\[[0-?]*[ -/]*[@-~]`, - 'g', -); -const BIDI_CONTROL_PATTERN = /[\u200e\u200f\u202a-\u202e\u2066-\u2069]/g; +function createTextContent(text: string): ToolCallData['content'] { + return [ + { + type: 'content', + content: { + type: 'text', + text: sanitizeDisplayText(text), + }, + }, + ]; +} From 9c3de08d76870079cee1a09ebae710d209207bd7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 13:15:20 +0800 Subject: [PATCH 03/17] test(daemon): cover raw event diagnostics option --- .../sdk-typescript/test/unit/daemonUi.test.ts | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index c66e60aa93a..8ce623175d2 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -171,6 +171,27 @@ describe('daemon UI normalizer and transcript reducer', () => { ).toMatchObject([{ type: 'user.text.delta', text: 'hello' }]); }); + it('optionally carries raw daemon events for diagnostics', () => { + const event = { + id: 24, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + } as const; + + const [withoutRaw] = normalizeDaemonEvent(event); + expect(withoutRaw).toMatchObject({ type: 'assistant.text.delta' }); + expect(withoutRaw).not.toHaveProperty('rawEvent'); + expect( + normalizeDaemonEvent(event, { includeRawEvent: true }), + ).toMatchObject([{ type: 'assistant.text.delta', rawEvent: event }]); + }); + it('projects AskUserQuestion into a semantic tool preview', () => { const events = normalizeDaemonEvent({ id: 3, From d36e8a63480f1abb36d2a5dfb2a226e0cde0d440 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:06:49 +0800 Subject: [PATCH 04/17] fix(daemon): address latest ui review --- .../src/daemon/ui/normalizer.ts | 5 +++ .../src/daemon/ui/transcript.ts | 6 +-- .../sdk-typescript/test/unit/daemonUi.test.ts | 9 +++-- .../src/daemon/DaemonSessionProvider.tsx | 37 +++++++++++++++---- 4 files changed, 42 insertions(+), 15 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 4c110989fc2..43e193f633f 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -124,6 +124,11 @@ export function normalizeDaemonEvent( type: 'status', text: `${event.type} (unrecognized daemon event)`, }, + { + ...base, + type: 'debug', + text: `${event.type}: ${stringifyJson(event.data)}`, + }, ]; } } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 0671b01ab09..5dd1992f3f7 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -242,11 +242,7 @@ function appendShellBlock( ): void { if (!event.text) return; const last = state.blocks[state.blocks.length - 1]; - if ( - last?.kind === 'shell' && - event.stream !== undefined && - last.stream === event.stream - ) { + if (last?.kind === 'shell' && last.stream === event.stream) { const writable = getWritableBlockById(state, last.id); if (writable?.kind === 'shell') { writable.text += event.text; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 8ce623175d2..796245e69c2 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -621,6 +621,10 @@ describe('daemon UI normalizer and transcript reducer', () => { type: 'status', text: 'mcp_budget_warning (unrecognized daemon event)', }, + { + type: 'debug', + text: expect.stringContaining('secret') as string, + }, ]); }); @@ -702,7 +706,7 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(stderr).toMatchObject({ type: 'shell.output', stream: 'stderr' }); }); - it('merges consecutive same-stream shell output blocks only', () => { + it('merges consecutive same-stream and streamless shell output blocks only', () => { const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), [ @@ -718,8 +722,7 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(state.blocks).toMatchObject([ { kind: 'shell', text: 'out-1out-2', stream: 'stdout' }, { kind: 'shell', text: 'err-1', stream: 'stderr' }, - { kind: 'shell', text: 'unknown-1' }, - { kind: 'shell', text: 'unknown-2' }, + { kind: 'shell', text: 'unknown-1unknown-2' }, ]); }); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 6cb94970775..e0b2141d1a6 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -288,29 +288,45 @@ export function DaemonSessionProvider({ }, promptAbort.signal, ); + store.dispatch({ type: 'assistant.done', reason: result.stopReason }); return result; } catch (error) { + if (isAbortError(error)) { + return { stopReason: 'cancelled' }; + } throw dispatchActionError(store, 'Prompt failed', error); } finally { if (promptAbortRef.current === promptAbort) { promptAbortRef.current = undefined; + promptBusyRef.current = false; } - promptBusyRef.current = false; } }, async cancel(): Promise { + const hadActivePrompt = promptAbortRef.current !== undefined; promptAbortRef.current?.abort(); promptAbortRef.current = undefined; - promptBusyRef.current = false; - const session = requireSessionForAction( - store, - sessionRef.current, - 'Cancel failed', - ); + let session: DaemonSessionClient; + try { + session = requireSessionForAction( + store, + sessionRef.current, + 'Cancel failed', + ); + } catch (error) { + if (hadActivePrompt) { + promptBusyRef.current = false; + } + throw error; + } try { await session.cancel(); } catch (error) { throw dispatchActionError(store, 'Cancel failed', error); + } finally { + if (hadActivePrompt) { + promptBusyRef.current = false; + } } }, async setModel(modelId: string): Promise { @@ -435,6 +451,13 @@ function dispatchActionError( return error instanceof Error ? error : new Error(message); } +function isAbortError(error: unknown): boolean { + return ( + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + ); +} + function getReconnectDelayMs( attempt: number, reconnectDelayMs: number, From f74af3b8156d045e2c66fd79ca184e2669f41d72 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:41:35 +0800 Subject: [PATCH 05/17] fix(daemon): cover reconnect and status edge cases --- .../src/daemon/ui/normalizer.ts | 3 +- .../sdk-typescript/test/unit/daemonUi.test.ts | 5 +- .../src/daemon/DaemonSessionProvider.test.tsx | 371 +++++++++++++++++- .../src/daemon/DaemonSessionProvider.tsx | 3 + .../src/daemon/transcriptAdapter.test.ts | 2 + .../webui/src/daemon/transcriptAdapter.ts | 39 +- 6 files changed, 395 insertions(+), 28 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 43e193f633f..d3e2943a267 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -82,8 +82,7 @@ export function normalizeDaemonEvent( return [ { ...base, - type: 'error', - recoverable: false, + type: 'status', text: `Session closed: ${getString(event.data, 'reason') ?? 'closed'}`, }, ]; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 796245e69c2..ab40f205f1c 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -120,7 +120,7 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); - it('surfaces session_closed as a visible terminal error', () => { + it('surfaces session_closed as a visible terminal status', () => { const events = normalizeDaemonEvent({ id: 23, v: 1, @@ -130,8 +130,7 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(events).toMatchObject([ { - type: 'error', - recoverable: false, + type: 'status', text: 'Session closed: idle timeout', }, ]); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index bae3cc45204..67a0b589915 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -8,19 +8,77 @@ import { act, type ReactNode } from 'react'; import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { + DaemonEvent, DaemonTranscriptBlock, DaemonUiSessionActions, + PromptResult, } from '@qwen-code/sdk/daemon'; import { DaemonSessionProvider, useDaemonActions, useDaemonConnection, useDaemonTranscriptBlocks, + type DaemonSessionProviderProps, type DaemonConnectionState, } from './DaemonSessionProvider.js'; +interface MockSession { + sessionId: string; + workspaceCwd: string; + clientId: string; + prompt: (req: unknown, signal?: AbortSignal) => Promise; + cancel: () => Promise; + setModel: (modelId: string) => Promise<{ modelId: string }>; + respondToSessionPermission: () => Promise; + events: (opts?: { + signal?: AbortSignal; + maxQueued?: number; + }) => AsyncGenerator; +} + +const sdkMocks = vi.hoisted(() => { + const sessions: MockSession[] = []; + + class MockDaemonClient { + constructor(_opts: unknown) {} + + capabilities = vi.fn(async () => ({ + workspaceCwd: '/mock-workspace', + })); + } + + class MockDaemonSessionClient { + static createOrAttach = vi.fn( + async (_client: unknown, _req: unknown): Promise => { + const session = sessions.shift(); + if (!session) throw new Error('No mock daemon session queued'); + return session; + }, + ); + } + + return { + sessions, + MockDaemonClient, + MockDaemonSessionClient, + reset() { + sessions.length = 0; + MockDaemonSessionClient.createOrAttach.mockClear(); + }, + }; +}); + +vi.mock('@qwen-code/sdk/daemon', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + DaemonClient: sdkMocks.MockDaemonClient, + DaemonSessionClient: sdkMocks.MockDaemonSessionClient, + }; +}); + describe('DaemonSessionProvider', () => { let container: HTMLDivElement | null = null; let root: Root | null = null; @@ -29,6 +87,7 @@ describe('DaemonSessionProvider', () => { ( globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; + sdkMocks.reset(); }); afterEach(() => { @@ -44,7 +103,7 @@ describe('DaemonSessionProvider', () => { } }); - it('exposes idle connection state without auto connect', () => { + it('exposes idle connection state without auto connect', async () => { let connection: DaemonConnectionState | undefined; let blocks: readonly DaemonTranscriptBlock[] | undefined; @@ -54,7 +113,7 @@ describe('DaemonSessionProvider', () => { return null; } - renderWithProvider(); + await renderWithProvider(); expect(connection).toEqual({ status: 'idle' }); expect(blocks).toEqual([]); @@ -70,7 +129,7 @@ describe('DaemonSessionProvider', () => { return null; } - renderWithProvider(); + await renderWithProvider(); const providerActions = actions; if (!providerActions) throw new Error('actions were not initialized'); @@ -125,7 +184,213 @@ describe('DaemonSessionProvider', () => { ]); }); - function renderWithProvider(children: ReactNode) { + it('prevents double submit while a prompt is running', async () => { + const prompt = createDeferred(); + const session = createMockSession({ + prompt: vi.fn(() => prompt.promise), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + + function Harness() { + actions = useDaemonActions(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + const providerActions = requireActions(actions); + + let firstPrompt: Promise | undefined; + await act(async () => { + firstPrompt = providerActions.sendPrompt('first'); + await flushPromises(); + }); + + await act(async () => { + await expect(providerActions.sendPrompt('second')).rejects.toThrow( + 'A prompt is already in progress', + ); + }); + + prompt.resolve({ stopReason: 'end_turn' }); + const runningPrompt = firstPrompt; + if (!runningPrompt) throw new Error('prompt was not started'); + await act(async () => { + await expect(runningPrompt).resolves.toEqual({ stopReason: 'end_turn' }); + }); + expect(session.prompt).toHaveBeenCalledTimes(1); + }); + + it('treats prompt abort during cancel as cancellation and keeps busy until cancel completes', async () => { + const cancel = createDeferred(); + const session = createMockSession({ + prompt: vi.fn( + (_req: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(createAbortError()), + { once: true }, + ); + }), + ), + cancel: vi.fn(() => cancel.promise), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + const providerActions = requireActions(actions); + + let promptResult: Promise | undefined; + let cancelResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('cancel me'); + await flushPromises(); + cancelResult = providerActions.cancel(); + await flushPromises(); + }); + + const cancelledPrompt = promptResult; + if (!cancelledPrompt) throw new Error('prompt was not started'); + await expect(cancelledPrompt).resolves.toEqual({ + stopReason: 'cancelled', + }); + await act(async () => { + await expect(providerActions.sendPrompt('blocked')).rejects.toThrow( + 'A prompt is already in progress', + ); + }); + + cancel.resolve(); + const pendingCancel = cancelResult; + if (!pendingCancel) throw new Error('cancel was not started'); + await act(async () => { + await pendingCancel; + }); + expect(session.cancel).toHaveBeenCalledTimes(1); + expect( + blocks.some( + (block) => block.kind === 'error' && block.text.includes('AbortError'), + ), + ).toBe(false); + }); + + it('clears prompt state and transcript when reconnect attaches a different session', async () => { + const firstEvents = createClosableEvents(); + const firstSession = createMockSession({ + sessionId: 'session-a', + prompt: vi.fn( + (_req: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(createAbortError()), + { once: true }, + ); + }), + ), + events: firstEvents.events, + }); + const secondSession = createMockSession({ + sessionId: 'session-b', + prompt: vi.fn(async () => ({ stopReason: 'end_turn' })), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(firstSession, secondSession); + let actions: DaemonUiSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + const providerActions = requireActions(actions); + + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('old prompt'); + await flushPromises(); + }); + expect(blocks).toMatchObject([{ kind: 'user', text: 'old prompt' }]); + + firstEvents.close(); + await act(async () => { + await wait(5); + await flushPromises(); + }); + + expect(connection).toMatchObject({ sessionId: 'session-b' }); + expect(blocks).toEqual([]); + const abortedPrompt = promptResult; + if (!abortedPrompt) throw new Error('prompt was not started'); + await expect(abortedPrompt).resolves.toEqual({ stopReason: 'cancelled' }); + + await act(async () => { + await expect(providerActions.sendPrompt('new prompt')).resolves.toEqual({ + stopReason: 'end_turn', + }); + }); + expect(secondSession.prompt).toHaveBeenCalledTimes(1); + }); + + it('surfaces SSE stream end and clears the session when reconnect is disabled', async () => { + const session = createMockSession({ events: createClosedEvents() }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + const providerActions = requireActions(actions); + + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ status: 'disconnected' }); + expect(blocks).toMatchObject([ + { kind: 'status', text: 'SSE stream ended' }, + ]); + await act(async () => { + await expect(providerActions.cancel()).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + }); + + async function renderWithProvider( + children: ReactNode, + props: Partial = {}, + ) { container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -135,10 +400,106 @@ describe('DaemonSessionProvider', () => { {children} , ); }); + await act(async () => { + await flushPromises(); + }); } }); + +function requireActions( + actions: DaemonUiSessionActions | undefined, +): DaemonUiSessionActions { + if (!actions) throw new Error('actions were not initialized'); + return actions; +} + +function createMockSession(opts: Partial = {}): MockSession { + return { + sessionId: opts.sessionId ?? 'session-1', + workspaceCwd: opts.workspaceCwd ?? '/mock-workspace', + clientId: opts.clientId ?? 'client-1', + prompt: + opts.prompt ?? + vi.fn(async () => ({ + stopReason: 'end_turn', + })), + cancel: opts.cancel ?? vi.fn(async () => {}), + setModel: + opts.setModel ?? + vi.fn(async (modelId: string) => ({ + modelId, + })), + respondToSessionPermission: + opts.respondToSessionPermission ?? vi.fn(async () => true), + events: opts.events ?? createIdleEvents(), + }; +} + +function createIdleEvents(): MockSession['events'] { + return async function* idleEvents(opts: { signal?: AbortSignal } = {}) { + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { once: true }); + }); + yield* []; + }; +} + +function createClosedEvents(): MockSession['events'] { + return async function* closedEvents() { + await Promise.resolve(); + yield* []; + }; +} + +function createClosableEvents(): { + events: MockSession['events']; + close: () => void; +} { + const closed = createDeferred(); + return { + events: async function* closableEvents() { + await closed.promise; + yield* []; + }, + close: closed.resolve, + }; +} + +function createDeferred(): { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} { + let resolve!: (value?: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = (value) => res(value as T | PromiseLike); + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function flushPromises(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +async function wait(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); +} + +function createAbortError(): Error { + const error = new Error('The operation was aborted'); + error.name = 'AbortError'; + return error; +} diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index e0b2141d1a6..54a899cdafc 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -129,6 +129,9 @@ export function DaemonSessionProvider({ previousSessionId !== undefined && nextSession.sessionId !== previousSessionId ) { + promptAbortRef.current?.abort(); + promptAbortRef.current = undefined; + promptBusyRef.current = false; store.reset(); } session = nextSession; diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 7bf2856866a..eb75ae82f5c 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -46,6 +46,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { const messages = daemonTranscriptToUnifiedMessages([ createPermissionBlock('pending-permission'), createPermissionBlock('allowed-permission', 'selected:allow'), + createPermissionBlock('allowed-substring-permission', 'selected:deny-me'), createPermissionBlock('cancelled-permission', 'cancelled'), createPermissionBlock('denied-permission', 'denied'), ]); @@ -53,6 +54,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { expect(messages.map((message) => message.toolCall?.status)).toEqual([ 'pending', 'completed', + 'completed', 'cancelled', 'failed', ]); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 024277eb30c..a91a9857f22 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -201,25 +201,28 @@ function normalizePermissionStatus( resolved: string | undefined, ): ToolCallStatus { if (!resolved) return 'pending'; - const normalized = resolved.toLowerCase(); - if ( - normalized.includes('cancel') || - normalized.includes('abort') || - normalized.includes('dismiss') - ) { - return 'cancelled'; - } - if ( - normalized.includes('deny') || - normalized.includes('denied') || - normalized.includes('reject') || - normalized.includes('blocked') || - normalized.includes('error') || - normalized.includes('fail') - ) { - return 'failed'; + const primary = resolved.split(':', 1)[0]?.toLowerCase(); + switch (primary) { + case 'cancel': + case 'cancelled': + case 'canceled': + case 'abort': + case 'aborted': + case 'dismiss': + case 'dismissed': + return 'cancelled'; + case 'deny': + case 'denied': + case 'reject': + case 'rejected': + case 'blocked': + case 'error': + case 'failed': + case 'fail': + return 'failed'; + default: + return 'completed'; } - return 'completed'; } function sanitizeDaemonValue(value: unknown, depth = 0): unknown { From ea5e5b90cf91e45d6a03c5dd761f0663bf99c3ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:43:47 +0800 Subject: [PATCH 06/17] fix(daemon): guard prompt busy cleanup --- packages/webui/src/daemon/DaemonSessionProvider.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 54a899cdafc..3df3387d6ac 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -283,8 +283,8 @@ export function DaemonSessionProvider({ promptBusyRef.current = true; const promptAbort = new AbortController(); promptAbortRef.current = promptAbort; - store.appendLocalUserMessage(text); try { + store.appendLocalUserMessage(text); const result = await session.prompt( { prompt: [{ type: 'text', text }], From df9881f8a18e12e9a26c13eeba58a238a7dc704d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:48:47 +0800 Subject: [PATCH 07/17] fix(daemon): handle trimmed tool updates --- .../sdk-typescript/src/daemon/ui/transcript.ts | 16 +++++++++++++--- .../sdk-typescript/test/unit/daemonUi.test.ts | 7 ++++++- .../webui/src/daemon/transcriptAdapter.test.ts | 2 ++ packages/webui/src/daemon/transcriptAdapter.ts | 1 + 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 5dd1992f3f7..d61a1185ae9 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -189,7 +189,15 @@ function upsertToolBlock( event: Extract, ): void { const existingId = state.toolBlockByCallId[event.toolCallId]; - if (existingId === TRIMMED_TOOL_BLOCK_ID) return; + if (existingId === TRIMMED_TOOL_BLOCK_ID) { + appendStatusBlock( + state, + 'error', + `Tool ${event.toolCallId} output trimmed (max blocks reached)`, + event, + ); + return; + } const existing = getWritableBlockById(state, existingId); if (existing?.kind === 'tool') { if (event.title !== undefined) existing.title = event.title; @@ -459,6 +467,8 @@ function clearActiveText(state: DaemonTranscriptState): void { state.activeThoughtBlockId = undefined; } -function assertNever(value: never): void { - void value; +function assertNever(value: never): never { + throw new Error( + `Unhandled daemon transcript event: ${JSON.stringify(value)}`, + ); } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index ab40f205f1c..495cd4435cd 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -367,7 +367,12 @@ describe('daemon UI normalizer and transcript reducer', () => { { now: 5 }, ); - expect(state.blocks).toMatchObject([{ kind: 'status', text: 'trim tool' }]); + expect(state.blocks).toMatchObject([ + { + kind: 'error', + text: 'Tool tool-1 output trimmed (max blocks reached)', + }, + ]); }); it('preserves rich tool preview and status on output-only updates', () => { diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index eb75ae82f5c..5652212093d 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -48,6 +48,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { createPermissionBlock('allowed-permission', 'selected:allow'), createPermissionBlock('allowed-substring-permission', 'selected:deny-me'), createPermissionBlock('cancelled-permission', 'cancelled'), + createPermissionBlock('already-resolved-permission', 'already resolved'), createPermissionBlock('denied-permission', 'denied'), ]); @@ -56,6 +57,7 @@ describe('daemonTranscriptToUnifiedMessages', () => { 'completed', 'completed', 'cancelled', + 'cancelled', 'failed', ]); }); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index a91a9857f22..720dd17cab9 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -210,6 +210,7 @@ function normalizePermissionStatus( case 'aborted': case 'dismiss': case 'dismissed': + case 'already resolved': return 'cancelled'; case 'deny': case 'denied': From f87ac5a5bbe69e438ae97a43f05b8f323a5cebfc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 14:52:43 +0800 Subject: [PATCH 08/17] fix(daemon): cap transcript text blocks --- .../src/daemon/ui/transcript.ts | 24 +++++++++++++++---- .../sdk-typescript/test/unit/daemonUi.test.ts | 18 ++++++++++++++ 2 files changed, 38 insertions(+), 4 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index d61a1185ae9..12a2da0ba48 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -18,6 +18,8 @@ import { createDaemonToolPreview } from './toolPreview.js'; const DEFAULT_MAX_BLOCKS = 1_000; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; +const MAX_TEXT_BLOCK_LENGTH = 100_000; +const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n'; export function createDaemonTranscriptState( opts: DaemonTranscriptReducerOptions = {}, @@ -158,7 +160,7 @@ function appendTextDelta( ): void { const existing = getWritableBlockById(state, state[activeKey]); if (existing && existing.kind === kind) { - existing.text += text; + existing.text = appendBoundedText(existing.text, text); existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; if (kind === 'assistant') existing.streaming = true; @@ -253,7 +255,7 @@ function appendShellBlock( if (last?.kind === 'shell' && last.stream === event.stream) { const writable = getWritableBlockById(state, last.id); if (writable?.kind === 'shell') { - writable.text += event.text; + writable.text = appendBoundedText(writable.text, event.text); writable.updatedAt = state.now; if (event.eventId !== undefined) writable.eventId = event.eventId; } @@ -263,7 +265,7 @@ function appendShellBlock( const block: DaemonShellTranscriptBlock = { id: allocateBlockId(state, 'shell'), kind: 'shell', - text: event.text, + text: truncateText(event.text), createdAt: state.now, updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), @@ -350,7 +352,7 @@ function appendStatusBlock( const block: DaemonStatusTranscriptBlock = { id: allocateBlockId(state, kind), kind, - text, + text: truncateText(text), createdAt: state.now, updatedAt: state.now, ...(event?.eventId !== undefined ? { eventId: event.eventId } : {}), @@ -467,6 +469,20 @@ function clearActiveText(state: DaemonTranscriptState): void { state.activeThoughtBlockId = undefined; } +function appendBoundedText(existing: string, text: string): string { + if (existing.length >= MAX_TEXT_BLOCK_LENGTH) return existing; + return truncateText(existing + text); +} + +function truncateText(text: string): string { + if (text.length <= MAX_TEXT_BLOCK_LENGTH) return text; + const keepLength = Math.max( + 0, + MAX_TEXT_BLOCK_LENGTH - TEXT_TRUNCATED_SUFFIX.length, + ); + return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; +} + function assertNever(value: never): never { throw new Error( `Unhandled daemon transcript event: ${JSON.stringify(value)}`, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 495cd4435cd..1bc26327d97 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -515,6 +515,24 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('caps text transcript blocks to prevent unbounded memory growth', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'assistant.text.delta', text: 'x'.repeat(80_000) }, + { type: 'assistant.text.delta', text: 'y'.repeat(80_000) }, + ], + { now: 2 }, + ); + const [block] = state.blocks; + + expect(block).toMatchObject({ kind: 'assistant' }); + expect( + block && 'text' in block ? block.text.length : 0, + ).toBeLessThanOrEqual(100_000); + expect(block && 'text' in block ? block.text : '').toContain('[truncated]'); + }); + it('redacts raw daemon payloads from fallback error text', () => { const [event] = normalizeDaemonEvent({ id: 43, From aa385c285ff4c47d465c81b7ee3c24b1afec3619 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 15:40:05 +0800 Subject: [PATCH 09/17] fix(daemon): dedupe trimmed tool diagnostics --- .../sdk-typescript/src/daemon/ui/store.ts | 3 + .../src/daemon/ui/transcript.ts | 25 +++-- .../sdk-typescript/src/daemon/ui/types.ts | 1 + .../sdk-typescript/test/unit/daemonUi.test.ts | 106 +++++++++++++++++- 4 files changed, 126 insertions(+), 9 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index 4f3fae3d2c1..ea80af59b05 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -78,6 +78,9 @@ function createState( blocks, blockIndexById: rebuildDaemonTranscriptBlockIndex(blocks), toolBlockByCallId: { ...(seed.toolBlockByCallId ?? {}) }, + trimmedToolNotificationByCallId: { + ...(seed.trimmedToolNotificationByCallId ?? {}), + }, permissionBlockByRequestId: { ...(seed.permissionBlockByRequestId ?? {}), }, diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 12a2da0ba48..10a49ad8707 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -28,6 +28,7 @@ export function createDaemonTranscriptState( blocks: [], blockIndexById: {}, toolBlockByCallId: {}, + trimmedToolNotificationByCallId: {}, permissionBlockByRequestId: {}, nextOrdinal: 1, now: opts.now ?? Date.now(), @@ -192,12 +193,16 @@ function upsertToolBlock( ): void { const existingId = state.toolBlockByCallId[event.toolCallId]; if (existingId === TRIMMED_TOOL_BLOCK_ID) { - appendStatusBlock( - state, - 'error', - `Tool ${event.toolCallId} output trimmed (max blocks reached)`, - event, - ); + if (!state.trimmedToolNotificationByCallId[event.toolCallId]) { + state.trimmedToolNotificationByCallId[event.toolCallId] = true; + appendStatusBlock( + state, + 'error', + `Tool ${event.toolCallId} output trimmed (max blocks reached)`, + event, + { clearActiveText: false }, + ); + } return; } const existing = getWritableBlockById(state, existingId); @@ -348,6 +353,7 @@ function appendStatusBlock( kind: 'status' | 'error' | 'debug', text: string, event?: DaemonUiEvent, + opts: { clearActiveText?: boolean } = {}, ): void { const block: DaemonStatusTranscriptBlock = { id: allocateBlockId(state, kind), @@ -358,7 +364,7 @@ function appendStatusBlock( ...(event?.eventId !== undefined ? { eventId: event.eventId } : {}), }; appendBlock(state, block); - clearActiveText(state); + if (opts.clearActiveText !== false) clearActiveText(state); } function createTextBlock( @@ -370,7 +376,7 @@ function createTextBlock( return { id: allocateBlockId(state, kind), kind, - text, + text: truncateText(text), createdAt: state.now, updatedAt: state.now, ...(eventId !== undefined ? { eventId } : {}), @@ -388,6 +394,9 @@ function cloneTranscriptState( blocks: [...state.blocks], blockIndexById: { ...state.blockIndexById }, toolBlockByCallId: { ...state.toolBlockByCallId }, + trimmedToolNotificationByCallId: { + ...state.trimmedToolNotificationByCallId, + }, permissionBlockByRequestId: { ...state.permissionBlockByRequestId }, }; } diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 98395987380..9b5de65116d 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -228,6 +228,7 @@ export interface DaemonTranscriptState { activeThoughtBlockId?: string; blockIndexById: Record; toolBlockByCallId: Record; + trimmedToolNotificationByCallId: Record; permissionBlockByRequestId: Record; nextOrdinal: number; now: number; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 1bc26327d97..a988b38e313 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -371,8 +371,112 @@ describe('daemon UI normalizer and transcript reducer', () => { { kind: 'error', text: 'Tool tool-1 output trimmed (max blocks reached)', + eventId: 8, }, ]); + expect(state.trimmedToolNotificationByCallId['tool-1']).toBe(true); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 9, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-1', + status: 'completed', + rawOutput: 'late again', + }, + }, + }), + { now: 6 }, + ); + + expect(state.blocks).toHaveLength(1); + expect(state.blocks).toMatchObject([ + { + kind: 'error', + text: 'Tool tool-1 output trimmed (max blocks reached)', + eventId: 8, + }, + ]); + }); + + it('keeps active assistant text open when reporting trimmed tool updates', () => { + let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 }); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 10, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 'tool-stream', + title: 'Run command', + status: 'running', + }, + }, + }), + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [ + { type: 'status', text: 'first trim filler' }, + { type: 'status', text: 'second trim filler' }, + ], + { now: 3 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'assistant.text.delta', text: 'streaming' }], + { now: 4 }, + ); + + const assistantBlockBeforeLateToolUpdate = state.blocks.find( + (block) => block.kind === 'assistant', + ); + expect(assistantBlockBeforeLateToolUpdate).toMatchObject({ + kind: 'assistant', + streaming: true, + }); + expect(state.activeAssistantBlockId).toBe( + assistantBlockBeforeLateToolUpdate?.id, + ); + + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-stream', + rawOutput: 'late', + }, + }, + }), + { now: 5 }, + ); + + const assistantBlockAfterLateToolUpdate = state.blocks.find( + (block) => block.kind === 'assistant', + ); + expect(assistantBlockAfterLateToolUpdate).toMatchObject({ + kind: 'assistant', + text: 'streaming', + streaming: true, + }); + expect(state.activeAssistantBlockId).toBe( + assistantBlockAfterLateToolUpdate?.id, + ); }); it('preserves rich tool preview and status on output-only updates', () => { @@ -519,7 +623,7 @@ describe('daemon UI normalizer and transcript reducer', () => { const state = reduceDaemonTranscriptEvents( createDaemonTranscriptState({ now: 1 }), [ - { type: 'assistant.text.delta', text: 'x'.repeat(80_000) }, + { type: 'assistant.text.delta', text: 'x'.repeat(160_000) }, { type: 'assistant.text.delta', text: 'y'.repeat(80_000) }, ], { now: 2 }, From d1d7c5665fa425174b848e6b2fba7495bf03feb3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 15:45:03 +0800 Subject: [PATCH 10/17] fix(daemon): harden webui transcript edge cases --- .../sdk-typescript/test/unit/daemonUi.test.ts | 18 +++++++ .../src/daemon/DaemonSessionProvider.test.tsx | 28 +++++++---- .../src/daemon/transcriptAdapter.test.ts | 6 ++- .../webui/src/daemon/transcriptAdapter.ts | 50 ++++++++++++++++++- 4 files changed, 89 insertions(+), 13 deletions(-) diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index a988b38e313..f3706308b86 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -637,6 +637,24 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(block && 'text' in block ? block.text : '').toContain('[truncated]'); }); + it('caps shell transcript blocks to prevent unbounded output growth', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + [ + { type: 'shell.output', text: 'x'.repeat(160_000), stream: 'stdout' }, + { type: 'shell.output', text: 'y'.repeat(80_000), stream: 'stdout' }, + ], + { now: 2 }, + ); + const [block] = state.blocks; + + expect(block).toMatchObject({ kind: 'shell', stream: 'stdout' }); + expect( + block && 'text' in block ? block.text.length : 0, + ).toBeLessThanOrEqual(100_000); + expect(block && 'text' in block ? block.text : '').toContain('[truncated]'); + }); + it('redacts raw daemon payloads from fallback error text', () => { const [event] = normalizeDaemonEvent({ id: 43, diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index 67a0b589915..cc0c4fbfadf 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -224,17 +224,17 @@ describe('DaemonSessionProvider', () => { it('treats prompt abort during cancel as cancellation and keeps busy until cancel completes', async () => { const cancel = createDeferred(); + let promptCalls = 0; const session = createMockSession({ - prompt: vi.fn( - (_req: unknown, signal?: AbortSignal) => - new Promise((_resolve, reject) => { - signal?.addEventListener( - 'abort', - () => reject(createAbortError()), - { once: true }, - ); - }), - ), + prompt: vi.fn((_req: unknown, signal?: AbortSignal) => { + promptCalls += 1; + if (promptCalls > 1) return Promise.resolve({ stopReason: 'end_turn' }); + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => reject(createAbortError()), { + once: true, + }); + }); + }), cancel: vi.fn(() => cancel.promise), events: createIdleEvents(), }); @@ -278,6 +278,14 @@ describe('DaemonSessionProvider', () => { await pendingCancel; }); expect(session.cancel).toHaveBeenCalledTimes(1); + await act(async () => { + await expect(providerActions.sendPrompt('after cancel')).resolves.toEqual( + { + stopReason: 'end_turn', + }, + ); + }); + expect(session.prompt).toHaveBeenCalledTimes(2); expect( blocks.some( (block) => block.kind === 'error' && block.text.includes('AbortError'), diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 5652212093d..c7deb1395e9 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -47,17 +47,21 @@ describe('daemonTranscriptToUnifiedMessages', () => { createPermissionBlock('pending-permission'), createPermissionBlock('allowed-permission', 'selected:allow'), createPermissionBlock('allowed-substring-permission', 'selected:deny-me'), + createPermissionBlock('cancelled-substring-permission', 'selected:abort'), createPermissionBlock('cancelled-permission', 'cancelled'), createPermissionBlock('already-resolved-permission', 'already resolved'), createPermissionBlock('denied-permission', 'denied'), + createPermissionBlock('unknown-permission', 'timed out'), ]); expect(messages.map((message) => message.toolCall?.status)).toEqual([ 'pending', 'completed', - 'completed', + 'failed', 'cancelled', 'cancelled', + 'cancelled', + 'failed', 'failed', ]); }); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 720dd17cab9..33486ffec49 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -201,7 +201,7 @@ function normalizePermissionStatus( resolved: string | undefined, ): ToolCallStatus { if (!resolved) return 'pending'; - const primary = resolved.split(':', 1)[0]?.toLowerCase(); + const [primary = '', ...detailParts] = resolved.toLowerCase().split(':'); switch (primary) { case 'cancel': case 'cancelled': @@ -221,9 +221,55 @@ function normalizePermissionStatus( case 'failed': case 'fail': return 'failed'; - default: + case 'allow': + case 'allowed': + case 'approve': + case 'approved': + case 'accept': + case 'accepted': + case 'confirm': + case 'confirmed': + case 'proceed': + case 'success': + case 'succeeded': return 'completed'; + case 'selected': + return classifyPermissionToken(detailParts.join(':')) ?? 'completed'; + default: + return classifyPermissionToken(primary) ?? 'failed'; + } +} + +function classifyPermissionToken(token: string): ToolCallStatus | undefined { + if (!token) return undefined; + if ( + token.includes('deny') || + token.includes('reject') || + token.includes('block') || + token.includes('fail') || + token.includes('error') + ) { + return 'failed'; + } + if ( + token.includes('cancel') || + token.includes('abort') || + token.includes('dismiss') + ) { + return 'cancelled'; + } + if ( + token.includes('allow') || + token.includes('approve') || + token.includes('accept') || + token.includes('confirm') || + token.includes('proceed') || + token.includes('grant') || + token.includes('success') + ) { + return 'completed'; } + return undefined; } function sanitizeDaemonValue(value: unknown, depth = 0): unknown { From 6886b6c554a7335b510f3a9eddacc44132b230c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 16:07:20 +0800 Subject: [PATCH 11/17] fix(daemon): preserve webui daemon events --- .../src/daemon/ui/normalizer.ts | 100 ++++++++++++++++-- .../src/daemon/ui/transcript.ts | 4 + .../sdk-typescript/src/daemon/ui/types.ts | 4 + .../sdk-typescript/test/unit/daemonUi.test.ts | 82 +++++++++++++- .../src/daemon/DaemonSessionProvider.test.tsx | 69 +++++++++++- .../src/daemon/DaemonSessionProvider.tsx | 16 ++- .../src/daemon/transcriptAdapter.test.ts | 44 ++++++++ .../webui/src/daemon/transcriptAdapter.ts | 64 +++++++++++ 8 files changed, 370 insertions(+), 13 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index d3e2943a267..a583ec59311 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -126,7 +126,7 @@ export function normalizeDaemonEvent( { ...base, type: 'debug', - text: `${event.type}: ${stringifyJson(event.data)}`, + text: `${event.type}: ${stringifyRedactedJson(event.data)}`, }, ]; } @@ -156,7 +156,7 @@ function normalizeSessionUpdate( { ...base, type: 'debug', - text: `session_update: ${stringifyJson(event.data)}`, + text: `session_update: ${stringifyRedactedJson(event.data)}`, }, ]; } @@ -212,12 +212,14 @@ function normalizeSessionUpdate( }, ]; } + case 'plan': + return [normalizePlanUpdate(update, base)]; default: return [ { ...base, type: 'debug', - text: `${kind ?? 'session_update'}: ${stringifyJson(update)}`, + text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(update)}`, }, ]; } @@ -237,6 +239,8 @@ function normalizeToolUpdate( const title = getString(update, 'title') ?? toolName ?? toolKind; const rawInput = update['rawInput'] ?? update['input'] ?? update['args']; const rawOutput = update['rawOutput'] ?? update['output'] ?? update['result']; + const content = update['content']; + const locations = update['locations']; const toolCallId = getString(update, 'toolCallId'); const status = getString(update, 'status'); if (!toolCallId) { @@ -255,6 +259,8 @@ function normalizeToolUpdate( ...(title ? { title } : {}), ...(toolName ? { toolName } : {}), ...(toolKind ? { toolKind } : {}), + ...(content !== undefined ? { content } : {}), + ...(locations !== undefined ? { locations } : {}), ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined @@ -265,6 +271,53 @@ function normalizeToolUpdate( }; } +function normalizePlanUpdate( + update: Record, + base: Pick, +): DaemonUiEvent { + const entries = Array.isArray(update['entries']) ? update['entries'] : []; + const contentText = formatPlanEntries(entries); + return { + ...base, + type: 'tool.update', + toolCallId: 'daemon-plan', + title: 'Updated Plan', + status: 'completed', + toolName: 'TodoWrite', + toolKind: 'updated_plan', + content: [ + { + type: 'content', + content: { type: 'text', text: contentText }, + }, + ], + rawOutput: { entries }, + }; +} + +function formatPlanEntries(entries: readonly unknown[]): string { + return entries + .flatMap((entry): string[] => { + if (!isRecord(entry)) return []; + const content = getString(entry, 'content'); + if (!content) return []; + const marker = getPlanEntryMarker(getString(entry, 'status')); + return [`- [${marker}] ${content}`]; + }) + .join('\n'); +} + +function getPlanEntryMarker(status: string | undefined): string { + switch (status) { + case 'completed': + return 'x'; + case 'in_progress': + return '-'; + default: + return ' '; + } +} + function capDetails(details: string): string { if (details.length <= MAX_DETAILS_LENGTH) return details; return `${details.slice(0, MAX_DETAILS_LENGTH)}... [truncated]`; @@ -279,7 +332,7 @@ function normalizePermissionRequest( { ...base, type: 'debug', - text: `permission_request: ${stringifyJson(event.data)}`, + text: `permission_request: ${stringifyRedactedJson(event.data)}`, }, ]; } @@ -290,7 +343,7 @@ function normalizePermissionRequest( { ...base, type: 'debug', - text: `permission_request: ${stringifyJson(event.data)}`, + text: `permission_request: ${stringifyRedactedJson(event.data)}`, }, ]; } @@ -319,7 +372,7 @@ function normalizePermissionResolved( { ...base, type: 'debug', - text: `${event.type}: ${stringifyJson(event.data)}`, + text: `${event.type}: ${stringifyRedactedJson(event.data)}`, }, ]; } @@ -393,3 +446,38 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { const stream = getString(value, 'stream'); return stream === 'stdout' || stream === 'stderr' ? stream : undefined; } + +function stringifyRedactedJson(value: unknown): string { + return stringifyJson(redactSensitiveFields(value)); +} + +function redactSensitiveFields(value: unknown, depth = 0): unknown { + if (depth > 16) return '[truncated]'; + if (Array.isArray(value)) { + return value.map((entry) => redactSensitiveFields(entry, depth + 1)); + } + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + isSensitiveKey(key) + ? '[redacted]' + : redactSensitiveFields(entry, depth + 1), + ]), + ); +} + +function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + return ( + normalized === 'authorization' || + normalized === 'cookie' || + normalized === 'setcookie' || + normalized === 'password' || + normalized === 'apikey' || + normalized.endsWith('token') || + normalized.endsWith('secret') || + normalized.includes('accesstoken') || + normalized.includes('refreshtoken') + ); +} diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 10a49ad8707..414158faa1e 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -219,6 +219,8 @@ function upsertToolBlock( existing.updatedAt = state.now; if (event.eventId !== undefined) existing.eventId = event.eventId; if (event.details) existing.details = event.details; + if (event.content !== undefined) existing.content = event.content; + if (event.locations !== undefined) existing.locations = event.locations; if (event.rawInput !== undefined) existing.rawInput = event.rawInput; if (event.rawOutput !== undefined) existing.rawOutput = event.rawOutput; if (event.toolName) existing.toolName = event.toolName; @@ -241,6 +243,8 @@ function upsertToolBlock( updatedAt: state.now, ...(event.eventId !== undefined ? { eventId: event.eventId } : {}), ...(event.details ? { details: event.details } : {}), + ...(event.content !== undefined ? { content: event.content } : {}), + ...(event.locations !== undefined ? { locations: event.locations } : {}), ...(event.rawInput !== undefined ? { rawInput: event.rawInput } : {}), ...(event.rawOutput !== undefined ? { rawOutput: event.rawOutput } : {}), ...(event.toolName ? { toolName: event.toolName } : {}), diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 9b5de65116d..995a8d06c35 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -44,6 +44,8 @@ export interface DaemonUiToolUpdateEvent extends DaemonUiEventBase { status?: string; toolName?: string; toolKind?: string; + content?: unknown; + locations?: unknown; details?: string; rawInput?: unknown; rawOutput?: unknown; @@ -185,6 +187,8 @@ export interface DaemonToolTranscriptBlock extends DaemonTranscriptBlockBase { toolName?: string; toolKind?: string; preview: DaemonToolPreview; + content?: unknown; + locations?: unknown; details?: string; rawInput?: unknown; rawOutput?: unknown; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index f3706308b86..9e36624236f 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -527,6 +527,47 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('preserves daemon tool content and locations for web renderers', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: 46, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call_update', + toolCallId: 'tool-rich', + title: 'Read file', + status: 'completed', + content: [ + { + type: 'content', + content: { type: 'text', text: 'read ok' }, + }, + ], + locations: [{ path: 'src/index.ts', line: 3 }], + }, + }, + }), + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + toolCallId: 'tool-rich', + content: [ + { + type: 'content', + content: { type: 'text', text: 'read ok' }, + }, + ], + locations: [{ path: 'src/index.ts', line: 3 }], + }, + ]); + }); + it('caps verbose tool details from raw input and output', () => { const [inputEvent] = normalizeDaemonEvent({ id: 44, @@ -767,7 +808,46 @@ describe('daemon UI normalizer and transcript reducer', () => { }, { type: 'debug', - text: expect.stringContaining('secret') as string, + text: expect.not.stringContaining('secret') as string, + }, + ]); + }); + + it('normalizes plan session updates as visible tool blocks', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: 60, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [ + { content: 'Design API', status: 'completed' }, + { content: 'Implement UI', status: 'in_progress' }, + { content: 'Add tests', status: 'pending' }, + ], + }, + }, + }), + { now: 2 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + toolCallId: 'daemon-plan', + toolKind: 'updated_plan', + content: [ + { + type: 'content', + content: { + type: 'text', + text: '- [x] Design API\n- [-] Implement UI\n- [ ] Add tests', + }, + }, + ], }, ]); }); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index cc0c4fbfadf..790f8d190fa 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -307,7 +307,11 @@ describe('DaemonSessionProvider', () => { ); }), ), - events: firstEvents.events, + events: async function* missingSessionEvents() { + await firstEvents.closed.promise; + yield* []; + throw Object.assign(new Error('missing session'), { status: 404 }); + }, }); const secondSession = createMockSession({ sessionId: 'session-b', @@ -342,7 +346,7 @@ describe('DaemonSessionProvider', () => { firstEvents.close(); await act(async () => { - await wait(5); + await wait(20); await flushPromises(); }); @@ -360,6 +364,65 @@ describe('DaemonSessionProvider', () => { expect(secondSession.prompt).toHaveBeenCalledTimes(1); }); + it('reuses the same session client after a normal SSE stream end', async () => { + const events = vi.fn(async function* reusableEvents( + opts: { signal?: AbortSignal } = {}, + ) { + if (events.mock.calls.length === 1) { + const event: DaemonEvent = { + id: 5, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + }, + }, + }; + yield event; + return; + } + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + yield* []; + }); + const session = createMockSession({ events }); + sdkMocks.sessions.push(session); + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + await act(async () => { + await wait(5); + await flushPromises(); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledTimes(1); + expect(events).toHaveBeenCalledTimes(2); + expect(blocks).toMatchObject([ + { kind: 'assistant', text: 'hello' }, + { kind: 'status', text: 'SSE stream ended' }, + ]); + }); + it('surfaces SSE stream end and clears the session when reconnect is disabled', async () => { const session = createMockSession({ events: createClosedEvents() }); sdkMocks.sessions.push(session); @@ -472,6 +535,7 @@ function createClosedEvents(): MockSession['events'] { function createClosableEvents(): { events: MockSession['events']; close: () => void; + closed: ReturnType>; } { const closed = createDeferred(); return { @@ -480,6 +544,7 @@ function createClosableEvents(): { yield* []; }, close: closed.resolve, + closed, }; } diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 3df3387d6ac..704ec343374 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -176,16 +176,13 @@ export function DaemonSessionProvider({ type: 'status', text: 'SSE stream ended', }); - session = undefined; - sessionRef.current = undefined; } } catch (error) { if (disposed || abort.signal.aborted) return; const message = error instanceof Error ? error.message : String(error); store.dispatch({ type: 'error', text: message, recoverable: true }); - const sessionMissing = - error instanceof DaemonHttpError && error.status === 404; + const sessionMissing = isDaemonHttpNotFound(error); if (sessionMissing) { session = undefined; sessionRef.current = undefined; @@ -461,6 +458,17 @@ function isAbortError(error: unknown): boolean { ); } +function isDaemonHttpNotFound(error: unknown): boolean { + return ( + (error instanceof DaemonHttpError && error.status === 404) || + (isRecord(error) && error['status'] === 404) + ); +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + function getReconnectDelayMs( attempt: number, reconnectDelayMs: number, diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index c7deb1395e9..8c58332761f 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -128,6 +128,50 @@ describe('daemonTranscriptToUnifiedMessages', () => { }); }); + it('preserves daemon tool content and locations for specialized renderers', () => { + const messages = daemonTranscriptToUnifiedMessages([ + { + id: 'tool-rich', + kind: 'tool', + toolCallId: 'tool-rich', + title: 'Read file', + status: 'completed', + preview: { kind: 'generic' }, + content: [ + { + type: 'content', + content: { type: 'text', text: '\u202eread ok' }, + }, + { + type: 'diff', + path: '\u202esrc/index.ts', + oldText: 'old', + newText: '\u202enew', + }, + ], + locations: [{ path: '\u202esrc/index.ts', line: 3 }], + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(messages[0]?.toolCall).toMatchObject({ + content: [ + { + type: 'content', + content: { type: 'text', text: 'read ok' }, + }, + { + type: 'diff', + path: 'src/index.ts', + oldText: 'old', + newText: 'new', + }, + ], + locations: [{ path: 'src/index.ts', line: 3 }], + }); + }); + it('computes grouping after filtering debug blocks and renders status', () => { const messages = daemonTranscriptToUnifiedMessages([ { diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 33486ffec49..61ec5907d7d 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -12,6 +12,8 @@ import { import type { UnifiedMessage } from '../adapters/types.js'; import type { ToolCallData, + ToolCallContent, + ToolCallLocation, ToolCallStatus, } from '../components/toolcalls/shared/index.js'; @@ -172,6 +174,12 @@ function daemonToolBlockToToolCallData( | string | undefined, rawOutput: sanitizeDaemonValue(block.rawOutput), + ...(block.content !== undefined + ? { content: normalizeToolContent(block.content) } + : {}), + ...(block.locations !== undefined + ? { locations: normalizeToolLocations(block.locations) } + : {}), }; } @@ -287,6 +295,62 @@ function sanitizeDaemonValue(value: unknown, depth = 0): unknown { ); } +function normalizeToolContent(value: unknown): ToolCallContent[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry): ToolCallContent[] => { + const sanitized = sanitizeDaemonValue(entry); + if (!isRecord(sanitized)) return []; + const type = sanitized['type']; + if (type === 'diff') { + const path = sanitized['path']; + const newText = sanitized['newText']; + return typeof path === 'string' && typeof newText === 'string' + ? [ + { + type: 'diff', + path, + oldText: + typeof sanitized['oldText'] === 'string' || + sanitized['oldText'] === null + ? sanitized['oldText'] + : null, + newText, + }, + ] + : []; + } + if (type !== 'content') return []; + const content = sanitized['content']; + if (!isRecord(content) || typeof content['type'] !== 'string') return []; + return [ + { + type: 'content', + content: { + ...content, + type: content['type'], + }, + }, + ]; + }); +} + +function normalizeToolLocations(value: unknown): ToolCallLocation[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry): ToolCallLocation[] => { + const sanitized = sanitizeDaemonValue(entry); + if (!isRecord(sanitized) || typeof sanitized['path'] !== 'string') { + return []; + } + const line = sanitized['line']; + return [ + { + path: sanitized['path'], + ...(typeof line === 'number' || line === null ? { line } : {}), + }, + ]; + }); +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } From 6090fbcff7173a96f94d023b5abc366b981d1fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 16:58:10 +0800 Subject: [PATCH 12/17] fix(daemon): address latest ui review comments --- .../src/daemon/ui/transcript.ts | 20 ++++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 55 ++++++++++++++++ .../src/daemon/DaemonSessionProvider.test.tsx | 65 ++++++++++++++++++- .../src/daemon/DaemonSessionProvider.tsx | 2 + .../src/daemon/transcriptAdapter.test.ts | 8 +++ .../webui/src/daemon/transcriptAdapter.ts | 2 + 6 files changed, 149 insertions(+), 3 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 414158faa1e..e934da1fdb8 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -193,6 +193,11 @@ function upsertToolBlock( ): void { const existingId = state.toolBlockByCallId[event.toolCallId]; if (existingId === TRIMMED_TOOL_BLOCK_ID) { + if (shouldRecreateTrimmedToolBlock(event)) { + delete state.toolBlockByCallId[event.toolCallId]; + delete state.trimmedToolNotificationByCallId[event.toolCallId]; + return upsertToolBlock(state, event); + } if (!state.trimmedToolNotificationByCallId[event.toolCallId]) { state.trimmedToolNotificationByCallId[event.toolCallId] = true; appendStatusBlock( @@ -418,6 +423,13 @@ function trimTranscriptState( state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID; } } + for (const [toolCallId] of Object.entries( + state.trimmedToolNotificationByCallId, + )) { + if (state.toolBlockByCallId[toolCallId] !== TRIMMED_TOOL_BLOCK_ID) { + delete state.trimmedToolNotificationByCallId[toolCallId]; + } + } for (const [requestId, blockId] of Object.entries( state.permissionBlockByRequestId, )) { @@ -436,6 +448,14 @@ function trimTranscriptState( return state; } +function shouldRecreateTrimmedToolBlock( + event: Extract, +): boolean { + return ( + event.toolCallId === 'daemon-plan' || event.toolKind === 'updated_plan' + ); +} + function appendBlock( state: DaemonTranscriptState, block: DaemonTranscriptBlock, diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 9e36624236f..b61fe455520 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -852,6 +852,61 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('recreates synthetic plan blocks after transcript trimming', () => { + let state = createDaemonTranscriptState({ maxBlocks: 1, now: 1 }); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 60, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [{ content: 'Design API', status: 'completed' }], + }, + }, + }), + { now: 2 }, + ); + state = reduceDaemonTranscriptEvents( + state, + [{ type: 'status', text: 'trim plan block' }], + { now: 3 }, + ); + state = reduceDaemonTranscriptEvents( + state, + normalizeDaemonEvent({ + id: 61, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [{ content: 'Implement UI', status: 'in_progress' }], + }, + }, + }), + { now: 4 }, + ); + + expect(state.blocks).toMatchObject([ + { + kind: 'tool', + toolCallId: 'daemon-plan', + content: [ + { + type: 'content', + content: { + type: 'text', + text: '- [-] Implement UI', + }, + }, + ], + }, + ]); + }); + it('caps recursive output extraction depth', () => { let nested: unknown = 'leaf'; for (let i = 0; i < 70; i += 1) { diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index 790f8d190fa..9d7d421877b 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -40,13 +40,12 @@ interface MockSession { const sdkMocks = vi.hoisted(() => { const sessions: MockSession[] = []; + const capabilities = vi.fn(); class MockDaemonClient { constructor(_opts: unknown) {} - capabilities = vi.fn(async () => ({ - workspaceCwd: '/mock-workspace', - })); + capabilities = capabilities; } class MockDaemonSessionClient { @@ -61,10 +60,13 @@ const sdkMocks = vi.hoisted(() => { return { sessions, + capabilities, MockDaemonClient, MockDaemonSessionClient, reset() { sessions.length = 0; + capabilities.mockReset(); + capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace' }); MockDaemonSessionClient.createOrAttach.mockClear(); }, }; @@ -423,6 +425,63 @@ describe('DaemonSessionProvider', () => { ]); }); + it('ignores stale connect attempts after provider props change', async () => { + const staleAttach = createDeferred(); + const staleSession = createMockSession({ sessionId: 'session-a' }); + const activeSession = createMockSession({ sessionId: 'session-b' }); + sdkMocks.MockDaemonSessionClient.createOrAttach + .mockImplementationOnce(async () => staleAttach.promise) + .mockImplementationOnce(async () => activeSession); + let connection: DaemonConnectionState | undefined; + + function Harness() { + connection = useDaemonConnection(); + return null; + } + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledTimes(1); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ sessionId: 'session-b' }); + + staleAttach.resolve(staleSession); + await act(async () => { + await flushPromises(); + }); + expect(connection).toMatchObject({ sessionId: 'session-b' }); + }); + it('surfaces SSE stream end and clears the session when reconnect is disabled', async () => { const session = createMockSession({ events: createClosedEvents() }); sdkMocks.sessions.push(session); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 704ec343374..d747d48e4be 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -116,6 +116,7 @@ export function DaemonSessionProvider({ if (!session) { setConnection({ status: 'connecting' }); const caps = await client.capabilities(); + if (disposed || abort.signal.aborted) return; const nextSession = await DaemonSessionClient.createOrAttach( client, { @@ -124,6 +125,7 @@ export function DaemonSessionProvider({ workspaceCwd: workspaceCwd ?? caps.workspaceCwd, }, ); + if (disposed || abort.signal.aborted) return; const previousSessionId = lastSessionIdRef.current; if ( previousSessionId !== undefined && diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 8c58332761f..0ad4948e9aa 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -48,6 +48,10 @@ describe('daemonTranscriptToUnifiedMessages', () => { createPermissionBlock('allowed-permission', 'selected:allow'), createPermissionBlock('allowed-substring-permission', 'selected:deny-me'), createPermissionBlock('cancelled-substring-permission', 'selected:abort'), + createPermissionBlock('grant-permission', 'selected:grant-access'), + createPermissionBlock('reject-permission', 'selected:reject-policy'), + createPermissionBlock('dismiss-permission', 'selected:dismiss-dialog'), + createPermissionBlock('question-choice-permission', 'selected:beijing'), createPermissionBlock('cancelled-permission', 'cancelled'), createPermissionBlock('already-resolved-permission', 'already resolved'), createPermissionBlock('denied-permission', 'denied'), @@ -59,6 +63,10 @@ describe('daemonTranscriptToUnifiedMessages', () => { 'completed', 'failed', 'cancelled', + 'completed', + 'failed', + 'cancelled', + 'completed', 'cancelled', 'cancelled', 'failed', diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 61ec5907d7d..2cff1f16575 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -242,6 +242,8 @@ function normalizePermissionStatus( case 'succeeded': return 'completed'; case 'selected': + // A selected option resolves the prompt even when the option id is a + // domain value like a city name rather than allow/deny terminology. return classifyPermissionToken(detailParts.join(':')) ?? 'completed'; default: return classifyPermissionToken(primary) ?? 'failed'; From 58a426ed07b13d99ae58f1fde7f42d2992c9eafc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 18:08:25 +0800 Subject: [PATCH 13/17] fix(daemon): close latest ui review nits --- .../src/daemon/ui/normalizer.ts | 19 ++++- .../sdk-typescript/src/daemon/ui/utils.ts | 3 +- .../sdk-typescript/test/unit/daemonUi.test.ts | 64 +++++++++++++- .../src/daemon/DaemonSessionProvider.test.tsx | 42 +++++++++- .../src/daemon/DaemonSessionProvider.tsx | 6 +- .../src/daemon/transcriptAdapter.test.ts | 35 ++++++++ .../webui/src/daemon/transcriptAdapter.ts | 84 ++++++++++++++----- packages/webui/vite.config.ts | 28 ++++--- 8 files changed, 241 insertions(+), 40 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index a583ec59311..da4c822b037 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -276,7 +276,7 @@ function normalizePlanUpdate( base: Pick, ): DaemonUiEvent { const entries = Array.isArray(update['entries']) ? update['entries'] : []; - const contentText = formatPlanEntries(entries); + const contentText = capDetails(formatPlanEntries(entries)); return { ...base, type: 'tool.update', @@ -471,13 +471,28 @@ function isSensitiveKey(key: string): boolean { const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); return ( normalized === 'authorization' || + normalized === 'auth' || normalized === 'cookie' || normalized === 'setcookie' || + normalized === 'credential' || + normalized === 'credentials' || normalized === 'password' || + normalized === 'passphrase' || + normalized === 'privatekey' || normalized === 'apikey' || + normalized === 'clientsecret' || + normalized === 'idtoken' || + normalized === 'sessiontoken' || + normalized === 'xapikey' || + normalized === 'xauthkey' || + normalized === 'xauthtoken' || normalized.endsWith('token') || normalized.endsWith('secret') || + normalized.endsWith('apikey') || + normalized.endsWith('privatekey') || normalized.includes('accesstoken') || - normalized.includes('refreshtoken') + normalized.includes('refreshtoken') || + normalized.includes('bearertoken') || + normalized.includes('personalaccesstoken') ); } diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index 408eef8c3c6..a3e344e8994 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -89,6 +89,7 @@ const nul = String.fromCharCode(0x00); const backspace = String.fromCharCode(0x08); const verticalTab = String.fromCharCode(0x0b); const formFeed = String.fromCharCode(0x0c); +const carriageReturn = String.fromCharCode(0x0d); const shiftOut = String.fromCharCode(0x0e); const unitSeparator = String.fromCharCode(0x1f); const deleteChar = String.fromCharCode(0x7f); @@ -97,7 +98,7 @@ const escapeChar = String.fromCharCode(0x1b); const bell = String.fromCharCode(0x07); const controlCharactersPattern = new RegExp( - `[${nul}-${backspace}${verticalTab}${formFeed}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`, + `[${nul}-${backspace}${verticalTab}${formFeed}${carriageReturn}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`, 'g', ); diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index b61fe455520..a1c9c9f9a21 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -852,6 +852,37 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('caps normalized plan content before storing it in tool content', () => { + const longPlan = 'x'.repeat(5_000); + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ now: 1 }), + normalizeDaemonEvent({ + id: 62, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'plan', + entries: [{ content: longPlan, status: 'in_progress' }], + }, + }, + }), + { now: 2 }, + ); + + const block = state.blocks[0]; + expect(block).toMatchObject({ kind: 'tool', toolKind: 'updated_plan' }); + if (block?.kind !== 'tool') throw new Error('expected plan tool block'); + const firstContent = block.content?.[0]; + expect(firstContent).toMatchObject({ + content: { + type: 'text', + text: expect.stringContaining('[truncated]') as string, + }, + }); + expect(JSON.stringify(block.content).length).toBeLessThan(4_300); + }); + it('recreates synthetic plan blocks after transcript trimming', () => { let state = createDaemonTranscriptState({ maxBlocks: 1, now: 1 }); state = reduceDaemonTranscriptEvents( @@ -1059,17 +1090,48 @@ describe('daemon UI normalizer and transcript reducer', () => { it('strips terminal control and bidi spoofing sequences', () => { const output = sanitizeTerminalText( - '\u202etxt.exe\u001b[31mred\u001bPhidden\u001b\\ok', + '\u202etxt.exe\u001b[31mred\roverwrite\u001bPhidden\u001b\\ok', ); expect(output).toContain('txt.exe'); expect(output).toContain('red'); + expect(output).toContain('overwrite'); expect(output).toContain('ok'); expect(output).not.toContain('\u202e'); expect(output).not.toContain('\u001b['); + expect(output).not.toContain('\r'); expect(output).not.toContain('hidden'); }); + it('redacts nested sensitive daemon payload fields', () => { + const events = normalizeDaemonEvent({ + id: 70, + v: 1, + type: 'future_event', + data: { + headers: { + Authorization: 'Bearer secret', + 'x-api-key': 'key-secret', + }, + nested: [{ client_secret: 'client-secret' }], + credentials: { passphrase: 'pass-secret' }, + }, + }); + + expect(events).toMatchObject([ + { type: 'status' }, + { + type: 'debug', + text: expect.stringContaining('[redacted]') as string, + }, + ]); + const debug = events.find((event) => event.type === 'debug'); + expect(debug?.text).not.toContain('Bearer secret'); + expect(debug?.text).not.toContain('key-secret'); + expect(debug?.text).not.toContain('client-secret'); + expect(debug?.text).not.toContain('pass-secret'); + }); + it('sanitizes unterminated terminal control sequences without swallowing output', () => { const output = sanitizeTerminalText( `visible\u001b]${'x'.repeat(1000)}still-visible`, diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index 9d7d421877b..d18705f3d13 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -226,6 +226,7 @@ describe('DaemonSessionProvider', () => { it('treats prompt abort during cancel as cancellation and keeps busy until cancel completes', async () => { const cancel = createDeferred(); + const assistantChunk = createDeferred(); let promptCalls = 0; const session = createMockSession({ prompt: vi.fn((_req: unknown, signal?: AbortSignal) => { @@ -238,7 +239,31 @@ describe('DaemonSessionProvider', () => { }); }), cancel: vi.fn(() => cancel.promise), - events: createIdleEvents(), + events: async function* assistantThenIdleEvents( + opts: { signal?: AbortSignal } = {}, + ) { + await assistantChunk.promise; + yield { + id: 10, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'streaming' }, + }, + }, + }; + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, }); sdkMocks.sessions.push(session); let actions: DaemonUiSessionActions | undefined; @@ -258,6 +283,15 @@ describe('DaemonSessionProvider', () => { await act(async () => { promptResult = providerActions.sendPrompt('cancel me'); await flushPromises(); + assistantChunk.resolve(); + await flushPromises(); + }); + expect(blocks).toMatchObject([ + { kind: 'user', text: 'cancel me' }, + { kind: 'assistant', text: 'streaming', streaming: true }, + ]); + + await act(async () => { cancelResult = providerActions.cancel(); await flushPromises(); }); @@ -280,6 +314,12 @@ describe('DaemonSessionProvider', () => { await pendingCancel; }); expect(session.cancel).toHaveBeenCalledTimes(1); + expect(blocks[0]).toMatchObject({ kind: 'user', text: 'cancel me' }); + expect(blocks[1]).toMatchObject({ + kind: 'assistant', + text: 'streaming', + streaming: false, + }); await act(async () => { await expect(providerActions.sendPrompt('after cancel')).resolves.toEqual( { diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index d747d48e4be..79bfe96a52a 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -294,6 +294,7 @@ export function DaemonSessionProvider({ return result; } catch (error) { if (isAbortError(error)) { + store.dispatch({ type: 'assistant.done', reason: 'cancelled' }); return { stopReason: 'cancelled' }; } throw dispatchActionError(store, 'Prompt failed', error); @@ -405,7 +406,8 @@ export function useDaemonTranscriptBlocks(): readonly DaemonTranscriptBlock[] { } export function useDaemonPendingPermissions() { - return selectPendingPermissionBlocks(useDaemonTranscriptState()); + const state = useDaemonTranscriptState(); + return useMemo(() => selectPendingPermissionBlocks(state), [state]); } export function useDaemonActions(): DaemonUiSessionActions { @@ -468,7 +470,7 @@ function isDaemonHttpNotFound(error: unknown): boolean { } function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null; + return typeof value === 'object' && value !== null && !Array.isArray(value); } function getReconnectDelayMs( diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 0ad4948e9aa..edba6b6b5c1 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -52,6 +52,8 @@ describe('daemonTranscriptToUnifiedMessages', () => { createPermissionBlock('reject-permission', 'selected:reject-policy'), createPermissionBlock('dismiss-permission', 'selected:dismiss-dialog'), createPermissionBlock('question-choice-permission', 'selected:beijing'), + createPermissionBlock('disallowed-permission', 'selected:disallow'), + createPermissionBlock('unblocked-permission', 'selected:unblock'), createPermissionBlock('cancelled-permission', 'cancelled'), createPermissionBlock('already-resolved-permission', 'already resolved'), createPermissionBlock('denied-permission', 'denied'), @@ -67,6 +69,8 @@ describe('daemonTranscriptToUnifiedMessages', () => { 'failed', 'cancelled', 'completed', + 'failed', + 'completed', 'cancelled', 'cancelled', 'failed', @@ -180,6 +184,37 @@ describe('daemonTranscriptToUnifiedMessages', () => { }); }); + it('truncates deeply nested daemon values instead of returning raw subtrees', () => { + let nested: unknown = '\u202eraw'; + for (let i = 0; i < 20; i += 1) { + nested = { child: nested }; + } + + const messages = daemonTranscriptToUnifiedMessages([ + { + id: 'tool-deep', + kind: 'tool', + toolCallId: 'tool-deep', + title: 'Deep', + status: 'completed', + preview: { kind: 'generic' }, + rawOutput: nested, + createdAt: 1, + updatedAt: 1, + }, + ]); + + expect(messages[0]?.toolCall?.rawOutput).toMatchObject({ + child: expect.any(Object) as object, + }); + expect(JSON.stringify(messages[0]?.toolCall?.rawOutput)).toContain( + '[truncated]', + ); + expect(JSON.stringify(messages[0]?.toolCall?.rawOutput)).not.toContain( + '\u202e', + ); + }); + it('computes grouping after filtering debug blocks and renders status', () => { const messages = daemonTranscriptToUnifiedMessages([ { diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 2cff1f16575..04efb86df7f 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -252,39 +252,79 @@ function normalizePermissionStatus( function classifyPermissionToken(token: string): ToolCallStatus | undefined { if (!token) return undefined; - if ( - token.includes('deny') || - token.includes('reject') || - token.includes('block') || - token.includes('fail') || - token.includes('error') - ) { + const terms = new Set( + token + .toLowerCase() + .split(/[^a-z0-9]+/) + .filter(Boolean), + ); + if (hasAnyTerm(terms, FAILED_PERMISSION_TERMS)) { return 'failed'; } - if ( - token.includes('cancel') || - token.includes('abort') || - token.includes('dismiss') - ) { + if (hasAnyTerm(terms, CANCELLED_PERMISSION_TERMS)) { return 'cancelled'; } - if ( - token.includes('allow') || - token.includes('approve') || - token.includes('accept') || - token.includes('confirm') || - token.includes('proceed') || - token.includes('grant') || - token.includes('success') - ) { + if (hasAnyTerm(terms, COMPLETED_PERMISSION_TERMS)) { return 'completed'; } return undefined; } +const FAILED_PERMISSION_TERMS = new Set([ + 'block', + 'blocked', + 'deny', + 'denied', + 'disallow', + 'disallowed', + 'error', + 'fail', + 'failed', + 'reject', + 'rejected', +]); + +const CANCELLED_PERMISSION_TERMS = new Set([ + 'abort', + 'aborted', + 'cancel', + 'cancelled', + 'canceled', + 'dismiss', + 'dismissed', +]); + +const COMPLETED_PERMISSION_TERMS = new Set([ + 'accept', + 'accepted', + 'allow', + 'allowed', + 'approve', + 'approved', + 'confirm', + 'confirmed', + 'grant', + 'granted', + 'proceed', + 'success', + 'succeeded', + 'unblock', + 'unblocked', +]); + +function hasAnyTerm( + terms: ReadonlySet, + expected: ReadonlySet, +): boolean { + for (const term of terms) { + if (expected.has(term)) return true; + } + return false; +} + function sanitizeDaemonValue(value: unknown, depth = 0): unknown { if (typeof value === 'string') return sanitizeDisplayText(value); - if (depth > 16) return value; + if (depth > 16) return '[truncated]'; if (Array.isArray(value)) { return value.map((entry) => sanitizeDaemonValue(entry, depth + 1)); } diff --git a/packages/webui/vite.config.ts b/packages/webui/vite.config.ts index b6ec279c38e..6190b7f3bd0 100644 --- a/packages/webui/vite.config.ts +++ b/packages/webui/vite.config.ts @@ -19,16 +19,22 @@ import { resolve } from 'path'; * - TypeScript declarations: dist/index.d.ts * - CSS: dist/styles.css (optional styles) */ -export default defineConfig({ - resolve: { - alias: { - '@qwen-code/sdk/daemon': resolve( - __dirname, - '../sdk-typescript/src/daemon/index.ts', - ), - '@qwen-code/sdk': resolve(__dirname, '../sdk-typescript/src/index.ts'), - }, - }, +export default defineConfig(({ command }) => ({ + resolve: + command === 'serve' + ? { + alias: { + '@qwen-code/sdk/daemon': resolve( + __dirname, + '../sdk-typescript/src/daemon/index.ts', + ), + '@qwen-code/sdk': resolve( + __dirname, + '../sdk-typescript/src/index.ts', + ), + }, + } + : undefined, plugins: [ react(), dts({ @@ -73,4 +79,4 @@ export default defineConfig({ minify: false, cssCodeSplit: false, }, -}); +})); From e3cea3db99603c8838635622518c2f4dfd031e83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Wed, 20 May 2026 18:50:30 +0800 Subject: [PATCH 14/17] fix(daemon): harden ui review edges --- packages/sdk-typescript/scripts/build.js | 43 +++-- packages/sdk-typescript/src/daemon/index.ts | 1 + .../sdk-typescript/src/daemon/ui/index.ts | 1 + .../src/daemon/ui/normalizer.ts | 63 ++----- .../sdk-typescript/src/daemon/ui/store.ts | 19 ++- .../src/daemon/ui/toolPreview.ts | 12 +- .../src/daemon/ui/transcript.ts | 45 ++++- .../sdk-typescript/src/daemon/ui/types.ts | 2 + .../sdk-typescript/src/daemon/ui/utils.ts | 50 ++++++ .../sdk-typescript/test/unit/daemonUi.test.ts | 78 ++++++++- .../components/toolcalls/ThinkToolCall.tsx | 4 +- .../src/daemon/DaemonSessionProvider.test.tsx | 158 ++++++++++++++++++ .../src/daemon/DaemonSessionProvider.tsx | 75 ++++++--- .../src/daemon/transcriptAdapter.test.ts | 12 +- .../webui/src/daemon/transcriptAdapter.ts | 9 +- 15 files changed, 475 insertions(+), 97 deletions(-) diff --git a/packages/sdk-typescript/scripts/build.js b/packages/sdk-typescript/scripts/build.js index 91a4cefbd80..e738d84175e 100755 --- a/packages/sdk-typescript/scripts/build.js +++ b/packages/sdk-typescript/scripts/build.js @@ -145,21 +145,40 @@ function assertBrowserSafeBundle(filePath) { } const contents = readFileSync(filePath, 'utf8'); - const forbidden = [ - 'node:', - 'require("fs")', - "require('fs')", - 'require("path")', - "require('path')", - 'require("http")', - "require('http')", - 'require("https")', - "require('https')", + if (contents.includes('node:')) { + throw new Error('Browser daemon SDK bundle contains Node-only token node:'); + } + const forbiddenBuiltins = [ + 'assert', + 'buffer', + 'child_process', + 'cluster', + 'crypto', + 'fs', + 'http', + 'https', + 'module', + 'net', + 'os', + 'path', + 'perf_hooks', + 'process', + 'readline', + 'stream', + 'tls', + 'tty', + 'url', + 'util', + 'worker_threads', + 'zlib', ]; - const found = forbidden.find((token) => contents.includes(token)); + const requirePattern = new RegExp( + `require\\((["'])(${forbiddenBuiltins.join('|')})(?:/[^"']*)?\\1\\)`, + ); + const found = contents.match(requirePattern); if (found) { throw new Error( - `Browser daemon SDK bundle contains Node-only token ${found}`, + `Browser daemon SDK bundle contains Node-only token ${found[0]}`, ); } } diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 7f3fc49ed23..2e61f8bbe31 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -41,6 +41,7 @@ export { createDaemonToolPreview, createDaemonTranscriptState, createDaemonTranscriptStore, + DAEMON_PLAN_TOOL_CALL_ID, daemonUiEventToTerminalText, getOutputText as getDaemonUiOutputText, getSessionUpdatePayload, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index 91dc61d4438..fe27ae68ab2 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -25,6 +25,7 @@ export { stringifyJson, stripOscSequences, } from './utils.js'; +export { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; export type { DaemonShellTranscriptBlock, DaemonStatusTranscriptBlock, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index da4c822b037..159f6ac9835 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -10,13 +10,16 @@ import type { DaemonUiPermissionOption, NormalizeDaemonEventOptions, } from './types.js'; +import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { getFirstString, getOutputText, getString, getTextContent, isRecord, + redactSensitiveFields, stringifyJson, + stringifyRedactedJson, } from './utils.js'; const MAX_DETAILS_LENGTH = 4096; @@ -264,9 +267,13 @@ function normalizeToolUpdate( ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined - ? { details: capDetails(stringifyJson(rawInput)) } + ? { details: capDetails(stringifyRedactedJson(rawInput)) } : rawOutput !== undefined - ? { details: capDetails(getOutputText(rawOutput)) } + ? { + details: capDetails( + getOutputText(redactSensitiveFields(rawOutput)), + ), + } : {}), }; } @@ -280,7 +287,7 @@ function normalizePlanUpdate( return { ...base, type: 'tool.update', - toolCallId: 'daemon-plan', + toolCallId: DAEMON_PLAN_TOOL_CALL_ID, title: 'Updated Plan', status: 'completed', toolName: 'TodoWrite', @@ -446,53 +453,3 @@ function getShellStream(value: unknown): 'stdout' | 'stderr' | undefined { const stream = getString(value, 'stream'); return stream === 'stdout' || stream === 'stderr' ? stream : undefined; } - -function stringifyRedactedJson(value: unknown): string { - return stringifyJson(redactSensitiveFields(value)); -} - -function redactSensitiveFields(value: unknown, depth = 0): unknown { - if (depth > 16) return '[truncated]'; - if (Array.isArray(value)) { - return value.map((entry) => redactSensitiveFields(entry, depth + 1)); - } - if (!isRecord(value)) return value; - return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - key, - isSensitiveKey(key) - ? '[redacted]' - : redactSensitiveFields(entry, depth + 1), - ]), - ); -} - -function isSensitiveKey(key: string): boolean { - const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); - return ( - normalized === 'authorization' || - normalized === 'auth' || - normalized === 'cookie' || - normalized === 'setcookie' || - normalized === 'credential' || - normalized === 'credentials' || - normalized === 'password' || - normalized === 'passphrase' || - normalized === 'privatekey' || - normalized === 'apikey' || - normalized === 'clientsecret' || - normalized === 'idtoken' || - normalized === 'sessiontoken' || - normalized === 'xapikey' || - normalized === 'xauthkey' || - normalized === 'xauthtoken' || - normalized.endsWith('token') || - normalized.endsWith('secret') || - normalized.endsWith('apikey') || - normalized.endsWith('privatekey') || - normalized.includes('accesstoken') || - normalized.includes('refreshtoken') || - normalized.includes('bearertoken') || - normalized.includes('personalaccesstoken') - ); -} diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index ea80af59b05..faf8b2de6c4 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -24,7 +24,13 @@ export function createDaemonTranscriptStore( let notifyScheduled = false; const notify = () => { - for (const listener of listeners) listener(); + for (const listener of listeners) { + try { + listener(); + } catch (error) { + reportListenerError(error); + } + } }; const scheduleNotify = () => { if (notifyScheduled) return; @@ -65,6 +71,17 @@ export function createDaemonTranscriptStore( }; } +function reportListenerError(error: unknown): void { + const reporter = ( + globalThis as typeof globalThis & { + reportError?: (error: unknown) => void; + } + ).reportError; + if (typeof reporter === 'function') { + reporter(error); + } +} + function createState( seed: Partial, ): DaemonTranscriptState { diff --git a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts index 55b064754e3..79b6ba2b7de 100644 --- a/packages/sdk-typescript/src/daemon/ui/toolPreview.ts +++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts @@ -9,7 +9,12 @@ import type { DaemonTranscriptQuestion, DaemonTranscriptQuestionOption, } from './types.js'; -import { getFirstString, isRecord, stringifyJson } from './utils.js'; +import { + getFirstString, + isRecord, + isSensitiveKey, + stringifyRedactedJson, +} from './utils.js'; const MAX_TOOL_PREVIEW_DEPTH = 8; @@ -117,7 +122,10 @@ function collectPreviewRows( for (const [key, value] of Object.entries(input).slice(0, 4)) { if (value === undefined || value === null || Array.isArray(value)) continue; if (isRecord(value)) continue; - rows.push({ label: key, value: stringifyJson(value) }); + rows.push({ + label: key, + value: isSensitiveKey(key) ? '[redacted]' : stringifyRedactedJson(value), + }); } return rows; } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index e934da1fdb8..7368651e004 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -14,7 +14,9 @@ import type { DaemonTranscriptState, DaemonUiEvent, } from './types.js'; +import { DAEMON_PLAN_TOOL_CALL_ID } from './types.js'; import { createDaemonToolPreview } from './toolPreview.js'; +import { isRecord } from './utils.js'; const DEFAULT_MAX_BLOCKS = 1_000; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; @@ -423,6 +425,7 @@ function trimTranscriptState( state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID; } } + pruneTrimmedToolIndexes(state); for (const [toolCallId] of Object.entries( state.trimmedToolNotificationByCallId, )) { @@ -452,7 +455,8 @@ function shouldRecreateTrimmedToolBlock( event: Extract, ): boolean { return ( - event.toolCallId === 'daemon-plan' || event.toolKind === 'updated_plan' + event.toolCallId === DAEMON_PLAN_TOOL_CALL_ID || + event.toolKind === 'updated_plan' ); } @@ -484,7 +488,19 @@ function cloneBlockForWrite( if (block.kind === 'permission') { return { ...block, - options: block.options.map((option) => ({ ...option })), + options: block.options.map((option) => cloneJsonLike(option)), + toolCall: cloneJsonLike(block.toolCall), + preview: cloneJsonLike(block.preview), + }; + } + if (block.kind === 'tool') { + return { + ...block, + preview: cloneJsonLike(block.preview), + content: cloneJsonLike(block.content), + locations: cloneJsonLike(block.locations), + rawInput: cloneJsonLike(block.rawInput), + rawOutput: cloneJsonLike(block.rawOutput), }; } return { ...block }; @@ -516,6 +532,31 @@ function truncateText(text: string): string { return `${text.slice(0, keepLength)}${TEXT_TRUNCATED_SUFFIX}`; } +function pruneTrimmedToolIndexes(state: DaemonTranscriptState): void { + const maxTrimmedEntries = Math.max(0, state.maxBlocks); + const trimmedToolCallIds = Object.entries(state.toolBlockByCallId) + .filter(([, blockId]) => blockId === TRIMMED_TOOL_BLOCK_ID) + .map(([toolCallId]) => toolCallId); + const overflow = trimmedToolCallIds.length - maxTrimmedEntries; + if (overflow <= 0) return; + for (const toolCallId of trimmedToolCallIds.slice(0, overflow)) { + delete state.toolBlockByCallId[toolCallId]; + delete state.trimmedToolNotificationByCallId[toolCallId]; + } +} + +function cloneJsonLike(value: T): T { + if (Array.isArray(value)) { + return value.map((entry) => cloneJsonLike(entry)) as T; + } + if (isRecord(value)) { + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [key, cloneJsonLike(entry)]), + ) as T; + } + return value; +} + function assertNever(value: never): never { throw new Error( `Unhandled daemon transcript event: ${JSON.stringify(value)}`, diff --git a/packages/sdk-typescript/src/daemon/ui/types.ts b/packages/sdk-typescript/src/daemon/ui/types.ts index 995a8d06c35..e0eb4517628 100644 --- a/packages/sdk-typescript/src/daemon/ui/types.ts +++ b/packages/sdk-typescript/src/daemon/ui/types.ts @@ -6,6 +6,8 @@ import type { DaemonEvent, PermissionResponse } from '../types.js'; +export const DAEMON_PLAN_TOOL_CALL_ID = 'daemon-plan'; + export type DaemonUiEventType = | 'user.text.delta' | 'assistant.text.delta' diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index a3e344e8994..2c862c4ff2d 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -38,6 +38,56 @@ export function stringifyJson(value: unknown): string { } } +export function stringifyRedactedJson(value: unknown): string { + return stringifyJson(redactSensitiveFields(value)); +} + +export function redactSensitiveFields(value: unknown, depth = 0): unknown { + if (depth > 16) return '[truncated]'; + if (Array.isArray(value)) { + return value.map((entry) => redactSensitiveFields(entry, depth + 1)); + } + if (!isRecord(value)) return value; + return Object.fromEntries( + Object.entries(value).map(([key, entry]) => [ + key, + isSensitiveKey(key) + ? '[redacted]' + : redactSensitiveFields(entry, depth + 1), + ]), + ); +} + +export function isSensitiveKey(key: string): boolean { + const normalized = key.toLowerCase().replace(/[^a-z0-9]/g, ''); + return ( + normalized === 'authorization' || + normalized === 'auth' || + normalized === 'cookie' || + normalized === 'setcookie' || + normalized === 'credential' || + normalized === 'credentials' || + normalized === 'password' || + normalized === 'passphrase' || + normalized === 'privatekey' || + normalized === 'apikey' || + normalized === 'clientsecret' || + normalized === 'idtoken' || + normalized === 'sessiontoken' || + normalized === 'xapikey' || + normalized === 'xauthkey' || + normalized === 'xauthtoken' || + normalized.endsWith('token') || + normalized.endsWith('secret') || + normalized.endsWith('apikey') || + normalized.endsWith('privatekey') || + normalized.includes('accesstoken') || + normalized.includes('refreshtoken') || + normalized.includes('bearertoken') || + normalized.includes('personalaccesstoken') + ); +} + export function getTextContent(value: unknown): string { if (typeof value === 'string') return value; if (!isRecord(value)) return ''; diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index a1c9c9f9a21..d51e313ff91 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { appendLocalUserTranscriptMessage, createDaemonToolPreview, @@ -404,6 +404,25 @@ describe('daemon UI normalizer and transcript reducer', () => { ]); }); + it('bounds trimmed tool indexes while keeping recent trimmed diagnostics', () => { + const state = reduceDaemonTranscriptEvents( + createDaemonTranscriptState({ maxBlocks: 2, now: 1 }), + Array.from({ length: 8 }, (_, index) => ({ + type: 'tool.update' as const, + toolCallId: `tool-${index}`, + title: `Tool ${index}`, + status: 'running', + })), + { now: 2 }, + ); + + const trimmedToolCallIds = Object.entries(state.toolBlockByCallId) + .filter(([, blockId]) => blockId === '__trimmed_tool_block__') + .map(([toolCallId]) => toolCallId); + expect(trimmedToolCallIds).toHaveLength(2); + expect(Object.keys(state.toolBlockByCallId)).toHaveLength(4); + }); + it('keeps active assistant text open when reporting trimmed tool updates', () => { let state = createDaemonTranscriptState({ maxBlocks: 2, now: 1 }); @@ -578,7 +597,7 @@ describe('daemon UI normalizer and transcript reducer', () => { sessionUpdate: 'tool_call', toolCallId: 'large-input', title: 'Large input', - rawInput: { text: 'x'.repeat(5000) }, + rawInput: { text: 'x'.repeat(5000), apiKey: 'input-secret' }, }, }, }); @@ -591,7 +610,7 @@ describe('daemon UI normalizer and transcript reducer', () => { sessionUpdate: 'tool_call', toolCallId: 'large-output', title: 'Large output', - rawOutput: 'y'.repeat(5000), + rawOutput: { text: 'y'.repeat(5000), token: 'output-secret' }, }, }, }); @@ -610,6 +629,12 @@ describe('daemon UI normalizer and transcript reducer', () => { expect( outputEvent && 'details' in outputEvent ? outputEvent.details?.length : 0, ).toBeLessThan(4200); + expect( + inputEvent && 'details' in inputEvent ? inputEvent.details : '', + ).not.toContain('input-secret'); + expect( + outputEvent && 'details' in outputEvent ? outputEvent.details : '', + ).not.toContain('output-secret'); }); it('marks active assistant block complete when a tool interrupts the stream', () => { @@ -1076,6 +1101,36 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(calls).toBe(2); }); + it('keeps notifying store listeners when one listener throws', async () => { + const store = createDaemonTranscriptStore(); + const globalWithReportError = globalThis as typeof globalThis & { + reportError?: (error: unknown) => void; + }; + const originalReportError = globalWithReportError.reportError; + const reportError = vi.fn(); + globalWithReportError.reportError = reportError; + let calls = 0; + store.subscribe(() => { + throw new Error('listener failed'); + }); + store.subscribe(() => { + calls += 1; + }); + + try { + store.dispatch({ type: 'status', text: 'ready' }); + await Promise.resolve(); + expect(calls).toBe(1); + expect(reportError).toHaveBeenCalledWith(expect.any(Error)); + } finally { + if (originalReportError) { + globalWithReportError.reportError = originalReportError; + } else { + delete globalWithReportError.reportError; + } + } + }); + it('renders UI events to sanitized terminal text', () => { const output = daemonUiEventToTerminalText({ type: 'shell.output', @@ -1151,4 +1206,21 @@ describe('daemon UI normalizer and transcript reducer', () => { kind: 'generic', }); }); + + it('redacts sensitive values in generic tool previews', () => { + expect( + createDaemonToolPreview({ + apiKey: 'secret-key', + password: 'secret-password', + visible: 'ok', + }), + ).toMatchObject({ + kind: 'key_value', + rows: [ + { label: 'apiKey', value: '[redacted]' }, + { label: 'password', value: '[redacted]' }, + { label: 'visible', value: 'ok' }, + ], + }); + }); }); diff --git a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx index b391d3d5f1f..7d041d80c34 100644 --- a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx +++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx @@ -70,7 +70,9 @@ export const ThinkToolCall: FC = ({ ? 'loading' : toolCall.status === 'failed' ? 'error' - : 'default'; + : toolCall.status === 'cancelled' + ? 'warning' + : 'default'; return ( { ).toBe(false); }); + it('ends assistant streaming when prompt fails with a non-abort error', async () => { + const prompt = createDeferred(); + const assistantChunk = createDeferred(); + const session = createMockSession({ + prompt: vi.fn(() => prompt.promise), + events: async function* assistantThenIdleEvents( + opts: { signal?: AbortSignal } = {}, + ) { + await assistantChunk.promise; + yield { + id: 11, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'partial' }, + }, + }, + }; + await new Promise((resolve) => { + if (opts.signal?.aborted) { + resolve(); + return; + } + opts.signal?.addEventListener('abort', () => resolve(), { + once: true, + }); + }); + }, + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + const providerActions = requireActions(actions); + + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('fail later'); + await flushPromises(); + assistantChunk.resolve(); + await flushPromises(); + }); + expect(blocks).toMatchObject([ + { kind: 'user', text: 'fail later' }, + { kind: 'assistant', text: 'partial', streaming: true }, + ]); + + prompt.reject(new Error('network down')); + const failedPrompt = promptResult; + if (!failedPrompt) throw new Error('prompt was not started'); + await act(async () => { + await expect(failedPrompt).rejects.toThrow('network down'); + }); + + expect(blocks).toMatchObject([ + { kind: 'user', text: 'fail later' }, + { kind: 'assistant', text: 'partial', streaming: false }, + { kind: 'error', text: 'Prompt failed: network down' }, + ]); + }); + it('clears prompt state and transcript when reconnect attaches a different session', async () => { const firstEvents = createClosableEvents(); const firstSession = createMockSession({ @@ -522,6 +592,54 @@ describe('DaemonSessionProvider', () => { expect(connection).toMatchObject({ sessionId: 'session-b' }); }); + it('does not reconnect when event processing options change', async () => { + const session = createMockSession({ events: createIdleEvents() }); + sdkMocks.sessions.push(session); + + function Harness() { + return null; + } + + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + + act(() => { + root?.render( + + + , + ); + }); + await act(async () => { + await flushPromises(); + }); + + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledTimes(1); + }); + it('surfaces SSE stream end and clears the session when reconnect is disabled', async () => { const session = createMockSession({ events: createClosedEvents() }); sdkMocks.sessions.push(session); @@ -557,6 +675,46 @@ describe('DaemonSessionProvider', () => { }); }); + it('clears stale sessions on terminal HTTP stream errors', async () => { + const session = createMockSession({ + events: async function* terminalErrorEvents() { + await Promise.resolve(); + yield* []; + throw Object.assign(new Error('session gone'), { status: 410 }); + }, + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: false, + }); + const providerActions = requireActions(actions); + + await act(async () => { + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'error', + error: 'session gone', + }); + expect(connection?.sessionId).toBeUndefined(); + await act(async () => { + await expect(providerActions.cancel()).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + }); + async function renderWithProvider( children: ReactNode, props: Partial = {}, diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 79bfe96a52a..34fa3315d38 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -75,6 +75,8 @@ const DaemonConnectionContext = createContext< const DaemonActionsContext = createContext( undefined, ); +const DEFAULT_ACTION_TIMEOUT_MS = 30_000; +const TERMINAL_SESSION_HTTP_STATUSES = new Set([401, 403, 404, 410]); export function DaemonSessionProvider({ baseUrl, @@ -95,6 +97,10 @@ export function DaemonSessionProvider({ const lastSessionIdRef = useRef(undefined); const promptAbortRef = useRef(undefined); const promptBusyRef = useRef(false); + const eventOptionsRef = useRef({ suppressOwnUserEcho, includeRawEvent }); + const reconnectConfigRef = useRef({ reconnectDelayMs, maxReconnectDelayMs }); + eventOptionsRef.current = { suppressOwnUserEcho, includeRawEvent }; + reconnectConfigRef.current = { reconnectDelayMs, maxReconnectDelayMs }; const modelServiceId = createSessionRequest?.modelServiceId; const sessionScope = createSessionRequest?.sessionScope; const [connection, setConnection] = useState({ @@ -157,10 +163,11 @@ export function DaemonSessionProvider({ reconnectAttempt = 0; } try { + const eventOptions = eventOptionsRef.current; const uiEvents = normalizeDaemonEvent(event, { clientId: session.clientId, - suppressOwnUserEcho, - includeRawEvent, + suppressOwnUserEcho: eventOptions.suppressOwnUserEcho, + includeRawEvent: eventOptions.includeRawEvent, }); store.dispatch(uiEvents); } catch (error) { @@ -174,6 +181,8 @@ export function DaemonSessionProvider({ } } if (!disposed && !abort.signal.aborted) { + // Keep the session handle after a normal SSE close so the next + // subscription can resume from DaemonSessionClient.lastEventId. store.dispatch({ type: 'status', text: 'SSE stream ended', @@ -184,8 +193,8 @@ export function DaemonSessionProvider({ const message = error instanceof Error ? error.message : String(error); store.dispatch({ type: 'error', text: message, recoverable: true }); - const sessionMissing = isDaemonHttpNotFound(error); - if (sessionMissing) { + const terminalSessionError = isTerminalSessionHttpError(error); + if (terminalSessionError) { session = undefined; sessionRef.current = undefined; } @@ -225,10 +234,11 @@ export function DaemonSessionProvider({ } reconnectAttempt += 1; + const reconnectConfig = reconnectConfigRef.current; const delayMs = getReconnectDelayMs( reconnectAttempt, - reconnectDelayMs, - maxReconnectDelayMs, + reconnectConfig.reconnectDelayMs, + reconnectConfig.maxReconnectDelayMs, ); setConnection((current) => ({ ...current, @@ -257,10 +267,6 @@ export function DaemonSessionProvider({ modelServiceId, sessionScope, maxQueued, - suppressOwnUserEcho, - includeRawEvent, - reconnectDelayMs, - maxReconnectDelayMs, store, ]); @@ -297,6 +303,7 @@ export function DaemonSessionProvider({ store.dispatch({ type: 'assistant.done', reason: 'cancelled' }); return { stopReason: 'cancelled' }; } + store.dispatch({ type: 'assistant.done', reason: 'error' }); throw dispatchActionError(store, 'Prompt failed', error); } finally { if (promptAbortRef.current === promptAbort) { @@ -323,7 +330,7 @@ export function DaemonSessionProvider({ throw error; } try { - await session.cancel(); + await withActionTimeout(session.cancel(), 'Cancel timed out'); } catch (error) { throw dispatchActionError(store, 'Cancel failed', error); } finally { @@ -339,7 +346,10 @@ export function DaemonSessionProvider({ 'Set model failed', ); try { - return await session.setModel(modelId); + return await withActionTimeout( + session.setModel(modelId), + 'Set model timed out', + ); } catch (error) { throw dispatchActionError(store, 'Set model failed', error); } @@ -354,7 +364,10 @@ export function DaemonSessionProvider({ 'Permission response failed', ); try { - return await session.respondToSessionPermission(requestId, response); + return await withActionTimeout( + session.respondToSessionPermission(requestId, response), + 'Permission response timed out', + ); } catch (error) { throw dispatchActionError(store, 'Permission response failed', error); } @@ -462,11 +475,14 @@ function isAbortError(error: unknown): boolean { ); } -function isDaemonHttpNotFound(error: unknown): boolean { - return ( - (error instanceof DaemonHttpError && error.status === 404) || - (isRecord(error) && error['status'] === 404) - ); +function isTerminalSessionHttpError(error: unknown): boolean { + const status = + error instanceof DaemonHttpError + ? error.status + : isRecord(error) && typeof error['status'] === 'number' + ? error['status'] + : undefined; + return status !== undefined && TERMINAL_SESSION_HTTP_STATUSES.has(status); } function isRecord(value: unknown): value is Record { @@ -487,7 +503,28 @@ function getReconnectDelayMs( ? Math.max(base, maxReconnectDelayMs) : base; const exponential = base * 2 ** Math.max(0, attempt - 1); - return Math.min(exponential, max); + const capped = Math.min(exponential, max); + const jitter = 0.8 + Math.random() * 0.4; + return Math.min(max, Math.max(1, Math.round(capped * jitter))); +} + +async function withActionTimeout( + promise: Promise, + message: string, +): Promise { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error(`${message} after ${DEFAULT_ACTION_TIMEOUT_MS}ms`)); + }, DEFAULT_ACTION_TIMEOUT_MS); + }), + ]); + } finally { + if (timer !== undefined) clearTimeout(timer); + } } function delay(delayMs: number, signal: AbortSignal): Promise { diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index edba6b6b5c1..189df3e9057 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -30,15 +30,21 @@ describe('daemonTranscriptToUnifiedMessages', () => { }); }); - it('preserves cancelled and unknown daemon tool statuses', () => { + it('maps daemon tool statuses without leaving terminal states spinning', () => { const messages = daemonTranscriptToUnifiedMessages([ createToolBlock('cancelled-tool', 'cancelled'), - createToolBlock('future-tool', 'waiting_for_input'), + createToolBlock('waiting-tool', 'waiting_for_input'), + createToolBlock('skipped-tool', 'skipped'), + createToolBlock('timeout-tool', 'timeout'), + createToolBlock('future-tool', 'future_status'), ]); expect(messages.map((message) => message.toolCall?.status)).toEqual([ 'cancelled', - 'in_progress', + 'pending', + 'cancelled', + 'failed', + 'failed', ]); }); diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index 04efb86df7f..b63001e3a3b 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -196,12 +196,19 @@ function normalizeToolStatus(status: string): ToolCallStatus { return 'completed'; case 'canceled': case 'cancelled': + case 'skipped': return 'cancelled'; + case 'waiting': + case 'waiting_for_input': + case 'queued': + return 'pending'; case 'failed': case 'error': + case 'timeout': + case 'timed_out': return 'failed'; default: - return 'in_progress'; + return 'failed'; } } From cb2720ad571bcc19edbc2dd59cc7d85b5293742d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 21 May 2026 10:18:01 +0800 Subject: [PATCH 15/17] fix(daemon-ui): address wenshao 2 Critical findings (#4328 review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Critical #1 — 401/403 reconnect storm + transcript wipe `DaemonSessionProvider`'s reconnect loop kept retrying `createOrAttach` on 401/403 even with `autoReconnect: true`. Each cycle: - hit the daemon with the same bad token → 401 again - cleared the session handle - the next successful attempt (if token magically recovered) would receive a different sessionId, triggering the `store.reset()` branch at line 143 and wiping the user's transcript - no terminal "auth failed" state surfaced to the user Fix: split `TERMINAL_SESSION_HTTP_STATUSES` into `AUTH_FAILURE_HTTP_STATUSES` (401, 403) and the rest (404, 410). On auth failure, return from the reconnect loop unconditionally regardless of the `autoReconnect` flag — these are credential failures, not transient. The user must update credentials; daemon spam must stop. `extractHttpStatus` helper factored out of `isTerminalSessionHttpError` to share between the two predicates. ## Critical #2 — rawInput / rawOutput leaking secrets to UI `normalizer.normalizeToolUpdate` forwarded `rawInput` / `rawOutput` verbatim onto `DaemonUiToolUpdateEvent` → `DaemonToolTranscriptBlock`. The `details` projection was redacted via `stringifyRedactedJson` / `redactSensitiveFields`, but the underlying `rawInput` / `rawOutput` fields were unredacted. Any UI component that read those fields directly (ShellToolCall, WriteToolCall, JSON debug panels) leaked the raw values to the DOM. Example: `{ command: 'curl', apiKey: 'sk-prod-...' }` had `apiKey` redacted in `details` but exposed verbatim on `rawInput`. Fix: apply `redactSensitiveFields` to both `rawInput` and `rawOutput` ONCE at the normalizer boundary, then reuse the redacted shape for the `details` projection. Downstream is uniformly safe; no double traversal. ## Tests (49/49 pass) - SDK `daemonUi.test.ts` (36 tests, +1) — new test `redacts sensitive fields in tool.update rawInput and rawOutput at normalizer boundary` verifies full-event string scan finds zero secret values + structural keys preserved with values `'[redacted]'`. - WebUI `DaemonSessionProvider.test.tsx` (13 tests, +2) — new tests `breaks out of the reconnect loop on 401 / 403 auth failures even when autoReconnect is true` and `still reconnects on 404 / 410 session-not-found errors when autoReconnect is true` lock in the asymmetry: auth failure → 1 attempt only; session-not-found → retries until success. ## Out of scope (declined / deferred — see PR review reply) - CRIT #3 `withActionTimeout` test coverage gap → behavior correct, test-only follow-up (avoids PR bloat) - Suggestions #4-7 → 4 nice-to-haves, deferred to keep PR focused on production-correctness fixes Generated with AI Co-authored-by: Claude Opus 4.7 --- .../src/daemon/ui/normalizer.ts | 31 +++++-- .../sdk-typescript/test/unit/daemonUi.test.ts | 61 +++++++++++++ .../src/daemon/DaemonSessionProvider.test.tsx | 88 +++++++++++++++++++ .../src/daemon/DaemonSessionProvider.tsx | 48 ++++++++-- 4 files changed, 214 insertions(+), 14 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 159f6ac9835..fb4fa068356 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -240,8 +240,27 @@ function normalizeToolUpdate( (metadata ? getString(metadata, 'name') : undefined); const toolKind = getString(update, 'kind'); const title = getString(update, 'title') ?? toolName ?? toolKind; - const rawInput = update['rawInput'] ?? update['input'] ?? update['args']; - const rawOutput = update['rawOutput'] ?? update['output'] ?? update['result']; + const rawInputSource = + update['rawInput'] ?? update['input'] ?? update['args']; + const rawOutputSource = + update['rawOutput'] ?? update['output'] ?? update['result']; + // Redact sensitive fields (apiKey / token / password / etc.) at the + // normalizer boundary so the raw values never reach the UI layer. The + // pre-redaction shape goes into `details` / `getOutputText` already, but + // `rawInput` / `rawOutput` are also forwarded verbatim onto the + // `DaemonToolTranscriptBlock` and any UI component that renders them + // (e.g., ShellToolCall, WriteToolCall, JSON debug panels) would expose + // the unredacted secret. Redact ONCE here so downstream is uniformly + // safe; the `details` / `getOutputText` paths below reuse the same + // redacted shape, avoiding redundant traversal. + const rawInput = + rawInputSource !== undefined + ? redactSensitiveFields(rawInputSource) + : undefined; + const rawOutput = + rawOutputSource !== undefined + ? redactSensitiveFields(rawOutputSource) + : undefined; const content = update['content']; const locations = update['locations']; const toolCallId = getString(update, 'toolCallId'); @@ -267,13 +286,9 @@ function normalizeToolUpdate( ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined - ? { details: capDetails(stringifyRedactedJson(rawInput)) } + ? { details: capDetails(stringifyJson(rawInput)) } : rawOutput !== undefined - ? { - details: capDetails( - getOutputText(redactSensitiveFields(rawOutput)), - ), - } + ? { details: capDetails(getOutputText(rawOutput)) } : {}), }; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index d51e313ff91..2ec2ddb1c96 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1223,4 +1223,65 @@ describe('daemon UI normalizer and transcript reducer', () => { ], }); }); + + it('redacts sensitive fields in tool.update rawInput and rawOutput at normalizer boundary (wenshao CRIT #2)', () => { + const events = normalizeDaemonEvent({ + id: 1, + v: 1, + type: 'session_update', + data: { + update: { + sessionUpdate: 'tool_call', + toolCallId: 't-secret', + title: 'Run curl', + status: 'completed', + name: 'Bash', + rawInput: { + command: 'curl https://api.example.com', + apiKey: 'sk-prod-do-not-leak', + headers: { Authorization: 'Bearer secret-do-not-leak' }, + }, + rawOutput: { + text: 'OK', + token: 'returned-secret-do-not-leak', + }, + }, + }, + } as never); + const event = events[0] as Extract; + + expect(event.type).toBe('tool.update'); + expect(event.rawInput).toBeDefined(); + expect(event.rawOutput).toBeDefined(); + + // Full-event string scan: no secret value can survive end-to-end. + // Previously these leaked into `rawInput` / `rawOutput`, exposing them + // to any UI component that JSON.stringify-ed the event or rendered + // those fields in a debug panel. + const serialized = JSON.stringify(event); + expect(serialized).not.toContain('sk-prod-do-not-leak'); + expect(serialized).not.toContain('Bearer secret-do-not-leak'); + expect(serialized).not.toContain('returned-secret-do-not-leak'); + + // Structural keys preserved; only sensitive VALUES are redacted. + expect((event.rawInput as Record).apiKey).toBe( + '[redacted]', + ); + expect( + ( + (event.rawInput as Record).headers as Record< + string, + unknown + > + ).Authorization, + ).toBe('[redacted]'); + expect((event.rawOutput as Record).token).toBe( + '[redacted]', + ); + // Non-sensitive fields survive verbatim. + expect((event.rawInput as Record).command).toBe( + 'curl https://api.example.com', + ); + expect((event.rawOutput as Record).text).toBe('OK'); + }); }); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index 9acb7aebad1..5eccab1be9e 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -715,6 +715,94 @@ describe('DaemonSessionProvider', () => { }); }); + it('breaks out of the reconnect loop on 401 / 403 auth failures even when autoReconnect is true (wenshao CRIT #1)', async () => { + // Simulates a daemon that consistently returns 401 (bad token). Pre-fix, + // autoReconnect: true would loop forever calling createOrAttach, + // generating a reconnection storm and wiping the user's transcript on + // every cycle. After the fix, the provider treats 401/403 as terminal + // regardless of autoReconnect. + let createAttempts = 0; + sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( + async () => { + createAttempts += 1; + throw Object.assign(new Error('Unauthorized'), { status: 401 }); + }, + ); + + let connection: DaemonConnectionState | undefined; + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, // ← critical: must NOT loop + reconnectDelayMs: 1, // keep timing tight in case it does loop + maxReconnectDelayMs: 1, + }); + + await act(async () => { + await flushPromises(); + }); + // Give any potential reconnect timer a window to fire. + await act(async () => { + await wait(20); + await flushPromises(); + }); + + expect(connection).toMatchObject({ + status: 'error', + error: 'Unauthorized', + }); + // No sessionId on auth-failure terminal state. + expect(connection?.sessionId).toBeUndefined(); + // Crucial: only ONE attempt happened. Pre-fix, multiple attempts would + // have occurred during the wait above. We assert exactly 1 to lock in + // the no-storm invariant. + expect(createAttempts).toBe(1); + }); + + it('still reconnects on 404 / 410 session-not-found errors when autoReconnect is true', async () => { + // Verifies the fix did NOT over-correct: 404/410 are session-not-found + // (recoverable by creating a fresh session), not credential failures. + // Pre-fix and post-fix behavior should be identical for these statuses. + let createAttempts = 0; + sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( + async () => { + createAttempts += 1; + if (createAttempts === 1) { + throw Object.assign(new Error('session gone'), { status: 410 }); + } + // Second attempt succeeds. + return createMockSession({ sessionId: `session-${createAttempts}` }); + }, + ); + + let connection: DaemonConnectionState | undefined; + function Harness() { + connection = useDaemonConnection(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + + await act(async () => { + await wait(30); + await flushPromises(); + }); + + // 410 path: SHOULD have retried at least once (so connect succeeded on + // attempt #2). This is the contrast case with the 401 test above. + expect(createAttempts).toBeGreaterThanOrEqual(2); + expect(connection?.status).not.toBe('error'); + }); + async function renderWithProvider( children: ReactNode, props: Partial = {}, diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 34fa3315d38..2c90d118798 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -77,6 +77,19 @@ const DaemonActionsContext = createContext( ); const DEFAULT_ACTION_TIMEOUT_MS = 30_000; const TERMINAL_SESSION_HTTP_STATUSES = new Set([401, 403, 404, 410]); +/** + * Subset of TERMINAL_SESSION_HTTP_STATUSES that represent **credential + * failures** (vs session-not-found 404/410). Auth failures should NOT enter + * the reconnect loop even when `autoReconnect: true` — retrying with the + * same bad token loops forever, hammering the server with bad credentials + * and wiping the user's transcript on each cycle (the sessionId-change + * `store.reset()` path at line 143 fires when each fresh attempt produces a + * different sessionId). + * + * 404/410 (session-not-found) keep the reconnect-then-recreate behavior — + * those are recoverable by creating a fresh session. + */ +const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]); export function DaemonSessionProvider({ baseUrl, @@ -198,6 +211,21 @@ export function DaemonSessionProvider({ session = undefined; sessionRef.current = undefined; } + // Auth failures (401 / 403) must NOT retry even when + // `autoReconnect: true`. Retrying with the same invalid token + // loops forever — the daemon keeps returning 401, each cycle + // wipes the user's transcript (via the sessionId-change branch + // in line 143), and the user sees no actionable error state. + // Surface as a terminal 'error' connection state regardless of + // the autoReconnect setting; the user must update credentials. + if (isAuthFailureHttpError(error)) { + sessionRef.current = undefined; + setConnection({ + status: 'error', + error: message, + }); + return; + } if (!autoReconnect) { sessionRef.current = undefined; setConnection({ @@ -476,15 +504,23 @@ function isAbortError(error: unknown): boolean { } function isTerminalSessionHttpError(error: unknown): boolean { - const status = - error instanceof DaemonHttpError - ? error.status - : isRecord(error) && typeof error['status'] === 'number' - ? error['status'] - : undefined; + const status = extractHttpStatus(error); return status !== undefined && TERMINAL_SESSION_HTTP_STATUSES.has(status); } +function isAuthFailureHttpError(error: unknown): boolean { + const status = extractHttpStatus(error); + return status !== undefined && AUTH_FAILURE_HTTP_STATUSES.has(status); +} + +function extractHttpStatus(error: unknown): number | undefined { + if (error instanceof DaemonHttpError) return error.status; + if (isRecord(error) && typeof error['status'] === 'number') { + return error['status']; + } + return undefined; +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value); } From f374920d98eed5ffa80d4ffc38bb2bb1a10f52e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 21 May 2026 15:04:20 +0800 Subject: [PATCH 16/17] fix(daemon-ui): redact tool details in web transcript --- packages/sdk-typescript/src/daemon/index.ts | 2 ++ .../sdk-typescript/src/daemon/ui/index.ts | 2 ++ .../src/daemon/ui/normalizer.ts | 14 ++++------- .../sdk-typescript/test/unit/daemonUi.test.ts | 4 ++++ .../src/daemon/transcriptAdapter.test.ts | 23 +++++++++++++++---- .../webui/src/daemon/transcriptAdapter.ts | 14 +++++++---- 6 files changed, 41 insertions(+), 18 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts index 2e61f8bbe31..8f841b3f8ac 100644 --- a/packages/sdk-typescript/src/daemon/index.ts +++ b/packages/sdk-typescript/src/daemon/index.ts @@ -45,7 +45,9 @@ export { daemonUiEventToTerminalText, getOutputText as getDaemonUiOutputText, getSessionUpdatePayload, + isDaemonUiSensitiveKey, normalizeDaemonEvent, + redactDaemonUiSensitiveFields, rebuildDaemonTranscriptBlockIndex, reduceDaemonTranscriptEvents, sanitizeTerminalText as sanitizeDaemonTerminalText, diff --git a/packages/sdk-typescript/src/daemon/ui/index.ts b/packages/sdk-typescript/src/daemon/ui/index.ts index fe27ae68ab2..1628e8e589d 100644 --- a/packages/sdk-typescript/src/daemon/ui/index.ts +++ b/packages/sdk-typescript/src/daemon/ui/index.ts @@ -21,6 +21,8 @@ export { } from './terminal.js'; export { getOutputText, + isSensitiveKey as isDaemonUiSensitiveKey, + redactSensitiveFields as redactDaemonUiSensitiveFields, sanitizeTerminalText, stringifyJson, stripOscSequences, diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index fb4fa068356..53c17ec1c74 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -245,14 +245,8 @@ function normalizeToolUpdate( const rawOutputSource = update['rawOutput'] ?? update['output'] ?? update['result']; // Redact sensitive fields (apiKey / token / password / etc.) at the - // normalizer boundary so the raw values never reach the UI layer. The - // pre-redaction shape goes into `details` / `getOutputText` already, but - // `rawInput` / `rawOutput` are also forwarded verbatim onto the - // `DaemonToolTranscriptBlock` and any UI component that renders them - // (e.g., ShellToolCall, WriteToolCall, JSON debug panels) would expose - // the unredacted secret. Redact ONCE here so downstream is uniformly - // safe; the `details` / `getOutputText` paths below reuse the same - // redacted shape, avoiding redundant traversal. + // normalizer boundary so raw values never reach transcript blocks, terminal + // details, or downstream UI components. const rawInput = rawInputSource !== undefined ? redactSensitiveFields(rawInputSource) @@ -286,9 +280,9 @@ function normalizeToolUpdate( ...(rawInput !== undefined ? { rawInput } : {}), ...(rawOutput !== undefined ? { rawOutput } : {}), ...(rawInput !== undefined - ? { details: capDetails(stringifyJson(rawInput)) } + ? { details: capDetails(stringifyRedactedJson(rawInput)) } : rawOutput !== undefined - ? { details: capDetails(getOutputText(rawOutput)) } + ? { details: capDetails(stringifyRedactedJson(rawOutput)) } : {}), }; } diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 2ec2ddb1c96..3d63f1e9e3d 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -1283,5 +1283,9 @@ describe('daemon UI normalizer and transcript reducer', () => { 'curl https://api.example.com', ); expect((event.rawOutput as Record).text).toBe('OK'); + expect(event.details).toContain('[redacted]'); + expect(event.details).not.toContain('sk-prod-do-not-leak'); + expect(event.details).not.toContain('Bearer secret-do-not-leak'); + expect(event.details).not.toContain('returned-secret-do-not-leak'); }); }); diff --git a/packages/webui/src/daemon/transcriptAdapter.test.ts b/packages/webui/src/daemon/transcriptAdapter.test.ts index 189df3e9057..5b7d619970e 100644 --- a/packages/webui/src/daemon/transcriptAdapter.test.ts +++ b/packages/webui/src/daemon/transcriptAdapter.test.ts @@ -100,8 +100,15 @@ describe('daemonTranscriptToUnifiedMessages', () => { title: '\u202eRun', status: 'completed', preview: { kind: 'generic' }, - rawInput: { '\u202ecommand': '\u202enpm test' }, - rawOutput: '\u001b]0;bad\u0007ok', + rawInput: { + '\u202ecommand': '\u202enpm test', + apiKey: 'secret-input', + headers: { Authorization: 'Bearer secret-auth' }, + }, + rawOutput: { + token: 'secret-output', + text: '\u001b]0;bad\u0007ok', + }, createdAt: 2, updatedAt: 2, }, @@ -110,9 +117,17 @@ describe('daemonTranscriptToUnifiedMessages', () => { expect(messages[0]?.content).toBe('txt.exered'); expect(messages[1]?.toolCall).toMatchObject({ title: 'Run', - rawInput: { command: 'npm test' }, - rawOutput: 'ok', + rawInput: { + command: 'npm test', + apiKey: '[redacted]', + headers: { Authorization: '[redacted]' }, + }, + rawOutput: { + token: '[redacted]', + text: 'ok', + }, }); + expect(JSON.stringify(messages[1]?.toolCall)).not.toContain('secret-'); }); it('renders shell and status text as visible tool content', () => { diff --git a/packages/webui/src/daemon/transcriptAdapter.ts b/packages/webui/src/daemon/transcriptAdapter.ts index b63001e3a3b..bdd0ed4f31f 100644 --- a/packages/webui/src/daemon/transcriptAdapter.ts +++ b/packages/webui/src/daemon/transcriptAdapter.ts @@ -5,6 +5,7 @@ */ import { + isDaemonUiSensitiveKey, sanitizeDaemonTerminalText, type DaemonTranscriptBlock, type DaemonToolTranscriptBlock, @@ -337,10 +338,15 @@ function sanitizeDaemonValue(value: unknown, depth = 0): unknown { } if (!isRecord(value)) return value; return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [ - sanitizeDisplayText(key), - sanitizeDaemonValue(entry, depth + 1), - ]), + Object.entries(value).map(([key, entry]) => { + const sanitizedKey = sanitizeDisplayText(key); + return [ + sanitizedKey, + isDaemonUiSensitiveKey(key) + ? '[redacted]' + : sanitizeDaemonValue(entry, depth + 1), + ]; + }), ); } From 905a436fb616f875ba56471694b198d7d7132a89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A7=A6=E5=A5=87?= Date: Thu, 21 May 2026 15:11:14 +0800 Subject: [PATCH 17/17] fix(daemon-ui): close review gaps in transcript safety --- .../src/daemon/ui/normalizer.ts | 19 +- .../sdk-typescript/src/daemon/ui/store.ts | 5 + .../src/daemon/ui/transcript.ts | 11 +- .../sdk-typescript/src/daemon/ui/utils.ts | 3 + .../sdk-typescript/test/unit/daemonUi.test.ts | 152 ++++++++- .../src/daemon/DaemonSessionProvider.test.tsx | 319 +++++++++++++----- .../src/daemon/DaemonSessionProvider.tsx | 12 +- 7 files changed, 428 insertions(+), 93 deletions(-) diff --git a/packages/sdk-typescript/src/daemon/ui/normalizer.ts b/packages/sdk-typescript/src/daemon/ui/normalizer.ts index 53c17ec1c74..4e028ea01ad 100644 --- a/packages/sdk-typescript/src/daemon/ui/normalizer.ts +++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts @@ -144,7 +144,9 @@ function createBase( ...(event.originatorClientId ? { originatorClientId: event.originatorClientId } : {}), - ...(opts.includeRawEvent ? { rawEvent: event } : {}), + ...(opts.includeRawEvent + ? { rawEvent: { ...event, data: redactSensitiveFields(event.data) } } + : {}), }; } @@ -255,8 +257,14 @@ function normalizeToolUpdate( rawOutputSource !== undefined ? redactSensitiveFields(rawOutputSource) : undefined; - const content = update['content']; - const locations = update['locations']; + const content = + update['content'] !== undefined + ? redactSensitiveFields(update['content']) + : undefined; + const locations = + update['locations'] !== undefined + ? redactSensitiveFields(update['locations']) + : undefined; const toolCallId = getString(update, 'toolCallId'); const status = getString(update, 'status'); if (!toolCallId) { @@ -364,7 +372,10 @@ function normalizePermissionRequest( ]; } - const toolCall = event.data['toolCall']; + const toolCall = + event.data['toolCall'] !== undefined + ? redactSensitiveFields(event.data['toolCall']) + : undefined; return [ { ...base, diff --git a/packages/sdk-typescript/src/daemon/ui/store.ts b/packages/sdk-typescript/src/daemon/ui/store.ts index faf8b2de6c4..d3241c6d202 100644 --- a/packages/sdk-typescript/src/daemon/ui/store.ts +++ b/packages/sdk-typescript/src/daemon/ui/store.ts @@ -79,6 +79,11 @@ function reportListenerError(error: unknown): void { ).reportError; if (typeof reporter === 'function') { reporter(error); + return; + } + const logger = globalThis.console?.error; + if (typeof logger === 'function') { + logger.call(globalThis.console, error); } } diff --git a/packages/sdk-typescript/src/daemon/ui/transcript.ts b/packages/sdk-typescript/src/daemon/ui/transcript.ts index 7368651e004..3acb45dea6b 100644 --- a/packages/sdk-typescript/src/daemon/ui/transcript.ts +++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts @@ -22,6 +22,7 @@ const DEFAULT_MAX_BLOCKS = 1_000; const TRIMMED_TOOL_BLOCK_ID = '__trimmed_tool_block__'; const MAX_TEXT_BLOCK_LENGTH = 100_000; const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n'; +const MAX_CLONE_DEPTH = 16; export function createDaemonTranscriptState( opts: DaemonTranscriptReducerOptions = {}, @@ -545,13 +546,17 @@ function pruneTrimmedToolIndexes(state: DaemonTranscriptState): void { } } -function cloneJsonLike(value: T): T { +function cloneJsonLike(value: T, depth = 0): T { + if (depth > MAX_CLONE_DEPTH) return value; if (Array.isArray(value)) { - return value.map((entry) => cloneJsonLike(entry)) as T; + return value.map((entry) => cloneJsonLike(entry, depth + 1)) as T; } if (isRecord(value)) { return Object.fromEntries( - Object.entries(value).map(([key, entry]) => [key, cloneJsonLike(entry)]), + Object.entries(value).map(([key, entry]) => [ + key, + cloneJsonLike(entry, depth + 1), + ]), ) as T; } return value; diff --git a/packages/sdk-typescript/src/daemon/ui/utils.ts b/packages/sdk-typescript/src/daemon/ui/utils.ts index 2c862c4ff2d..73f5e5d5fd5 100644 --- a/packages/sdk-typescript/src/daemon/ui/utils.ts +++ b/packages/sdk-typescript/src/daemon/ui/utils.ts @@ -77,8 +77,11 @@ export function isSensitiveKey(key: string): boolean { normalized === 'xapikey' || normalized === 'xauthkey' || normalized === 'xauthtoken' || + normalized.endsWith('password') || normalized.endsWith('token') || normalized.endsWith('secret') || + normalized.endsWith('secretkey') || + normalized.endsWith('accesskey') || normalized.endsWith('apikey') || normalized.endsWith('privatekey') || normalized.includes('accesstoken') || diff --git a/packages/sdk-typescript/test/unit/daemonUi.test.ts b/packages/sdk-typescript/test/unit/daemonUi.test.ts index 3d63f1e9e3d..218cc29b821 100644 --- a/packages/sdk-typescript/test/unit/daemonUi.test.ts +++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts @@ -12,6 +12,7 @@ import { createDaemonTranscriptStore, daemonUiEventToTerminalText, getOutputText, + isDaemonUiSensitiveKey, normalizeDaemonEvent, reduceDaemonTranscriptEvents, sanitizeTerminalText, @@ -179,6 +180,9 @@ describe('daemon UI normalizer and transcript reducer', () => { update: { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello' }, + rawInput: { + apiKey: 'raw-event-secret', + }, }, }, } as const; @@ -186,9 +190,23 @@ describe('daemon UI normalizer and transcript reducer', () => { const [withoutRaw] = normalizeDaemonEvent(event); expect(withoutRaw).toMatchObject({ type: 'assistant.text.delta' }); expect(withoutRaw).not.toHaveProperty('rawEvent'); - expect( - normalizeDaemonEvent(event, { includeRawEvent: true }), - ).toMatchObject([{ type: 'assistant.text.delta', rawEvent: event }]); + const [withRaw] = normalizeDaemonEvent(event, { includeRawEvent: true }); + expect(withRaw).toMatchObject({ + type: 'assistant.text.delta', + rawEvent: { + ...event, + data: { + update: { + sessionUpdate: 'agent_message_chunk', + content: { type: 'text', text: 'hello' }, + rawInput: { + apiKey: '[redacted]', + }, + }, + }, + }, + }); + expect(JSON.stringify(withRaw)).not.toContain('raw-event-secret'); }); it('projects AskUserQuestion into a semantic tool preview', () => { @@ -1224,6 +1242,34 @@ describe('daemon UI normalizer and transcript reducer', () => { }); }); + it('recognizes common secret-key aliases before rendering previews', () => { + expect( + [ + 'secret_key', + 'access_key', + 'DATABASE_PASSWORD', + 'db_password', + 'aws_secret_access_key', + ].every((key) => isDaemonUiSensitiveKey(key)), + ).toBe(true); + expect( + createDaemonToolPreview({ + secret_key: 'secret-key', + access_key: 'access-key', + DATABASE_PASSWORD: 'database-password', + db_password: 'db-password', + }), + ).toMatchObject({ + kind: 'key_value', + rows: [ + { label: 'secret_key', value: '[redacted]' }, + { label: 'access_key', value: '[redacted]' }, + { label: 'DATABASE_PASSWORD', value: '[redacted]' }, + { label: 'db_password', value: '[redacted]' }, + ], + }); + }); + it('redacts sensitive fields in tool.update rawInput and rawOutput at normalizer boundary (wenshao CRIT #2)', () => { const events = normalizeDaemonEvent({ id: 1, @@ -1245,6 +1291,22 @@ describe('daemon UI normalizer and transcript reducer', () => { text: 'OK', token: 'returned-secret-do-not-leak', }, + content: [ + { + type: 'content', + content: { + type: 'text', + apiKey: 'content-secret-do-not-leak', + text: 'visible content', + }, + }, + ], + locations: [ + { + path: '/tmp/output.txt', + access_key: 'location-secret-do-not-leak', + }, + ], }, }, } as never); @@ -1287,5 +1349,89 @@ describe('daemon UI normalizer and transcript reducer', () => { expect(event.details).not.toContain('sk-prod-do-not-leak'); expect(event.details).not.toContain('Bearer secret-do-not-leak'); expect(event.details).not.toContain('returned-secret-do-not-leak'); + expect(serialized).not.toContain('content-secret-do-not-leak'); + expect(serialized).not.toContain('location-secret-do-not-leak'); + expect(event.content).toMatchObject([ + { + content: { + apiKey: '[redacted]', + text: 'visible content', + }, + }, + ]); + expect(event.locations).toMatchObject([ + { + path: '/tmp/output.txt', + access_key: '[redacted]', + }, + ]); + }); + + it('redacts permission tool calls at the normalizer boundary', () => { + const [event] = normalizeDaemonEvent({ + id: 2, + v: 1, + type: 'permission_request', + data: { + requestId: 'perm-secret', + toolCall: { + name: 'Bash', + rawInput: { + command: 'curl https://api.example.com', + Authorization: 'Bearer permission-secret-do-not-leak', + }, + }, + options: [{ optionId: 'allow', label: 'Allow' }], + }, + } as never); + + expect(event).toMatchObject({ + type: 'permission.request', + toolCall: { + rawInput: { + command: 'curl https://api.example.com', + Authorization: '[redacted]', + }, + }, + }); + expect(JSON.stringify(event)).not.toContain( + 'Bearer permission-secret-do-not-leak', + ); + }); + + it('reports subscriber errors when reportError is unavailable', async () => { + const previousReportError = ( + globalThis as typeof globalThis & { + reportError?: (error: unknown) => void; + } + ).reportError; + const consoleError = vi + .spyOn(globalThis.console, 'error') + .mockImplementation(() => {}); + try { + ( + globalThis as typeof globalThis & { + reportError?: (error: unknown) => void; + } + ).reportError = undefined; + const store = createDaemonTranscriptStore(); + const listenerError = new Error('listener failed'); + store.subscribe(() => { + throw listenerError; + }); + + store.dispatch({ type: 'status', text: 'notify' }); + await Promise.resolve(); + await Promise.resolve(); + + expect(consoleError).toHaveBeenCalledWith(listenerError); + } finally { + consoleError.mockRestore(); + ( + globalThis as typeof globalThis & { + reportError?: (error: unknown) => void; + } + ).reportError = previousReportError; + } }); }); diff --git a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx index 5eccab1be9e..460bcdaa610 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.test.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx @@ -67,7 +67,14 @@ const sdkMocks = vi.hoisted(() => { sessions.length = 0; capabilities.mockReset(); capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace' }); - MockDaemonSessionClient.createOrAttach.mockClear(); + MockDaemonSessionClient.createOrAttach.mockReset(); + MockDaemonSessionClient.createOrAttach.mockImplementation( + async (_client: unknown, _req: unknown): Promise => { + const session = sessions.shift(); + if (!session) throw new Error('No mock daemon session queued'); + return session; + }, + ); }, }; }); @@ -715,92 +722,248 @@ describe('DaemonSessionProvider', () => { }); }); - it('breaks out of the reconnect loop on 401 / 403 auth failures even when autoReconnect is true (wenshao CRIT #1)', async () => { - // Simulates a daemon that consistently returns 401 (bad token). Pre-fix, - // autoReconnect: true would loop forever calling createOrAttach, - // generating a reconnection storm and wiping the user's transcript on - // every cycle. After the fix, the provider treats 401/403 as terminal - // regardless of autoReconnect. - let createAttempts = 0; - sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( - async () => { - createAttempts += 1; - throw Object.assign(new Error('Unauthorized'), { status: 401 }); - }, - ); + it.each([401, 403])( + 'breaks out of the reconnect loop on %d auth failures even when autoReconnect is true (wenshao CRIT #1)', + async (status) => { + // Simulates a daemon that consistently returns 401 (bad token). Pre-fix, + // autoReconnect: true would loop forever calling createOrAttach, + // generating a reconnection storm and wiping the user's transcript on + // every cycle. After the fix, the provider treats 401/403 as terminal + // regardless of autoReconnect. + let createAttempts = 0; + sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( + async () => { + createAttempts += 1; + throw Object.assign(new Error('Unauthorized'), { status }); + }, + ); - let connection: DaemonConnectionState | undefined; - function Harness() { - connection = useDaemonConnection(); - return null; - } + let connection: DaemonConnectionState | undefined; + function Harness() { + connection = useDaemonConnection(); + return null; + } - await renderWithProvider(, { - autoConnect: true, - autoReconnect: true, // ← critical: must NOT loop - reconnectDelayMs: 1, // keep timing tight in case it does loop - maxReconnectDelayMs: 1, - }); + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, // ← critical: must NOT loop + reconnectDelayMs: 1, // keep timing tight in case it does loop + maxReconnectDelayMs: 1, + }); - await act(async () => { - await flushPromises(); - }); - // Give any potential reconnect timer a window to fire. - await act(async () => { - await wait(20); - await flushPromises(); - }); + await act(async () => { + await flushPromises(); + }); + // Give any potential reconnect timer a window to fire. + await act(async () => { + await wait(20); + await flushPromises(); + }); - expect(connection).toMatchObject({ - status: 'error', - error: 'Unauthorized', - }); - // No sessionId on auth-failure terminal state. - expect(connection?.sessionId).toBeUndefined(); - // Crucial: only ONE attempt happened. Pre-fix, multiple attempts would - // have occurred during the wait above. We assert exactly 1 to lock in - // the no-storm invariant. - expect(createAttempts).toBe(1); - }); + expect(connection).toMatchObject({ + status: 'error', + error: 'Unauthorized', + }); + // No sessionId on auth-failure terminal state. + expect(connection?.sessionId).toBeUndefined(); + // Crucial: only ONE attempt happened. Pre-fix, multiple attempts would + // have occurred during the wait above. We assert exactly 1 to lock in + // the no-storm invariant. + expect(createAttempts).toBe(1); + }, + ); + + it.each([404, 410])( + 'still reconnects on %d session-not-found errors when autoReconnect is true', + async (status) => { + // Verifies the fix did NOT over-correct: 404/410 are session-not-found + // (recoverable by creating a fresh session), not credential failures. + // Pre-fix and post-fix behavior should be identical for these statuses. + let createAttempts = 0; + sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( + async () => { + createAttempts += 1; + if (createAttempts === 1) { + throw Object.assign(new Error('session gone'), { status }); + } + // Second attempt succeeds. + return createMockSession({ sessionId: `session-${createAttempts}` }); + }, + ); - it('still reconnects on 404 / 410 session-not-found errors when autoReconnect is true', async () => { - // Verifies the fix did NOT over-correct: 404/410 are session-not-found - // (recoverable by creating a fresh session), not credential failures. - // Pre-fix and post-fix behavior should be identical for these statuses. - let createAttempts = 0; - sdkMocks.MockDaemonSessionClient.createOrAttach.mockImplementation( - async () => { - createAttempts += 1; - if (createAttempts === 1) { - throw Object.assign(new Error('session gone'), { status: 410 }); - } - // Second attempt succeeds. - return createMockSession({ sessionId: `session-${createAttempts}` }); - }, - ); + let connection: DaemonConnectionState | undefined; + function Harness() { + connection = useDaemonConnection(); + return null; + } - let connection: DaemonConnectionState | undefined; - function Harness() { - connection = useDaemonConnection(); - return null; - } + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); - await renderWithProvider(, { - autoConnect: true, - autoReconnect: true, - reconnectDelayMs: 1, - maxReconnectDelayMs: 1, - }); + await act(async () => { + await wait(30); + await flushPromises(); + }); - await act(async () => { - await wait(30); - await flushPromises(); - }); + // 410 path: SHOULD have retried at least once (so connect succeeded on + // attempt #2). This is the contrast case with the 401 test above. + expect(createAttempts).toBeGreaterThanOrEqual(2); + expect(connection?.status).not.toBe('error'); + }, + ); + + it.each([401, 403])( + 'preserves transcript and clears prompt state on %d auth failures from the SSE stream', + async (status) => { + const streamFailure = createDeferred(); + const session = createMockSession({ + prompt: vi.fn( + (_req: unknown, signal?: AbortSignal) => + new Promise((_resolve, reject) => { + signal?.addEventListener( + 'abort', + () => reject(createAbortError()), + { once: true }, + ); + }), + ), + events: async function* authFailureEvents() { + await streamFailure.promise; + yield* []; + throw Object.assign(new Error('Unauthorized'), { status }); + }, + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let connection: DaemonConnectionState | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + connection = useDaemonConnection(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { + autoConnect: true, + autoReconnect: true, + reconnectDelayMs: 1, + maxReconnectDelayMs: 1, + }); + const providerActions = requireActions(actions); - // 410 path: SHOULD have retried at least once (so connect succeeded on - // attempt #2). This is the contrast case with the 401 test above. - expect(createAttempts).toBeGreaterThanOrEqual(2); - expect(connection?.status).not.toBe('error'); + let promptResult: Promise | undefined; + await act(async () => { + promptResult = providerActions.sendPrompt('keep transcript'); + await flushPromises(); + }); + expect(blocks).toMatchObject([{ kind: 'user', text: 'keep transcript' }]); + + streamFailure.resolve(); + await act(async () => { + await wait(20); + await flushPromises(); + }); + + const runningPrompt = promptResult; + if (!runningPrompt) throw new Error('prompt was not started'); + await expect(runningPrompt).resolves.toEqual({ + stopReason: 'cancelled', + }); + expect(connection).toMatchObject({ + status: 'error', + error: 'Unauthorized', + }); + expect(blocks[0]).toMatchObject({ + kind: 'user', + text: 'keep transcript', + }); + expect(blocks).toContainEqual( + expect.objectContaining({ + kind: 'error', + text: 'Unauthorized', + }) as DaemonTranscriptBlock, + ); + expect( + sdkMocks.MockDaemonSessionClient.createOrAttach, + ).toHaveBeenCalledTimes(1); + await act(async () => { + await expect(providerActions.sendPrompt('after auth')).rejects.toThrow( + 'Daemon session is not connected', + ); + }); + }, + ); + + it.each([ + [ + 'cancel', + (actions: DaemonUiSessionActions) => actions.cancel(), + 'Cancel failed: Cancel timed out after 30000ms', + ], + [ + 'setModel', + (actions: DaemonUiSessionActions) => actions.setModel('qwen-plus'), + 'Set model failed: Set model timed out after 30000ms', + ], + [ + 'respondToPermission', + (actions: DaemonUiSessionActions) => + actions.respondToPermission('perm-1', { + outcome: { outcome: 'selected', optionId: 'allow' }, + }), + 'Permission response failed: Permission response timed out after 30000ms', + ], + ])('times out hung %s actions', async (_name, invoke, expectedError) => { + vi.useFakeTimers(); + try { + const session = createMockSession({ + cancel: vi.fn(() => new Promise(() => {})), + setModel: vi.fn(() => new Promise<{ modelId: string }>(() => {})), + respondToSessionPermission: vi.fn(() => new Promise(() => {})), + events: createIdleEvents(), + }); + sdkMocks.sessions.push(session); + let actions: DaemonUiSessionActions | undefined; + let blocks: readonly DaemonTranscriptBlock[] = []; + + function Harness() { + actions = useDaemonActions(); + blocks = useDaemonTranscriptBlocks(); + return null; + } + + await renderWithProvider(, { autoConnect: true }); + const providerActions = requireActions(actions); + + let actionResult: Promise | undefined; + let actionError: Promise | undefined; + await act(async () => { + actionResult = invoke(providerActions); + actionError = actionResult.catch((error: unknown) => error); + await flushPromises(); + }); + await act(async () => { + vi.advanceTimersByTime(30_000); + await flushPromises(); + }); + + const pendingAction = actionResult; + if (!pendingAction) throw new Error('action was not started'); + const observedError = await actionError; + expect(observedError).toBeInstanceOf(Error); + expect((observedError as Error).message).toBe( + expectedError.replace(/^.*?: /, ''), + ); + expect(blocks.at(-1)).toMatchObject({ text: expectedError }); + } finally { + vi.useRealTimers(); + } }); async function renderWithProvider( diff --git a/packages/webui/src/daemon/DaemonSessionProvider.tsx b/packages/webui/src/daemon/DaemonSessionProvider.tsx index 2c90d118798..69b48ad550e 100644 --- a/packages/webui/src/daemon/DaemonSessionProvider.tsx +++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx @@ -82,9 +82,8 @@ const TERMINAL_SESSION_HTTP_STATUSES = new Set([401, 403, 404, 410]); * failures** (vs session-not-found 404/410). Auth failures should NOT enter * the reconnect loop even when `autoReconnect: true` — retrying with the * same bad token loops forever, hammering the server with bad credentials - * and wiping the user's transcript on each cycle (the sessionId-change - * `store.reset()` path at line 143 fires when each fresh attempt produces a - * different sessionId). + * and risking transcript wipes if reconnect later attaches a different + * session and hits the sessionId-change `store.reset()` branch. * * 404/410 (session-not-found) keep the reconnect-then-recreate behavior — * those are recoverable by creating a fresh session. @@ -214,11 +213,14 @@ export function DaemonSessionProvider({ // Auth failures (401 / 403) must NOT retry even when // `autoReconnect: true`. Retrying with the same invalid token // loops forever — the daemon keeps returning 401, each cycle - // wipes the user's transcript (via the sessionId-change branch - // in line 143), and the user sees no actionable error state. + // risks transcript wipes via the sessionId-change branch above, + // and the user sees no actionable error state. // Surface as a terminal 'error' connection state regardless of // the autoReconnect setting; the user must update credentials. if (isAuthFailureHttpError(error)) { + promptAbortRef.current?.abort(); + promptAbortRef.current = undefined; + promptBusyRef.current = false; sessionRef.current = undefined; setConnection({ status: 'error',