diff --git a/docs/developers/daemon-client-adapters/tui.md b/docs/developers/daemon-client-adapters/tui.md
deleted file mode 100644
index c9d223b926..0000000000
--- 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 0000000000..2aa022fa37
--- /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 4ddeda80f1..b626c4ef4d 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 098d606999..0000000000
--- 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 d5aa3ddd3c..0000000000
--- 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 d7205d8c21..07931f73bc 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 90c6f4277e..e738d84175 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,50 @@ 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');
+ 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 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[0]}`,
+ );
+ }
+}
diff --git a/packages/sdk-typescript/src/daemon/index.ts b/packages/sdk-typescript/src/daemon/index.ts
index 8996921a0d..d71c26700d 100644
--- a/packages/sdk-typescript/src/daemon/index.ts
+++ b/packages/sdk-typescript/src/daemon/index.ts
@@ -44,6 +44,56 @@ export {
reduceDaemonSessionEvents,
} from './events.js';
export { parseSseStream, SseFramingError } from './sse.js';
+export {
+ appendLocalUserTranscriptMessage,
+ createDaemonToolPreview,
+ createDaemonTranscriptState,
+ createDaemonTranscriptStore,
+ DAEMON_PLAN_TOOL_CALL_ID,
+ daemonUiEventToTerminalText,
+ getOutputText as getDaemonUiOutputText,
+ getSessionUpdatePayload,
+ isDaemonUiSensitiveKey,
+ normalizeDaemonEvent,
+ redactDaemonUiSensitiveFields,
+ 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 0000000000..1628e8e589
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/index.ts
@@ -0,0 +1,59 @@
+/**
+ * @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,
+ isSensitiveKey as isDaemonUiSensitiveKey,
+ redactSensitiveFields as redactDaemonUiSensitiveFields,
+ sanitizeTerminalText,
+ stringifyJson,
+ stripOscSequences,
+} from './utils.js';
+export { DAEMON_PLAN_TOOL_CALL_ID } from './types.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 0000000000..4e028ea01a
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/normalizer.ts
@@ -0,0 +1,475 @@
+/**
+ * @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 { 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;
+
+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',
+ recoverable: false,
+ text:
+ getString(event.data, 'reason') ??
+ 'Session died (no details available)',
+ },
+ ];
+ case 'session_closed':
+ return [
+ {
+ ...base,
+ type: 'status',
+ 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: 'status',
+ text: `${event.type} (unrecognized daemon event)`,
+ },
+ {
+ ...base,
+ type: 'debug',
+ text: `${event.type}: ${stringifyRedactedJson(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, data: redactSensitiveFields(event.data) } }
+ : {}),
+ };
+}
+
+function normalizeSessionUpdate(
+ event: DaemonEvent,
+ base: Pick,
+ opts: NormalizeDaemonEventOptions,
+): DaemonUiEvent[] {
+ const update = getSessionUpdatePayload(event.data);
+ if (!update) {
+ return [
+ {
+ ...base,
+ type: 'debug',
+ text: `session_update: ${stringifyRedactedJson(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})`,
+ },
+ ];
+ }
+ case 'plan':
+ return [normalizePlanUpdate(update, base)];
+ default:
+ return [
+ {
+ ...base,
+ type: 'debug',
+ text: `${kind ?? 'session_update'}: ${stringifyRedactedJson(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 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 raw values never reach transcript blocks, terminal
+ // details, or downstream UI components.
+ const rawInput =
+ rawInputSource !== undefined
+ ? redactSensitiveFields(rawInputSource)
+ : undefined;
+ const rawOutput =
+ rawOutputSource !== undefined
+ ? redactSensitiveFields(rawOutputSource)
+ : undefined;
+ 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) {
+ 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 } : {}),
+ ...(content !== undefined ? { content } : {}),
+ ...(locations !== undefined ? { locations } : {}),
+ ...(rawInput !== undefined ? { rawInput } : {}),
+ ...(rawOutput !== undefined ? { rawOutput } : {}),
+ ...(rawInput !== undefined
+ ? { details: capDetails(stringifyRedactedJson(rawInput)) }
+ : rawOutput !== undefined
+ ? { details: capDetails(stringifyRedactedJson(rawOutput)) }
+ : {}),
+ };
+}
+
+function normalizePlanUpdate(
+ update: Record,
+ base: Pick,
+): DaemonUiEvent {
+ const entries = Array.isArray(update['entries']) ? update['entries'] : [];
+ const contentText = capDetails(formatPlanEntries(entries));
+ return {
+ ...base,
+ type: 'tool.update',
+ toolCallId: DAEMON_PLAN_TOOL_CALL_ID,
+ 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]`;
+}
+
+function normalizePermissionRequest(
+ event: DaemonEvent,
+ base: Pick,
+): DaemonUiEvent[] {
+ if (!isRecord(event.data)) {
+ return [
+ {
+ ...base,
+ type: 'debug',
+ text: `permission_request: ${stringifyRedactedJson(event.data)}`,
+ },
+ ];
+ }
+
+ const requestId = getString(event.data, 'requestId');
+ if (!requestId) {
+ return [
+ {
+ ...base,
+ type: 'debug',
+ text: `permission_request: ${stringifyRedactedJson(event.data)}`,
+ },
+ ];
+ }
+
+ const toolCall =
+ event.data['toolCall'] !== undefined
+ ? redactSensitiveFields(event.data['toolCall'])
+ : undefined;
+ 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}: ${stringifyRedactedJson(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 (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 getFirstString(value, ['status', 'reason']) ?? 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 0000000000..d3241c6d20
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/store.ts
@@ -0,0 +1,110 @@
+/**
+ * @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) {
+ try {
+ listener();
+ } catch (error) {
+ reportListenerError(error);
+ }
+ }
+ };
+ 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 reportListenerError(error: unknown): void {
+ const reporter = (
+ globalThis as typeof globalThis & {
+ reportError?: (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);
+ }
+}
+
+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 ?? {}) },
+ trimmedToolNotificationByCallId: {
+ ...(seed.trimmedToolNotificationByCallId ?? {}),
+ },
+ 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 0000000000..ac26e43a0a
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/terminal.ts
@@ -0,0 +1,113 @@
+/**
+ * @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 {
+ 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 {
+ 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 0000000000..79b6ba2b7d
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/toolPreview.ts
@@ -0,0 +1,131 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import type {
+ DaemonToolPreview,
+ DaemonTranscriptQuestion,
+ DaemonTranscriptQuestionOption,
+} from './types.js';
+import {
+ getFirstString,
+ isRecord,
+ isSensitiveKey,
+ stringifyRedactedJson,
+} 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: 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
new file mode 100644
index 0000000000..3acb45dea6
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/transcript.ts
@@ -0,0 +1,569 @@
+/**
+ * @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 { 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__';
+const MAX_TEXT_BLOCK_LENGTH = 100_000;
+const TEXT_TRUNCATED_SUFFIX = '\n[truncated]\n';
+const MAX_CLONE_DEPTH = 16;
+
+export function createDaemonTranscriptState(
+ opts: DaemonTranscriptReducerOptions = {},
+): DaemonTranscriptState {
+ return {
+ blocks: [],
+ blockIndexById: {},
+ toolBlockByCallId: {},
+ trimmedToolNotificationByCallId: {},
+ 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:
+ assertNever(event);
+ }
+}
+
+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 = appendBoundedText(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];
+ 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(
+ state,
+ 'error',
+ `Tool ${event.toolCallId} output trimmed (max blocks reached)`,
+ event,
+ { clearActiveText: false },
+ );
+ }
+ return;
+ }
+ 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.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;
+ 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.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 } : {}),
+ ...(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 = appendBoundedText(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: truncateText(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,
+ opts: { clearActiveText?: boolean } = {},
+): void {
+ const block: DaemonStatusTranscriptBlock = {
+ id: allocateBlockId(state, kind),
+ kind,
+ text: truncateText(text),
+ createdAt: state.now,
+ updatedAt: state.now,
+ ...(event?.eventId !== undefined ? { eventId: event.eventId } : {}),
+ };
+ appendBlock(state, block);
+ if (opts.clearActiveText !== false) clearActiveText(state);
+}
+
+function createTextBlock(
+ state: DaemonTranscriptState,
+ kind: 'user' | 'assistant' | 'thought',
+ text: string,
+ eventId?: number,
+): DaemonTextTranscriptBlock {
+ return {
+ id: allocateBlockId(state, kind),
+ kind,
+ text: truncateText(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: { ...state.blockIndexById },
+ toolBlockByCallId: { ...state.toolBlockByCallId },
+ trimmedToolNotificationByCallId: {
+ ...state.trimmedToolNotificationByCallId,
+ },
+ 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)) {
+ state.toolBlockByCallId[toolCallId] = TRIMMED_TOOL_BLOCK_ID;
+ }
+ }
+ pruneTrimmedToolIndexes(state);
+ 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,
+ )) {
+ 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 shouldRecreateTrimmedToolBlock(
+ event: Extract,
+): boolean {
+ return (
+ event.toolCallId === DAEMON_PLAN_TOOL_CALL_ID ||
+ event.toolKind === 'updated_plan'
+ );
+}
+
+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) => 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 };
+}
+
+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;
+}
+
+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 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, depth = 0): T {
+ if (depth > MAX_CLONE_DEPTH) return value;
+ if (Array.isArray(value)) {
+ 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, depth + 1),
+ ]),
+ ) 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
new file mode 100644
index 0000000000..e0eb451762
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/types.ts
@@ -0,0 +1,265 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+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'
+ | '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;
+ content?: unknown;
+ locations?: unknown;
+ 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;
+ content?: unknown;
+ locations?: unknown;
+ 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;
+ trimmedToolNotificationByCallId: 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 0000000000..73f5e5d5fd
--- /dev/null
+++ b/packages/sdk-typescript/src/daemon/ui/utils.ts
@@ -0,0 +1,167 @@
+/**
+ * @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 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('password') ||
+ normalized.endsWith('token') ||
+ normalized.endsWith('secret') ||
+ normalized.endsWith('secretkey') ||
+ normalized.endsWith('accesskey') ||
+ 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 '';
+ 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 carriageReturn = String.fromCharCode(0x0d);
+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}${carriageReturn}${shiftOut}-${unitSeparator}${deleteChar}-${c1End}]`,
+ 'g',
+);
+
+const oscSequencePattern = new RegExp(
+ `${escapeChar}\\][^${bell}${escapeChar}]*(?:${bell}|${escapeChar}\\\\)`,
+ 'g',
+);
+const dcsSequencePattern = new RegExp(
+ `${escapeChar}P[^${escapeChar}]*${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 0000000000..218cc29b82
--- /dev/null
+++ b/packages/sdk-typescript/test/unit/daemonUi.test.ts
@@ -0,0 +1,1437 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import { describe, expect, it, vi } from 'vitest';
+import {
+ appendLocalUserTranscriptMessage,
+ createDaemonToolPreview,
+ createDaemonTranscriptState,
+ createDaemonTranscriptStore,
+ daemonUiEventToTerminalText,
+ getOutputText,
+ isDaemonUiSensitiveKey,
+ 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 status', () => {
+ const events = normalizeDaemonEvent({
+ id: 23,
+ v: 1,
+ type: 'session_closed',
+ data: { reason: 'idle timeout' },
+ });
+
+ expect(events).toMatchObject([
+ {
+ type: 'status',
+ 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('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' },
+ rawInput: {
+ apiKey: 'raw-event-secret',
+ },
+ },
+ },
+ } as const;
+
+ const [withoutRaw] = normalizeDaemonEvent(event);
+ expect(withoutRaw).toMatchObject({ type: 'assistant.text.delta' });
+ expect(withoutRaw).not.toHaveProperty('rawEvent');
+ 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', () => {
+ 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['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: '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('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 });
+
+ 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', () => {
+ 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('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,
+ v: 1,
+ type: 'session_update',
+ data: {
+ update: {
+ sessionUpdate: 'tool_call',
+ toolCallId: 'large-input',
+ title: 'Large input',
+ rawInput: { text: 'x'.repeat(5000), apiKey: 'input-secret' },
+ },
+ },
+ });
+ const [outputEvent] = normalizeDaemonEvent({
+ id: 45,
+ v: 1,
+ type: 'session_update',
+ data: {
+ update: {
+ sessionUpdate: 'tool_call',
+ toolCallId: 'large-output',
+ title: 'Large output',
+ rawOutput: { text: 'y'.repeat(5000), token: 'output-secret' },
+ },
+ },
+ });
+
+ 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);
+ 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', () => {
+ 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('caps text transcript blocks to prevent unbounded memory growth', () => {
+ const state = reduceDaemonTranscriptEvents(
+ createDaemonTranscriptState({ now: 1 }),
+ [
+ { type: 'assistant.text.delta', text: 'x'.repeat(160_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('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,
+ v: 1,
+ type: 'session_died',
+ data: { token: 'secret-token' },
+ });
+
+ expect(event).toMatchObject({
+ type: 'error',
+ recoverable: false,
+ text: 'Session died (no details available)',
+ });
+ expect(event && 'text' in event ? event.text : '').not.toContain(
+ 'secret-token',
+ );
+ });
+
+ 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)',
+ },
+ {
+ type: 'debug',
+ 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',
+ },
+ },
+ ],
+ },
+ ]);
+ });
+
+ 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(
+ 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) {
+ 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 and streamless 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' },
+ { type: 'shell.output', text: 'unknown-1' },
+ { type: 'shell.output', text: 'unknown-2' },
+ ],
+ { now: 2 },
+ );
+
+ expect(state.blocks).toMatchObject([
+ { kind: 'shell', text: 'out-1out-2', stream: 'stdout' },
+ { kind: 'shell', text: 'err-1', stream: 'stderr' },
+ { kind: 'shell', text: 'unknown-1unknown-2' },
+ ]);
+ });
+
+ it('provides a batched framework-free external store', async () => {
+ const store = createDaemonTranscriptStore();
+ let calls = 0;
+ const unsubscribe = store.subscribe(() => {
+ calls += 1;
+ });
+
+ 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(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',
+ 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\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`,
+ );
+
+ 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) {
+ nested = { rawInput: nested };
+ }
+
+ expect(createDaemonToolPreview(nested)).toMatchObject({
+ 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' },
+ ],
+ });
+ });
+
+ 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,
+ 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',
+ },
+ 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);
+ 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');
+ 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');
+ 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/package.json b/packages/webui/package.json
index e7be195a50..3cbe6c75d7 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 5e92ee8653..b73820c754 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 a198691544..7d041d80c3 100644
--- a/packages/webui/src/components/toolcalls/ThinkToolCall.tsx
+++ b/packages/webui/src/components/toolcalls/ThinkToolCall.tsx
@@ -68,7 +68,11 @@ export const ThinkToolCall: FC = ({
const status =
toolCall.status === 'pending' || toolCall.status === 'in_progress'
? 'loading'
- : 'default';
+ : toolCall.status === 'failed'
+ ? 'error'
+ : toolCall.status === 'cancelled'
+ ? 'warning'
+ : 'default';
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.test.tsx b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx
new file mode 100644
index 0000000000..460bcdaa61
--- /dev/null
+++ b/packages/webui/src/daemon/DaemonSessionProvider.test.tsx
@@ -0,0 +1,1086 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+// @vitest-environment jsdom
+
+import { act, type ReactNode } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+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[] = [];
+ const capabilities = vi.fn();
+
+ class MockDaemonClient {
+ constructor(_opts: unknown) {}
+
+ capabilities = capabilities;
+ }
+
+ 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,
+ capabilities,
+ MockDaemonClient,
+ MockDaemonSessionClient,
+ reset() {
+ sessions.length = 0;
+ capabilities.mockReset();
+ capabilities.mockResolvedValue({ workspaceCwd: '/mock-workspace' });
+ 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;
+ },
+ );
+ },
+ };
+});
+
+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;
+
+ beforeEach(() => {
+ (
+ globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }
+ ).IS_REACT_ACT_ENVIRONMENT = true;
+ sdkMocks.reset();
+ });
+
+ afterEach(() => {
+ if (root) {
+ act(() => {
+ root?.unmount();
+ });
+ root = null;
+ }
+ if (container) {
+ container.remove();
+ container = null;
+ }
+ });
+
+ it('exposes idle connection state without auto connect', async () => {
+ let connection: DaemonConnectionState | undefined;
+ let blocks: readonly DaemonTranscriptBlock[] | undefined;
+
+ function Harness() {
+ connection = useDaemonConnection();
+ blocks = useDaemonTranscriptBlocks();
+ return null;
+ }
+
+ await 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;
+ }
+
+ await 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' },
+ ]);
+ });
+
+ 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 assistantChunk = createDeferred();
+ let promptCalls = 0;
+ const session = createMockSession({
+ 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: 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;
+ 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();
+ 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();
+ });
+
+ 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[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(
+ {
+ stopReason: 'end_turn',
+ },
+ );
+ });
+ expect(session.prompt).toHaveBeenCalledTimes(2);
+ expect(
+ blocks.some(
+ (block) => block.kind === 'error' && block.text.includes('AbortError'),
+ ),
+ ).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({
+ sessionId: 'session-a',
+ prompt: vi.fn(
+ (_req: unknown, signal?: AbortSignal) =>
+ new Promise((_resolve, reject) => {
+ signal?.addEventListener(
+ 'abort',
+ () => reject(createAbortError()),
+ { once: true },
+ );
+ }),
+ ),
+ events: async function* missingSessionEvents() {
+ await firstEvents.closed.promise;
+ yield* [];
+ throw Object.assign(new Error('missing session'), { status: 404 });
+ },
+ });
+ 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(20);
+ 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('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('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('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);
+ 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',
+ );
+ });
+ });
+
+ 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',
+ );
+ });
+ });
+
+ 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;
+ }
+
+ 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.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}` });
+ },
+ );
+
+ 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');
+ },
+ );
+
+ 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);
+
+ 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(
+ children: ReactNode,
+ props: Partial = {},
+ ) {
+ container = document.createElement('div');
+ document.body.appendChild(container);
+ root = createRoot(container);
+
+ act(() => {
+ root?.render(
+
+ {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;
+ closed: ReturnType>;
+} {
+ const closed = createDeferred();
+ return {
+ events: async function* closableEvents() {
+ await closed.promise;
+ yield* [];
+ },
+ close: closed.resolve,
+ closed,
+ };
+}
+
+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
new file mode 100644
index 0000000000..69b48ad550
--- /dev/null
+++ b/packages/webui/src/daemon/DaemonSessionProvider.tsx
@@ -0,0 +1,580 @@
+/**
+ * @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,
+);
+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 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.
+ */
+const AUTH_FAILURE_HTTP_STATUSES = new Set([401, 403]);
+
+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 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({
+ 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();
+ if (disposed || abort.signal.aborted) return;
+ const nextSession = await DaemonSessionClient.createOrAttach(
+ client,
+ {
+ ...(modelServiceId !== undefined ? { modelServiceId } : {}),
+ ...(sessionScope !== undefined ? { sessionScope } : {}),
+ workspaceCwd: workspaceCwd ?? caps.workspaceCwd,
+ },
+ );
+ if (disposed || abort.signal.aborted) return;
+ const previousSessionId = lastSessionIdRef.current;
+ if (
+ previousSessionId !== undefined &&
+ nextSession.sessionId !== previousSessionId
+ ) {
+ promptAbortRef.current?.abort();
+ promptAbortRef.current = undefined;
+ promptBusyRef.current = false;
+ 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 eventOptions = eventOptionsRef.current;
+ const uiEvents = normalizeDaemonEvent(event, {
+ clientId: session.clientId,
+ suppressOwnUserEcho: eventOptions.suppressOwnUserEcho,
+ includeRawEvent: eventOptions.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) {
+ // 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',
+ });
+ }
+ } 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 terminalSessionError = isTerminalSessionHttpError(error);
+ if (terminalSessionError) {
+ 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
+ // 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',
+ error: message,
+ });
+ return;
+ }
+ 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 reconnectConfig = reconnectConfigRef.current;
+ const delayMs = getReconnectDelayMs(
+ reconnectAttempt,
+ reconnectConfig.reconnectDelayMs,
+ reconnectConfig.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,
+ store,
+ ]);
+
+ const actions = useMemo(
+ () => ({
+ async sendPrompt(text: string): Promise {
+ const session = requireSessionForAction(
+ store,
+ sessionRef.current,
+ 'Prompt failed',
+ );
+ if (promptBusyRef.current) {
+ throw dispatchActionError(
+ store,
+ 'Prompt failed',
+ 'A prompt is already in progress',
+ );
+ }
+ promptBusyRef.current = true;
+ const promptAbort = new AbortController();
+ promptAbortRef.current = promptAbort;
+ try {
+ store.appendLocalUserMessage(text);
+ const result = await session.prompt(
+ {
+ prompt: [{ type: 'text', text }],
+ },
+ promptAbort.signal,
+ );
+ store.dispatch({ type: 'assistant.done', reason: result.stopReason });
+ return result;
+ } catch (error) {
+ if (isAbortError(error)) {
+ 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) {
+ promptAbortRef.current = undefined;
+ promptBusyRef.current = false;
+ }
+ }
+ },
+ async cancel(): Promise {
+ const hadActivePrompt = promptAbortRef.current !== undefined;
+ promptAbortRef.current?.abort();
+ promptAbortRef.current = undefined;
+ let session: DaemonSessionClient;
+ try {
+ session = requireSessionForAction(
+ store,
+ sessionRef.current,
+ 'Cancel failed',
+ );
+ } catch (error) {
+ if (hadActivePrompt) {
+ promptBusyRef.current = false;
+ }
+ throw error;
+ }
+ try {
+ await withActionTimeout(session.cancel(), 'Cancel timed out');
+ } catch (error) {
+ throw dispatchActionError(store, 'Cancel failed', error);
+ } finally {
+ if (hadActivePrompt) {
+ promptBusyRef.current = false;
+ }
+ }
+ },
+ async setModel(modelId: string): Promise {
+ const session = requireSessionForAction(
+ store,
+ sessionRef.current,
+ 'Set model failed',
+ );
+ try {
+ return await withActionTimeout(
+ session.setModel(modelId),
+ 'Set model timed out',
+ );
+ } catch (error) {
+ throw dispatchActionError(store, 'Set model failed', error);
+ }
+ },
+ async respondToPermission(
+ requestId: string,
+ response: PermissionResponse,
+ ): Promise {
+ const session = requireSessionForAction(
+ store,
+ sessionRef.current,
+ 'Permission response failed',
+ );
+ try {
+ return await withActionTimeout(
+ session.respondToSessionPermission(requestId, response),
+ 'Permission response timed out',
+ );
+ } catch (error) {
+ throw dispatchActionError(store, 'Permission response failed', error);
+ }
+ },
+ }),
+ [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() {
+ const state = useDaemonTranscriptState();
+ return useMemo(() => selectPendingPermissionBlocks(state), [state]);
+}
+
+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 requireSessionForAction(
+ store: DaemonTranscriptStore,
+ session: DaemonSessionClient | undefined,
+ action: string,
+): DaemonSessionClient {
+ if (!session) {
+ 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 isAbortError(error: unknown): boolean {
+ return (
+ (error instanceof DOMException && error.name === 'AbortError') ||
+ (error instanceof Error && error.name === 'AbortError')
+ );
+}
+
+function isTerminalSessionHttpError(error: unknown): boolean {
+ 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);
+}
+
+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);
+ 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 {
+ 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 0000000000..58c261d83d
--- /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 0000000000..5b7d619970
--- /dev/null
+++ b/packages/webui/src/daemon/transcriptAdapter.test.ts
@@ -0,0 +1,318 @@
+/**
+ * @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('maps daemon tool statuses without leaving terminal states spinning', () => {
+ const messages = daemonTranscriptToUnifiedMessages([
+ createToolBlock('cancelled-tool', 'cancelled'),
+ 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',
+ 'pending',
+ 'cancelled',
+ 'failed',
+ 'failed',
+ ]);
+ });
+
+ it('maps permission resolution outcomes to user-visible statuses', () => {
+ const messages = daemonTranscriptToUnifiedMessages([
+ createPermissionBlock('pending-permission'),
+ 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('disallowed-permission', 'selected:disallow'),
+ createPermissionBlock('unblocked-permission', 'selected:unblock'),
+ 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',
+ 'failed',
+ 'cancelled',
+ 'completed',
+ 'failed',
+ 'cancelled',
+ 'completed',
+ 'failed',
+ 'completed',
+ 'cancelled',
+ 'cancelled',
+ 'failed',
+ '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: {
+ '\u202ecommand': '\u202enpm test',
+ apiKey: 'secret-input',
+ headers: { Authorization: 'Bearer secret-auth' },
+ },
+ rawOutput: {
+ token: 'secret-output',
+ text: '\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',
+ 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', () => {
+ 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('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('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([
+ {
+ 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',
+ content: [{ content: { text: '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 0000000000..bdd0ed4f31
--- /dev/null
+++ b/packages/webui/src/daemon/transcriptAdapter.ts
@@ -0,0 +1,427 @@
+/**
+ * @license
+ * Copyright 2025 Qwen Team
+ * SPDX-License-Identifier: Apache-2.0
+ */
+
+import {
+ isDaemonUiSensitiveKey,
+ sanitizeDaemonTerminalText,
+ type DaemonTranscriptBlock,
+ type DaemonToolTranscriptBlock,
+} from '@qwen-code/sdk/daemon';
+import type { UnifiedMessage } from '../adapters/types.js';
+import type {
+ ToolCallData,
+ ToolCallContent,
+ ToolCallLocation,
+ 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),
+ content: createTextContent(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),
+ content: createTextContent(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),
+ ...(block.content !== undefined
+ ? { content: normalizeToolContent(block.content) }
+ : {}),
+ ...(block.locations !== undefined
+ ? { locations: normalizeToolLocations(block.locations) }
+ : {}),
+ };
+}
+
+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':
+ 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 'failed';
+ }
+}
+
+function normalizePermissionStatus(
+ resolved: string | undefined,
+): ToolCallStatus {
+ if (!resolved) return 'pending';
+ const [primary = '', ...detailParts] = resolved.toLowerCase().split(':');
+ switch (primary) {
+ case 'cancel':
+ case 'cancelled':
+ case 'canceled':
+ case 'abort':
+ case 'aborted':
+ case 'dismiss':
+ case 'dismissed':
+ case 'already resolved':
+ return 'cancelled';
+ case 'deny':
+ case 'denied':
+ case 'reject':
+ case 'rejected':
+ case 'blocked':
+ case 'error':
+ case 'failed':
+ case 'fail':
+ return 'failed';
+ 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':
+ // 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';
+ }
+}
+
+function classifyPermissionToken(token: string): ToolCallStatus | undefined {
+ if (!token) return undefined;
+ const terms = new Set(
+ token
+ .toLowerCase()
+ .split(/[^a-z0-9]+/)
+ .filter(Boolean),
+ );
+ if (hasAnyTerm(terms, FAILED_PERMISSION_TERMS)) {
+ return 'failed';
+ }
+ if (hasAnyTerm(terms, CANCELLED_PERMISSION_TERMS)) {
+ return 'cancelled';
+ }
+ 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 '[truncated]';
+ 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]) => {
+ const sanitizedKey = sanitizeDisplayText(key);
+ return [
+ sanitizedKey,
+ isDaemonUiSensitiveKey(key)
+ ? '[redacted]'
+ : sanitizeDaemonValue(entry, depth + 1),
+ ];
+ }),
+ );
+}
+
+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);
+}
+
+function sanitizeDisplayText(text: string): string {
+ return sanitizeDaemonTerminalText(text);
+}
+
+function createTextContent(text: string): ToolCallData['content'] {
+ return [
+ {
+ type: 'content',
+ content: {
+ type: 'text',
+ text: sanitizeDisplayText(text),
+ },
+ },
+ ];
+}
diff --git a/packages/webui/src/index.ts b/packages/webui/src/index.ts
index 567d597072..7538c635b0 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 37762fb8a7..c6f1752123 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 3e0be85f6d..a9d9985aae 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 9a571eab3b..6190b7f3bd 100644
--- a/packages/webui/vite.config.ts
+++ b/packages/webui/vite.config.ts
@@ -19,7 +19,22 @@ import { resolve } from 'path';
* - TypeScript declarations: dist/index.d.ts
* - CSS: dist/styles.css (optional styles)
*/
-export default defineConfig({
+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({
@@ -42,9 +57,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',
@@ -56,4 +79,4 @@ export default defineConfig({
minify: false,
cssCodeSplit: false,
},
-});
+}));