mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-10 01:29:17 +00:00
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:
parent
46f8d48f13
commit
d0563ecf52
33 changed files with 6103 additions and 1993 deletions
|
|
@ -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.
|
||||
118
docs/developers/daemon-client-adapters/web-ui.md
Normal file
118
docs/developers/daemon-client-adapters/web-ui.md
Normal 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
1
package-lock.json
generated
|
|
@ -22264,6 +22264,7 @@
|
|||
"version": "0.15.11",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@qwen-code/sdk": "~0.1.7",
|
||||
"markdown-it": "^14.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
|
|
@ -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]}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
59
packages/sdk-typescript/src/daemon/ui/index.ts
Normal file
59
packages/sdk-typescript/src/daemon/ui/index.ts
Normal 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';
|
||||
475
packages/sdk-typescript/src/daemon/ui/normalizer.ts
Normal file
475
packages/sdk-typescript/src/daemon/ui/normalizer.ts
Normal 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;
|
||||
}
|
||||
110
packages/sdk-typescript/src/daemon/ui/store.ts
Normal file
110
packages/sdk-typescript/src/daemon/ui/store.ts
Normal 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 ?? {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
113
packages/sdk-typescript/src/daemon/ui/terminal.ts
Normal file
113
packages/sdk-typescript/src/daemon/ui/terminal.ts
Normal 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 `;
|
||||
}
|
||||
131
packages/sdk-typescript/src/daemon/ui/toolPreview.ts
Normal file
131
packages/sdk-typescript/src/daemon/ui/toolPreview.ts
Normal 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;
|
||||
}
|
||||
569
packages/sdk-typescript/src/daemon/ui/transcript.ts
Normal file
569
packages/sdk-typescript/src/daemon/ui/transcript.ts
Normal 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)}`,
|
||||
);
|
||||
}
|
||||
265
packages/sdk-typescript/src/daemon/ui/types.ts
Normal file
265
packages/sdk-typescript/src/daemon/ui/types.ts
Normal 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>;
|
||||
}
|
||||
167
packages/sdk-typescript/src/daemon/ui/utils.ts
Normal file
167
packages/sdk-typescript/src/daemon/ui/utils.ts
Normal 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;
|
||||
1437
packages/sdk-typescript/test/unit/daemonUi.test.ts
Normal file
1437
packages/sdk-typescript/test/unit/daemonUi.test.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ const mapToolStatusToBullet = (
|
|||
case 'failed':
|
||||
return 'error';
|
||||
case 'in_progress':
|
||||
case 'cancelled':
|
||||
return 'warning';
|
||||
case 'pending':
|
||||
return 'loading';
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
1086
packages/webui/src/daemon/DaemonSessionProvider.test.tsx
Normal file
1086
packages/webui/src/daemon/DaemonSessionProvider.test.tsx
Normal file
File diff suppressed because it is too large
Load diff
580
packages/webui/src/daemon/DaemonSessionProvider.tsx
Normal file
580
packages/webui/src/daemon/DaemonSessionProvider.tsx
Normal 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 });
|
||||
});
|
||||
}
|
||||
21
packages/webui/src/daemon/index.ts
Normal file
21
packages/webui/src/daemon/index.ts
Normal 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';
|
||||
318
packages/webui/src/daemon/transcriptAdapter.test.ts
Normal file
318
packages/webui/src/daemon/transcriptAdapter.test.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
427
packages/webui/src/daemon/transcriptAdapter.ts
Normal file
427
packages/webui/src/daemon/transcriptAdapter.ts
Normal 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),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
}));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue