feat(daemon): add shared UI transcript layer (#4328)

* feat(daemon): add shared UI transcript layer

* fix(daemon): address ui review feedback

* test(daemon): cover raw event diagnostics option

* fix(daemon): address latest ui review

* fix(daemon): cover reconnect and status edge cases

* fix(daemon): guard prompt busy cleanup

* fix(daemon): handle trimmed tool updates

* fix(daemon): cap transcript text blocks

* fix(daemon): dedupe trimmed tool diagnostics

* fix(daemon): harden webui transcript edge cases

* fix(daemon): preserve webui daemon events

* fix(daemon): address latest ui review comments

* fix(daemon): close latest ui review nits

* fix(daemon): harden ui review edges

* fix(daemon-ui): address wenshao 2 Critical findings (#4328 review)

## Critical #1 — 401/403 reconnect storm + transcript wipe

`DaemonSessionProvider`'s reconnect loop kept retrying `createOrAttach` on
401/403 even with `autoReconnect: true`. Each cycle:
  - hit the daemon with the same bad token → 401 again
  - cleared the session handle
  - the next successful attempt (if token magically recovered) would
    receive a different sessionId, triggering the `store.reset()` branch
    at line 143 and wiping the user's transcript
  - no terminal "auth failed" state surfaced to the user

Fix: split `TERMINAL_SESSION_HTTP_STATUSES` into `AUTH_FAILURE_HTTP_STATUSES`
(401, 403) and the rest (404, 410). On auth failure, return from the
reconnect loop unconditionally regardless of the `autoReconnect` flag —
these are credential failures, not transient. The user must update
credentials; daemon spam must stop.

`extractHttpStatus` helper factored out of `isTerminalSessionHttpError` to
share between the two predicates.

## Critical #2 — rawInput / rawOutput leaking secrets to UI

`normalizer.normalizeToolUpdate` forwarded `rawInput` / `rawOutput`
verbatim onto `DaemonUiToolUpdateEvent` → `DaemonToolTranscriptBlock`. The
`details` projection was redacted via `stringifyRedactedJson` /
`redactSensitiveFields`, but the underlying `rawInput` / `rawOutput`
fields were unredacted. Any UI component that read those fields directly
(ShellToolCall, WriteToolCall, JSON debug panels) leaked the raw values
to the DOM.

Example: `{ command: 'curl', apiKey: 'sk-prod-...' }` had `apiKey`
redacted in `details` but exposed verbatim on `rawInput`.

Fix: apply `redactSensitiveFields` to both `rawInput` and `rawOutput`
ONCE at the normalizer boundary, then reuse the redacted shape for the
`details` projection. Downstream is uniformly safe; no double traversal.

## Tests (49/49 pass)

- SDK `daemonUi.test.ts` (36 tests, +1) — new test `redacts sensitive
  fields in tool.update rawInput and rawOutput at normalizer boundary`
  verifies full-event string scan finds zero secret values + structural
  keys preserved with values `'[redacted]'`.
- WebUI `DaemonSessionProvider.test.tsx` (13 tests, +2) — new tests
  `breaks out of the reconnect loop on 401 / 403 auth failures even when
  autoReconnect is true` and `still reconnects on 404 / 410
  session-not-found errors when autoReconnect is true` lock in the
  asymmetry: auth failure → 1 attempt only; session-not-found → retries
  until success.

## Out of scope (declined / deferred — see PR review reply)

- CRIT #3 `withActionTimeout` test coverage gap → behavior correct,
  test-only follow-up (avoids PR bloat)
- Suggestions #4-7 → 4 nice-to-haves, deferred to keep PR focused on
  production-correctness fixes

Generated with AI

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>

* fix(daemon-ui): redact tool details in web transcript

* fix(daemon-ui): close review gaps in transcript safety

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ChiGao 2026-05-22 14:02:44 +08:00 committed by GitHub
parent 46f8d48f13
commit d0563ecf52
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
33 changed files with 6103 additions and 1993 deletions

View file

@ -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.

View file

@ -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 (
<DaemonSessionProvider baseUrl="http://127.0.0.1:4170">
<Transcript />
<PromptBox />
</DaemonSessionProvider>
);
}
function Transcript() {
const blocks = useDaemonTranscriptBlocks();
return blocks.map((block) => <RenderBlock key={block.id} block={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.

1
package-lock.json generated
View file

@ -22264,6 +22264,7 @@
"version": "0.15.11",
"license": "MIT",
"dependencies": {
"@qwen-code/sdk": "~0.1.7",
"markdown-it": "^14.1.0"
},
"devDependencies": {

View file

@ -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<DaemonTuiEvent> {
private events: DaemonTuiEvent[] = [];
private waiters: Array<{
resolve: (value: IteratorResult<DaemonTuiEvent>) => void;
reject: (error: unknown) => void;
}> = [];
private closed = false;
private failure: unknown;
async next(): Promise<IteratorResult<DaemonTuiEvent>> {
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<IteratorResult<DaemonTuiEvent>> {
this.close();
return { done: true, value: undefined };
}
async throw(error?: unknown): Promise<IteratorResult<DaemonTuiEvent>> {
this.close();
throw error;
}
[Symbol.asyncIterator](): AsyncGenerator<DaemonTuiEvent> {
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<typeof vi.fn>;
events: ReturnType<typeof vi.fn>;
cancel: ReturnType<typeof vi.fn>;
setModel: ReturnType<typeof vi.fn>;
respondToPermission: ReturnType<typeof vi.fn>;
}
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<void> {
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<DaemonTuiEvent> = {
next: vi.fn(() => new Promise<IteratorResult<DaemonTuiEvent>>(() => {})),
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();
});
});

View file

@ -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<DaemonTuiPromptResult>;
events(opts?: {
signal?: AbortSignal;
lastEventId?: number;
resume?: boolean;
}): AsyncGenerator<DaemonTuiEvent>;
cancel(): Promise<void>;
setModel(modelId: string): Promise<Record<string, unknown>>;
respondToPermission(
requestId: string,
response: RequestPermissionResponse,
): Promise<boolean>;
}
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<string, IndividualToolCallDisplay>;
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<string>();
const UNSUPPORTED_PROTOCOL_VERSIONS = new Set<string>();
const debugLogger = createDebugLogger('DAEMON_TUI_ADAPTER');
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, unknown> | 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<string, unknown>,
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<void> | 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<void> {
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<DaemonTuiPromptResult> {
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<void> {
this.assertRunning();
try {
await this.session.cancel();
} catch (error) {
this.reportDaemonFailure(error);
throw createSanitizedDaemonError(error);
}
}
async setModel(modelId: string): Promise<Record<string, unknown>> {
this.assertRunning();
try {
return await this.session.setModel(modelId);
} catch (error) {
this.reportDaemonFailure(error);
throw createSanitizedDaemonError(error);
}
}
async approvePermission(
requestId: string,
optionId: string,
): Promise<boolean> {
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<boolean> {
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<void> {
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<void>): Promise<boolean> {
let timedOut = false;
let timeout: ReturnType<typeof setTimeout> | undefined;
try {
await Promise.race([
pump.then(
() => undefined,
() => undefined,
),
new Promise<void>((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();
}
}
}

View file

@ -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": [

View file

@ -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]}`,
);
}
}

View file

@ -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,

View file

@ -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';

View file

@ -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<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'> {
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<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
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<string, unknown>,
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
): 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<string, unknown>,
base: Pick<DaemonUiEvent, 'eventId' | 'originatorClientId' | 'rawEvent'>,
): 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, 'eventId' | 'originatorClientId' | 'rawEvent'>,
): 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, 'eventId' | 'originatorClientId' | 'rawEvent'>,
): 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<string, unknown> | 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;
}

View file

@ -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<DaemonTranscriptState> = {},
): 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<DaemonTranscriptState> = {}) {
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>,
): 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 ?? {}),
},
};
}

View file

@ -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 `;
}

View file

@ -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<string, unknown>,
): 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<string, unknown>,
): 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;
}

View file

@ -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<string, number> {
const blockIndexById: Record<string, number> = {};
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<Extract<DaemonTranscriptBlock, { kind: 'permission' }>> {
return state.blocks.filter(
(block): block is Extract<DaemonTranscriptBlock, { kind: 'permission' }> =>
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<DaemonUiEvent, { type: 'tool.update' }>,
): 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<DaemonUiEvent, { type: 'shell.output' }>,
): 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<DaemonUiEvent, { type: 'permission.request' }>,
): 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<DaemonTranscriptBlock, { kind: 'permission' }> = {
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<DaemonUiEvent, { type: 'permission.resolved' }>,
): 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<DaemonTranscriptBlock, { kind: 'permission' }> = {
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<DaemonUiEvent, { type: 'tool.update' }>,
): 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<T>(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)}`,
);
}

View file

@ -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<string, number>;
toolBlockByCallId: Record<string, string>;
trimmedToolNotificationByCallId: Record<string, true>;
permissionBlockByRequestId: Record<string, string>;
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<DaemonTranscriptState>): void;
}
export interface DaemonUiSessionActions {
sendPrompt(text: string): Promise<unknown>;
cancel(): Promise<void>;
setModel(modelId: string): Promise<unknown>;
respondToPermission(
requestId: string,
response: PermissionResponse,
): Promise<boolean>;
}

View file

@ -0,0 +1,167 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export function isRecord(value: unknown): value is Record<string, unknown> {
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;

File diff suppressed because it is too large Load diff

View file

@ -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": {

View file

@ -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<BaseToolCallProps> = ({
}
// 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 (
<ToolCallContainer
label={displayLabel}
@ -126,10 +124,7 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({
// 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 (
<ToolCallContainer
label={displayLabel}
@ -145,10 +140,7 @@ export const GenericToolCall: FC<BaseToolCallProps> = ({
// 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 (
<ToolCallContainer
label={displayLabel}

View file

@ -13,6 +13,7 @@ import {
CopyButton,
safeTitle,
groupContent,
mapToolStatusToContainerStatus,
} from './shared/index.js';
import { usePlatform } from '../../context/PlatformContext.js';
import type {
@ -157,9 +158,7 @@ const ShellToolCallImpl: FC<BaseToolCallProps & { variant: ShellVariant }> = ({
| '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) {

View file

@ -68,7 +68,11 @@ export const ThinkToolCall: FC<BaseToolCallProps> = ({
const status =
toolCall.status === 'pending' || toolCall.status === 'in_progress'
? 'loading'
: 'default';
: toolCall.status === 'failed'
? 'error'
: toolCall.status === 'cancelled'
? 'warning'
: 'default';
return (
<ToolCallContainer
label="Think"

View file

@ -66,6 +66,7 @@ const mapToolStatusToBullet = (
case 'failed':
return 'error';
case 'in_progress':
case 'cancelled':
return 'warning';
case 'pending':
return 'loading';

View file

@ -35,7 +35,12 @@ export interface ToolCallLocation {
/**
* Tool call status type
*/
export type ToolCallStatus = 'pending' | 'in_progress' | 'completed' | 'failed';
export type ToolCallStatus =
| 'pending'
| 'in_progress'
| 'completed'
| 'failed'
| 'cancelled';
export type AgentExecutionStatus =
| 'running'

View file

@ -264,6 +264,7 @@ export const hasToolCallOutput = (toolCall: ToolCallData): boolean => {
* 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:

File diff suppressed because it is too large Load diff

View file

@ -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<CreateSessionRequest, 'workspaceCwd'>;
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<DaemonTranscriptStore | undefined>(
undefined,
);
const DaemonConnectionContext = createContext<
DaemonConnectionState | undefined
>(undefined);
const DaemonActionsContext = createContext<DaemonUiSessionActions | undefined>(
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<DaemonSessionClient | undefined>(undefined);
const lastSessionIdRef = useRef<string | undefined>(undefined);
const promptAbortRef = useRef<AbortController | undefined>(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<DaemonConnectionState>({
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<DaemonUiSessionActions>(
() => ({
async sendPrompt(text: string): Promise<PromptResult> {
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<void> {
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<SetModelResult> {
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<boolean> {
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 (
<DaemonStoreContext.Provider value={store}>
<DaemonConnectionContext.Provider value={connection}>
<DaemonActionsContext.Provider value={actions}>
{children}
</DaemonActionsContext.Provider>
</DaemonConnectionContext.Provider>
</DaemonStoreContext.Provider>
);
}
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<string, unknown> {
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<T>(
promise: Promise<T>,
message: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
try {
return await Promise.race([
promise,
new Promise<T>((_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<void> {
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 });
});
}

View file

@ -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';

View file

@ -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<DaemonTranscriptBlock, { kind: 'tool' }> {
return {
id: toolCallId,
kind: 'tool',
toolCallId,
title: 'Tool',
status,
preview: { kind: 'generic' },
createdAt: 1,
updatedAt: 1,
};
}
function createPermissionBlock(
requestId: string,
resolved?: string,
): Extract<DaemonTranscriptBlock, { kind: 'permission' }> {
return {
id: requestId,
kind: 'permission',
requestId,
title: 'Permission',
options: [],
preview: { kind: 'generic' },
createdAt: 1,
updatedAt: 1,
...(resolved !== undefined ? { resolved } : {}),
};
}

View file

@ -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<string>,
expected: ReadonlySet<string>,
): 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<string, unknown> {
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),
},
},
];
}

View file

@ -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,

View file

@ -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

View file

@ -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"]

View file

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