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, }, -}); +}));