feat(qwen-live): standalone voice daemon package — M1 + M2 (#10367)

* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* fix(sdk): raise daemon browser bundle budget

Co-authored-by: Qwen-Coder <[email protected]>

* feat(qwen-live): scaffold standalone live daemon package with ported host stack

New workspace package @qwen-code/qwen-live (M1+M2 of the Live split,
issue #10118). This commit adds the package skeleton and the verbatim
ports that need no behavioral changes:

- host/: Live Host protocol v6 types, LiveHostCoordinator, and the
  discovery-file writer, ported from packages/cli/src/serve/live/
  together with their test suites (51 tests green)
- realtime/socket.ts, realtime/sanitize.ts: the two helper symbols the
  realtime client depends on, extracted from cli ui utils
- registered in scripts/build.js buildOrder and root vitest projects

* feat(qwen-live): generalize the realtime client tool surface

Port qwen-realtime-session.ts and adapt it for the orchestration model:
tools and instructions come from config, every declared tool dispatches
through onFunctionCall with the raw argument payload, and outputs are
receipt-style via submitFunctionOutput. The single-handoff machinery
(multiple_handoffs error, steering acknowledgement, open-call complete)
is removed; transport, response arbitration, pendingCalls timing,
barge-in, and defensive validation are unchanged from the port.
capturesTranscript marks handoff-style tools that capture the live
transcript tail at call time.

Test suite adapted: 27 tests green.

* feat(qwen-live): add the BackendAdaptor seam and the qwen serve adaptor

BackendAdaptor is the capability-bit-driven port between the live
orchestrator and a coding-agent backend. QwenCodeAdaptor drives a
running qwen serve daemon through the @qwen-code/sdk DaemonClient:
202-admission prompts, SSE event normalization (chunks and tool calls
live under session_update.data.update), reject-if-idle mid-turn
steering with a plain-prompt fallback, nested permission votes with
voter attribution, and attachment-backed image blocks. 23 unit tests
against an injected fake client.

* feat(qwen-live): orchestrator, tools, injector, permissions, and daemon entry

The M1+M2 core:
- seven receipt-style dispatch tools plus remain_silent, with a
  dispatcher that answers every call (timeout fallback included) and
  session_N/job_N/asset_N handle registries
- LiveSession wires Host audio, the realtime connection, tool handlers
  (appshot via the Host capture channel, handoff with voice-context
  packing and image attachments, busy-session steering), backend event
  pumps, and a bounded graceful-stop drain
- Injector: safe-insertion window (VAD + response-in-flight + playback
  estimate at 48 kB/s with a quiet gap), batch aggregation, per-job
  progress throttling, barge-in drops progress, spoken/detail split
- PermissionBroker: allow_always stays a one-shot protocol vote backed
  by a local TTL rule; remote resolutions retract queued asks
- LiveDaemon: Host WS endpoint (Bearer + Origin-reject), discovery
  publish with fail-fast mutual exclusion and dead-owner handoff,
  appshot channel marked ready (the in-process capture path needs no
  separate verification), SIGINT/SIGTERM shutdown
- SessionLog: JSONL with synchronous rotation (rename-before-reopen)

116 new unit tests; package total 167 green.

* feat(qwen-live): end-to-end test suites against a real qwen serve daemon

Test stack: a real qwen serve (spawned CLI bundle, fake-openai model
endpoint), a scriptable fake DashScope realtime WebSocket server, the
qwen-live daemon as a child process, and a protocol-v6 FakeHost client
— every boundary is the real wire.

Scenarios (11 tests): discovery publish/remove and hello/welcome,
toggle brings up the realtime session (tool schema + instructions
asserted on session.update), two-way audio byte fidelity, the full
handoff loop against real serve with [COMPLETE] injection and spoken
summary, injection-window hold/batch semantics, permission ask/vote
relay, and busy-session steering tiers.

The suites caught a real bug the unit layer could not: per-session
calls must echo the daemon-issued clientId from the create/attach
response (a self-made id is rejected by the client registration guard
and broke handoff admission and vote attribution). QwenCodeAdaptor now
tracks and echoes the issued id per session.

* chore(release): keep @qwen-code/qwen-live versioned independently

Same treatment as sdk/mobile-mcp/node-repl: the incubating package
stays at its own 0.x version instead of lock-stepping with the CLI
release train. The verbatim guard in package-scripts.test.js is
updated alongside.

* fix(qwen-live): harden review findings across dispatcher, injector, and permission relay

Self-review fixes bundled after the adversarial pass:

- adaptor: classify snake_case permission option ids (proceed_once) —
  the word-boundary regex never matched through "_", so an allow vote
  silently degraded to a cancelled outcome and serve dropped the turn
- adaptor: detect the pending-prompt limit by error name as well as
  status, and fall image-bearing steers back to a regular prompt
- dispatcher: a timed-out tool now answers with a pending receipt that
  tells the model not to retry (the handler keeps running)
- injector: permission items flush ahead of the size-capped context
  join; spoken lines merge whole-line greedily under the 280-char cap
- live-session: resubscribe the event pump with backoff until
  session_closed; a rejected permission chain can no longer become an
  unhandled rejection; receipts are clamped and never empty
- permission broker: a failed auto-approval falls back to the spoken
  ask instead of losing the request
- daemon/config/index: appshot readiness is declared at construction,
  numeric config ports parse, stray rejections are logged not fatal
- e2e harness: QWEN_LIVE_E2E_KEEP=1 keeps the temp dirs for post-mortem

* fix(qwen-live): address review round 1 — permission voting, shutdown, discovery reclaim, and hardening

Permission trust boundary:
- classify options by the wire's structured kind enum (allow_once/
  allow_always/reject_*) instead of word regexes over snake_case ids,
  and vote the least-escalating match — a spoken "allow" now lands on
  proceed_once, never proceed_always_project
- respond_permission's note is no longer discarded: it is logged and
  relayed to the backend session as a follow-up user instruction
- track own votes per session so byUs attribution works on adopted
  sessions; narrow allow_always standing rules to the exact normalized
  command instead of a two-token prefix

Lifecycle:
- daemon stop() no longer hangs when SIGINT lands before listen() or
  when a Host redials during shutdown (stopping gate + client terminate)
- dead-owner discovery reclaim now follows the cli contract (no-op
  commitOwner during handoff, republish after) — previously it could
  never succeed and a crash left the daemon unable to start
- stop drain settles on the input-commit ack instead of burning the
  full 30s budget; response.created during the drain no longer flips
  the call back to speaking; a host-initiated start can no longer race
  deactivate()

Robustness:
- a late ASR final for an already-consumed input item is ignored as
  stale_input instead of dropping the call
- raise the Host frame cap to cover a conformant CJK accessibility
  dump (64KiB frame cap contradicted the 32k-char field cap)
- bin entry works through npm's bin symlink (realpath + pathToFileURL)
- steered handoffs that join a running turn reuse the existing job
  instead of orphaning it; over-long steers fall back to a queued
  prompt; session_list unions created cwds, requests a full page, and
  maps hasActivePrompt for untracked sessions; created-session labels
  are applied via session metadata
- injector: jobless throttle keys use the full context, dedup is
  symmetric, and barge-in drops clear the throttle stamp; sentence
  shapers handle CJK punctuation and mid-token periods
- config loader: ENOENT-only swallow, BOM strip, ~ expansion, typed
  port validation, source-accurate errors; session log close() is
  terminal; transcript retention is capped

Tests and docs: new config/daemon/sanitize suites, win32 guards
restored on discovery mode assertions, protocol parity test now checks
both type copies, qwen-live e2e suites join the no-AK PR gate, fake
model gating in the inject e2e matches only the current turn and is
consumed on match, FakeHost answers capture requests, lockfile diff
reduced to qwen-live-only entries, README states the installer gap.

* test(qwen-live): pin injector size caps and steered-message delivery

- injector: 6000-char combined-context cap, whole-line spoken join at
  the 280-char boundary, single-line ellipsis truncation
- steering e2e: assert the joined instruction actually reached the
  model endpoint, not just the receipt wording

* feat(qwen-live): port the Live Host installer — M1 gap closed

`live-host-installer.ts` moves verbatim from
packages/cli/src/serve/live/ (same OSS/GitHub dual-source manifest
fetch, sha256 + codesign + team-identifier verification, staging +
backup install with rollback, and the retryable status machine), with
its 11-test suite. The daemon gains the matching HTTP surface:

- GET /live/setup — installed Host state
- POST /live/setup/install — download + verify + install the latest
  POST /live/setup/launch — open the installed app

All three sit behind the daemon's existing Bearer wall (a browser
context sending Origin is always refused, wrong token 401) — pinned by
a new daemon test; unknown paths stay 404 and GET /healthz answers
plain 200. Bootstrap no longer depends on the built-in qwen-code Live
integration having installed the Host first.

* fix(qwen-live): attach the appshot-timeout rejection handler before advancing fake timers

The timeout rejects synchronously inside advanceTimersByTimeAsync; a
handler attached only afterwards leaves an unhandled rejection for an
instant, which vitest counts as a run-level error (CI fails the
package with Errors: 1 even though all 250 tests pass). Attach the
expectation first, await it after the clock advances.

* test(qwen-live): sync the no-AK gate pin with the qwen-live suites

The no-ak-integration-ci pin (added upstream after our branch point)
asserts the gate's file list verbatim; add the four qwen-live suites
the gate has been running since they joined it.

* fix(qwen-live): address review round 2 — trust-boundary keys, backpressure, session lifecycle

Criticals (all probed and reproduced before fixing):

- broker titleKeyOf: split on the FIRST colon and keep the entire
  remainder (commands routinely contain colons — a truncated key
  auto-approved a different -m message), and stop case-folding
  (/a vs /A are different paths on Linux). The whitespace-collapse
  pin is preserved
- broker deny: an explicit spoken refusal now revokes the standing
  rule that matched the same request — the silent auto-answer's
  fallback path could otherwise be overridden by the invisible rule
  on the next identical request
- adaptor activeJobRef: a busy-session fallback (image steer, over-
  long text 400) no longer overwrites the running turn's ref with the
  queued prompt's id — later steer receipts report the right job
- adaptor byUs attribution: the daemon's originator stamp is now
  authoritative when present; ownVotes only covers anonymous
  resolutions. Another client resolving during our vote round-trip no
  longer reads as our own answer (R1-2 regression, fixed test-first)
- adaptor permission titles: control sequences stripped before the
  title reaches speech or keys a standing rule (whole command kept —
  no first-line cut, unlike the progress path)
- live-session pushAudio: the provider's backpressure signal is
  propagated (the port source fails the call) and onAudioDropped is
  wired to failCall — a socket over its buffer cap no longer silently
  garbles the utterance; the deliberate true for stale/connecting
  frames is untouched
- handles: closed sessions stop resolving entirely (registry-level),
  and session_closed clears the call's default handle so a session-
  less handoff rebuilds instead of retrying a dead target forever
- handles: read-site reconciliation — session_list and session_monitor
  transition a session's stale non-terminal jobs to done when the
  backend itself reports idle (a turn_complete emitted while no pump
  was subscribed no longer reports a running active_job forever)

Suggestions on this PR's new surfaces:

- live-host.yml: packages/qwen-live/src/host/** joins the path filter
  so the parity-test lane runs on qwen-live-only PRs instead of first
  failing in the merge queue
- installer: the install route passes force=true (the recovery path
  for a corrupted-but-present app, matching the CLI mirror), and
  launch() gains the non-darwin guard in BOTH copies so the byte
  parity pin stays green
- index: directory-form invocation (node packages/qwen-live) now
  runs main() instead of exiting 0 silently
- realtime: the dead lastHandoffEntryCount machinery is removed
  (truncate-and-reset replaced the cursor it pretended to track)

---------

Co-authored-by: Claude <[email protected]>
Co-authored-by: 易良 <[email protected]>
Co-authored-by: yiliang114 <[email protected]>
Co-authored-by: Qwen-Coder <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-09-01 06:37:23 +00:00
1 parent 1aa365ba8e
commit 8290c81eea
60 files changed
+18728 -9

No files matched your search

+4
View File
@@ -8,6 +8,10 @@ on:
- 'packages/cli/src/serve/conversations/**'
- 'packages/cli/src/serve/live/**'
- 'packages/live-host/**'
# The protocol parity test couples the qwen-live host copies into
# this lane; without the path filter a qwen-live-only PR skips it
# and the first red lands in the merge queue instead.
- 'packages/qwen-live/src/host/**'
- 'packages/sdk-typescript/src/daemon/types.ts'
merge_group:
workflow_dispatch:
+365
View File
@@ -0,0 +1,365 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Scriptable stand-in for the DashScope qwen-omni realtime endpoint that
* `packages/qwen-live` connects to (`ws://…/api-ws/v1/realtime?model=…`).
*
* Wire behavior mirrors what the daemon's realtime client
* (`packages/qwen-live/src/realtime/realtime-session.ts`) expects:
* - on connect the server sends `session.created`; the client answers with
* `session.update`, which we acknowledge with `session.updated` so the
* client resolves its open() promise;
* - every client message is recorded in `inbox` for assertions
* (session.update payload, input_audio_buffer.append frames,
* conversation.item.create context/function_call_output items,
* response.create, …);
* - the test drives the model side through the per-connection script API
* (`speakTranscript`, `functionCall`, `respondWithAudio`,
* `beginResponse`/`finishResponse`). Message shapes follow the provider
* events the client's `case` branches parse (see the responseCreated /
* functionCall / responseDone helpers in
* packages/qwen-live/src/realtime/realtime-session.test.ts).
*
* `autoAckResponses` (default on) answers client-initiated `response.create`
* requests (speakToUser flow) with an immediate `response.created` +
* `response.done` pair so the client's response arbitration never stalls.
* Tests that need a response held in flight open one manually with
* `beginResponse()` and settle it with `finishResponse()`.
*/
import { EventEmitter } from 'node:events';
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
import { WebSocketServer, type WebSocket } from 'ws';
type JsonObject = Record<string, unknown>;
function isRecord(value: unknown): value is JsonObject {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
export interface FakeDashScopeFunctionCall {
name: string;
/** Raw JSON string handed to the client as `item.arguments`. */
argumentsJson: string;
callId: string;
}
export interface FakeDashScopeConnection {
readonly index: number;
readonly socket: WebSocket;
/** Raw upgrade-request URL (path + query). */
readonly requestUrl: string;
/** `model` query parameter from the upgrade URL. */
readonly model: string | undefined;
/** `Authorization` header presented by the client. */
readonly authorization: string | undefined;
/** Send one raw provider event (an `event_id` is added automatically). */
send(message: JsonObject): void;
/**
* Simulate one finished user utterance:
* speech_started → speech_stopped → input_audio_buffer.committed →
* conversation.item.input_audio_transcription.completed.
* Returns the item id.
*/
speakTranscript(text: string): string;
/**
* Simulate the model calling a tool: response.created →
* response.output_item.done (item.type = 'function_call') → response.done.
* Returns the response id.
*/
functionCall(call: FakeDashScopeFunctionCall): string;
/**
* Simulate a direct spoken answer: response.created →
* response.audio.delta (base64) → response.audio.done → response.done.
* Returns the response id.
*/
respondWithAudio(pcm16: Uint8Array): string;
/** Open a response and leave it in flight. Returns the response id. */
beginResponse(): string;
/** Settle a response previously opened with beginResponse(). */
finishResponse(responseId: string, status?: string): void;
}
export interface WaitForMessageOptions {
timeoutMs?: number;
/** Only consider inbox entries at or after this index (default 0). */
fromIndex?: number;
description?: string;
}
export interface FakeDashScopeServer {
/** HTTP origin, e.g. `http://127.0.0.1:PORT` — feed to the daemon as QWEN_LIVE_REALTIME_ENDPOINT. */
url: string;
port: number;
connections: FakeDashScopeConnection[];
/** Every JSON message received from every connection, in arrival order. */
inbox: JsonObject[];
/** Answer client `response.create` with created+done automatically. */
autoAckResponses: boolean;
waitForConnection(timeoutMs?: number): Promise<FakeDashScopeConnection>;
waitForMessage(
predicate: (message: JsonObject) => boolean,
opts?: WaitForMessageOptions,
): Promise<JsonObject>;
close(): Promise<void>;
}
/**
* If `message` is a `conversation.item.create` carrying a
* `function_call_output` item, return its call id and output string.
*/
export function functionCallOutputOf(
message: JsonObject,
): { callId: string; output: string } | undefined {
if (message['type'] !== 'conversation.item.create') return undefined;
const item = message['item'];
if (!isRecord(item) || item['type'] !== 'function_call_output') {
return undefined;
}
if (
typeof item['call_id'] !== 'string' ||
typeof item['output'] !== 'string'
) {
return undefined;
}
return { callId: item['call_id'], output: item['output'] };
}
/**
* If `message` is a `conversation.item.create` carrying a user text item
* (the daemon's context/speech injections), return its text body.
*/
export function contextTextOf(message: JsonObject): string | undefined {
if (message['type'] !== 'conversation.item.create') return undefined;
const item = message['item'];
if (!isRecord(item) || item['type'] !== 'message') return undefined;
const content = item['content'];
if (!Array.isArray(content)) return undefined;
const first: unknown = content[0];
if (!isRecord(first) || first['type'] !== 'input_text') return undefined;
return typeof first['text'] === 'string' ? first['text'] : undefined;
}
export async function startFakeDashScopeServer(): Promise<FakeDashScopeServer> {
const httpServer = createServer((_req, res) => {
res.statusCode = 404;
res.end();
});
const wss = new WebSocketServer({ server: httpServer });
const emitter = new EventEmitter();
emitter.setMaxListeners(0);
const connections: FakeDashScopeConnection[] = [];
const inbox: JsonObject[] = [];
let eventSeq = 0;
let itemSeq = 0;
let responseSeq = 0;
const handle: FakeDashScopeServer = {
url: '',
port: 0,
connections,
inbox,
autoAckResponses: true,
waitForConnection: (timeoutMs = 15_000) => {
if (connections.length > 0) return Promise.resolve(connections[0]);
return new Promise<FakeDashScopeConnection>((resolve, reject) => {
const timer = setTimeout(() => {
emitter.off('connection', onConnection);
reject(
new Error(
`fake DashScope: no realtime connection within ${timeoutMs}ms`,
),
);
}, timeoutMs);
const onConnection = (connection: FakeDashScopeConnection) => {
clearTimeout(timer);
resolve(connection);
};
emitter.once('connection', onConnection);
});
},
waitForMessage: (predicate, opts = {}) => {
const fromIndex = opts.fromIndex ?? 0;
const timeoutMs = opts.timeoutMs ?? 15_000;
for (let i = Math.max(fromIndex, 0); i < inbox.length; i++) {
if (predicate(inbox[i])) return Promise.resolve(inbox[i]);
}
return new Promise<JsonObject>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
emitter.off('message', onMessage);
};
const timer = setTimeout(() => {
cleanup();
const recent = inbox
.slice(-15)
.map((message) => String(message['type']))
.join(', ');
reject(
new Error(
`fake DashScope: timed out after ${timeoutMs}ms waiting for ` +
`${opts.description ?? 'a matching client message'} ` +
`(inbox=${inbox.length}, recent types: ${recent})`,
),
);
}, timeoutMs);
const onMessage = (message: JsonObject) => {
if (!predicate(message)) return;
cleanup();
resolve(message);
};
emitter.on('message', onMessage);
});
},
close: () => {
for (const connection of connections) {
try {
connection.socket.terminate();
} catch {
/* already gone */
}
}
wss.close();
return new Promise<void>((resolve) => {
httpServer.close(() => {
resolve();
});
});
},
};
wss.on('connection', (socket: WebSocket, req) => {
const requestUrl = req.url ?? '';
const query = new URL(requestUrl, 'http://127.0.0.1').searchParams;
const authorization =
typeof req.headers['authorization'] === 'string'
? req.headers['authorization']
: undefined;
const sendJson = (body: JsonObject) => {
if (socket.readyState !== socket.OPEN) return;
socket.send(JSON.stringify({ event_id: `evt-${++eventSeq}`, ...body }));
};
const beginResponse = (): string => {
const responseId = `resp-${++responseSeq}`;
sendJson({
type: 'response.created',
response: { id: responseId, status: 'in_progress' },
});
return responseId;
};
const finishResponse = (responseId: string, status = 'completed') => {
sendJson({
type: 'response.done',
response: { id: responseId, status },
});
};
const connection: FakeDashScopeConnection = {
index: connections.length,
socket,
requestUrl,
model: query.get('model') ?? undefined,
authorization,
send: sendJson,
speakTranscript: (text) => {
const itemId = `item-${++itemSeq}`;
sendJson({
type: 'input_audio_buffer.speech_started',
item_id: itemId,
audio_start_ms: 0,
});
sendJson({
type: 'input_audio_buffer.speech_stopped',
item_id: itemId,
audio_end_ms: 400,
});
sendJson({ type: 'input_audio_buffer.committed', item_id: itemId });
sendJson({
type: 'conversation.item.input_audio_transcription.completed',
item_id: itemId,
transcript: text,
});
return itemId;
},
functionCall: (call) => {
const responseId = beginResponse();
sendJson({
type: 'response.output_item.done',
response_id: responseId,
item: {
id: `item-${call.callId}`,
type: 'function_call',
name: call.name,
call_id: call.callId,
arguments: call.argumentsJson,
},
});
finishResponse(responseId);
return responseId;
},
respondWithAudio: (pcm16) => {
if (pcm16.byteLength === 0 || pcm16.byteLength % 2 !== 0) {
throw new Error('respondWithAudio needs a non-empty PCM16 buffer');
}
const responseId = beginResponse();
sendJson({
type: 'response.audio.delta',
response_id: responseId,
item_id: `item-audio-${responseSeq}`,
delta: Buffer.from(pcm16).toString('base64'),
});
sendJson({ type: 'response.audio.done', response_id: responseId });
finishResponse(responseId);
return responseId;
},
beginResponse,
finishResponse,
};
connections.push(connection);
socket.on('message', (data, isBinary) => {
if (isBinary) return; // the realtime protocol is JSON-text only
let parsed: unknown;
try {
parsed = JSON.parse(String(data));
} catch {
return;
}
if (!isRecord(parsed)) return;
inbox.push(parsed);
if (parsed['type'] === 'session.update') {
sendJson({ type: 'session.updated', session: { id: 'sess-1' } });
} else if (
parsed['type'] === 'response.create' &&
handle.autoAckResponses
) {
finishResponse(beginResponse());
}
emitter.emit('message', parsed);
});
// First provider event on a fresh connection; the client answers with
// session.update.
sendJson({ type: 'session.created' });
emitter.emit('connection', connection);
});
await new Promise<void>((resolve, reject) => {
httpServer.once('error', reject);
httpServer.listen(0, '127.0.0.1', () => {
resolve();
});
});
const address = httpServer.address() as AddressInfo;
handle.port = address.port;
handle.url = `http://127.0.0.1:${address.port}`;
return handle;
}
+738
View File
@@ -0,0 +1,738 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* e2e harness for the standalone `qwen-live` daemon (packages/qwen-live).
*
* Everything here talks to real subprocesses over real network/file
* boundaries — no package sources are imported:
* - `spawnQwenLive` boots `node packages/qwen-live/dist/index.js` with a
* hermetic env (tmp data/discovery dirs, fake DashScope endpoint, a real
* `qwen serve` URL) and parses the single machine-readable stdout line,
* following the `_daemon-harness.spawnDaemon` pattern.
* - `FakeHost` speaks Host protocol v6 against the daemon's `/live/host`
* WebSocket: discovery-file lookup, Bearer token + `x-qwen-live-nonce`
* headers, `host.hello`, auto `host.pong`, auto success replies to
* `host.capture_screen_context`, `host.action`, binary input
* audio frames (8-byte BigUInt64BE epoch prefix + PCM16), and records
* `host.welcome`/`host.state` plus raw output PCM frames.
* - `bootLiveStack` assembles the full fixture: tmp workspace + HOME, a
* fake OpenAI model endpoint, a real `qwen serve`, a fake DashScope
* realtime server, the qwen-live daemon, and a connected FakeHost.
* - `waitForLiveLogEvents` reads the daemon's append-only session JSONL
* (`<dataDir>/sessions/*.jsonl`) as a synchronization point for
* daemon-internal happenings (e.g. "the backend turn_complete event
* reached the orchestrator") that have no other observable edge.
*/
import { spawn, type ChildProcess } from 'node:child_process';
import { randomUUID } from 'node:crypto';
import { EventEmitter } from 'node:events';
import {
mkdirSync,
mkdtempSync,
realpathSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { readFile, readdir } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
import WebSocket from 'ws';
import {
sleep,
spawnDaemon,
type SpawnedDaemon,
} from './cli/_daemon-harness.js';
import {
startFakeDashScopeServer,
type FakeDashScopeServer,
} from './fake-dashscope-server.js';
import {
startFakeOpenAIServer,
type FakeOpenAIHandler,
type FakeOpenAIServer,
} from './fake-openai-server.js';
type JsonObject = Record<string, unknown>;
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const QWEN_LIVE_BIN = path.resolve(
__dirname,
'../packages/qwen-live/dist/index.js',
);
export const QWEN_LIVE_REALTIME_MODEL = 'fake-omni-realtime';
export const QWEN_LIVE_API_KEY = 'sk-test';
const SERVE_TOKEN = 'qwen-live-e2e-token';
const LIVE_LISTENING_RE = /qwen-live listening on http:\/\/127\.0\.0\.1:(\d+)/;
const DISPOSE_GRACE_MS = 10_000;
const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host';
const LIVE_HOST_PROTOCOL_VERSION = 6;
const LIVE_INPUT_AUDIO_EPOCH_BYTES = 8;
// -- small async utilities ----------------------------------------------------
export interface Deferred<T = void> {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: unknown) => void;
}
export function deferred<T = void>(): Deferred<T> {
let resolve!: (value: T) => void;
let reject!: (error: unknown) => void;
const promise = new Promise<T>((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
}
export function withTimeout<T>(
promise: Promise<T>,
timeoutMs: number,
label: string,
): Promise<T> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new Error(`timed out after ${timeoutMs}ms waiting for ${label}`));
}, timeoutMs);
});
return Promise.race([promise, timeout]).finally(() => {
if (timer !== undefined) clearTimeout(timer);
}) as Promise<T>;
}
// -- discovery ---------------------------------------------------------------
export interface LiveDiscoveryRecordShape {
url: string;
token?: string;
protocolVersion: number;
pid: number;
instanceNonce: string;
}
export function liveDiscoveryPath(discoveryDir: string): string {
return path.join(discoveryDir, 'live', 'daemon.json');
}
export async function readLiveDiscovery(
discoveryDir: string,
): Promise<LiveDiscoveryRecordShape> {
const raw = await readFile(liveDiscoveryPath(discoveryDir), 'utf8');
return JSON.parse(raw) as LiveDiscoveryRecordShape;
}
// -- qwen-live daemon process --------------------------------------------------
export interface SpawnQwenLiveOptions {
serveUrl: string;
serveToken: string;
realtimeEndpoint: string;
dataDir: string;
discoveryDir: string;
/** Default workspace cwd for handoff-created sessions (QWEN_LIVE_CWD). */
cwd: string;
apiKey?: string;
model?: string;
bootTimeoutMs?: number;
env?: Record<string, string>;
}
export interface SpawnedQwenLive {
proc: ChildProcess;
port: number;
url: string;
stdoutBuf: { value: string };
stderrBuf: { value: string };
/** Idempotent. SIGTERM, wait up to 10s, then SIGKILL. */
dispose: () => Promise<void>;
}
export async function spawnQwenLive(
opts: SpawnQwenLiveOptions,
): Promise<SpawnedQwenLive> {
const bootTimeoutMs = opts.bootTimeoutMs ?? 15_000;
const proc = spawn(process.execPath, [QWEN_LIVE_BIN], {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
DASHSCOPE_API_KEY: opts.apiKey ?? QWEN_LIVE_API_KEY,
QWEN_LIVE_REALTIME_ENDPOINT: opts.realtimeEndpoint,
QWEN_LIVE_REALTIME_MODEL: opts.model ?? QWEN_LIVE_REALTIME_MODEL,
QWEN_LIVE_SERVE_URL: opts.serveUrl,
QWEN_SERVER_TOKEN: opts.serveToken,
QWEN_LIVE_DATA_DIR: opts.dataDir,
QWEN_LIVE_DISCOVERY_DIR: opts.discoveryDir,
QWEN_LIVE_CWD: opts.cwd,
QWEN_LIVE_PORT: '0',
...opts.env,
},
});
const stdoutBuf = { value: '' };
const stderrBuf = { value: '' };
proc.stdout?.on('data', (chunk: Buffer) => {
stdoutBuf.value += chunk.toString();
});
proc.stderr?.on('data', (chunk: Buffer) => {
stderrBuf.value += chunk.toString();
});
const port = await new Promise<number>((resolve, reject) => {
let settled = false;
const cleanup = () => {
proc.stdout?.off('data', onData);
proc.off('exit', onExit);
clearTimeout(bootTimer);
};
const fail = (error: Error, kill = false) => {
if (settled) return;
settled = true;
cleanup();
if (kill && proc.exitCode === null) proc.kill('SIGTERM');
reject(error);
};
const bootTimer = setTimeout(() => {
fail(
new Error(
`qwen-live boot timeout after ${bootTimeoutMs}ms:\n` +
`stdout=${stdoutBuf.value}\nstderr=${stderrBuf.value}`,
),
true,
);
}, bootTimeoutMs);
const onData = () => {
const match = stdoutBuf.value.match(LIVE_LISTENING_RE);
if (!match || settled) return;
settled = true;
cleanup();
resolve(Number(match[1]));
};
const onExit = (code: number | null) => {
fail(
new Error(
`qwen-live exited with ${code} before listening:\n` +
`stdout=${stdoutBuf.value}\nstderr=${stderrBuf.value}`,
),
);
};
proc.stdout!.on('data', onData);
proc.once('exit', onExit);
});
const dispose = async () => {
// Signal death leaves exitCode null and sets signalCode instead — both
// mean the child is already gone and the 'exit' event already fired.
if (proc.exitCode !== null || proc.signalCode !== null) return;
proc.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timer = setTimeout(() => {
try {
proc.kill('SIGKILL');
} catch {
/* already gone */
}
resolve();
}, DISPOSE_GRACE_MS);
proc.once('exit', () => {
clearTimeout(timer);
resolve();
});
});
};
return {
proc,
port,
url: `http://127.0.0.1:${port}`,
stdoutBuf,
stderrBuf,
dispose,
};
}
// -- fake Host (protocol v6) ---------------------------------------------------
export interface FakeHostStateEntry {
type: 'host.welcome' | 'host.state';
epoch: number;
status: JsonObject;
}
export interface FakeHostWaitOptions {
timeoutMs?: number;
/** Only consider entries at or after this index (default 0). */
fromIndex?: number;
}
export class FakeHost {
/** Every parsed text frame from the daemon, in arrival order. */
readonly messages: JsonObject[] = [];
/** host.welcome / host.state frames, normalized. */
readonly states: FakeHostStateEntry[] = [];
/** Raw binary output frames (bare PCM16). */
readonly audioFrames: Buffer[] = [];
private socket: WebSocket | undefined;
private readonly emitter = new EventEmitter();
private readonly hostInstanceNonce = randomUUID();
private screenshotPath: string | undefined;
constructor(private readonly discoveryDir: string) {
this.emitter.setMaxListeners(0);
}
/** Read the discovery file, connect, send host.hello, await host.welcome. */
async connect(timeoutMs = 10_000): Promise<void> {
const record = await readLiveDiscovery(this.discoveryDir);
const socket = new WebSocket(
`${record.url.replace(/^http/, 'ws')}/live/host`,
{
headers: {
Authorization: `Bearer ${record.token ?? ''}`,
'x-qwen-live-nonce': record.instanceNonce,
},
},
);
this.socket = socket;
socket.on('message', (data, isBinary) => {
if (isBinary) {
const frame = Buffer.isBuffer(data)
? Buffer.from(data)
: Array.isArray(data)
? Buffer.concat(data)
: Buffer.from(data as ArrayBuffer);
this.audioFrames.push(frame);
this.emitter.emit('audio', frame);
return;
}
let parsed: unknown;
try {
parsed = JSON.parse(String(data));
} catch {
return;
}
if (typeof parsed !== 'object' || parsed === null) return;
const message = parsed as JsonObject;
this.messages.push(message);
if (message['type'] === 'host.ping') {
this.send({ type: 'host.pong', pingId: message['pingId'] });
} else if (message['type'] === 'host.capture_screen_context') {
// The hello advertises `appshot: true`, so the daemon may request a
// capture; settle it immediately (a real Host replies with a
// screenshot it wrote to disk plus the accessibility dump).
this.send({
type: 'host.screen_context_result',
requestId: message['requestId'],
success: true,
appName: 'FakeApp',
windowTitle: 'Fake Window',
accessibilityText: 'fake accessibility text',
screenshotPath: this.fakeScreenshotPath(),
});
} else if (
message['type'] === 'host.welcome' ||
message['type'] === 'host.state'
) {
const entry: FakeHostStateEntry = {
type: message['type'],
epoch: Number(message['epoch']),
status: (message['status'] ?? {}) as JsonObject,
};
this.states.push(entry);
this.emitter.emit('state', entry);
}
this.emitter.emit('message', message);
});
socket.on('close', (code, reason) => {
this.emitter.emit('close', code, String(reason));
});
await withTimeout(
new Promise<void>((resolve, reject) => {
socket.once('open', () => {
resolve();
});
socket.once('error', reject);
}),
timeoutMs,
'the /live/host WebSocket to open',
);
this.send({
type: 'host.hello',
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
hostVersion: '1.0.0-e2e',
bundleId: LIVE_HOST_BUNDLE_ID,
instanceNonce: this.hostInstanceNonce,
permissions: {
microphone: 'granted',
accessibility: 'granted',
screenRecording: 'granted',
},
selfChecks: {
audioInput: true,
audioOutput: true,
globalShortcut: true,
appshot: true,
},
});
await this.waitForState((entry) => entry.type === 'host.welcome', {
timeoutMs,
});
}
action(action: 'toggle' | 'new' | 'stop'): void {
this.send({ type: 'host.action', action });
}
/** Input audio frame: 8-byte BigUInt64BE epoch prefix + PCM16 payload. */
sendAudio(epoch: number, pcm16: Buffer): void {
const frame = Buffer.alloc(LIVE_INPUT_AUDIO_EPOCH_BYTES + pcm16.byteLength);
frame.writeBigUInt64BE(BigInt(epoch), 0);
pcm16.copy(frame, LIVE_INPUT_AUDIO_EPOCH_BYTES);
this.socketOrThrow().send(frame, { binary: true });
}
waitForState(
predicate: (entry: FakeHostStateEntry) => boolean,
opts: FakeHostWaitOptions = {},
): Promise<FakeHostStateEntry> {
const fromIndex = opts.fromIndex ?? 0;
const timeoutMs = opts.timeoutMs ?? 15_000;
for (let i = Math.max(fromIndex, 0); i < this.states.length; i++) {
if (predicate(this.states[i])) return Promise.resolve(this.states[i]);
}
return new Promise<FakeHostStateEntry>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
this.emitter.off('state', onState);
};
const timer = setTimeout(() => {
cleanup();
const recent = this.states
.slice(-8)
.map((entry) => `${entry.type}:${String(entry.status['state'])}`)
.join(', ');
reject(
new Error(
`FakeHost: timed out after ${timeoutMs}ms waiting for a host ` +
`state (recent: ${recent})`,
),
);
}, timeoutMs);
const onState = (entry: FakeHostStateEntry) => {
if (!predicate(entry)) return;
cleanup();
resolve(entry);
};
this.emitter.on('state', onState);
});
}
waitForAudioFrame(opts: FakeHostWaitOptions = {}): Promise<Buffer> {
const fromIndex = opts.fromIndex ?? 0;
const timeoutMs = opts.timeoutMs ?? 15_000;
if (this.audioFrames.length > fromIndex) {
return Promise.resolve(this.audioFrames[fromIndex]);
}
return new Promise<Buffer>((resolve, reject) => {
const cleanup = () => {
clearTimeout(timer);
this.emitter.off('audio', onAudio);
};
const timer = setTimeout(() => {
cleanup();
reject(
new Error(
`FakeHost: timed out after ${timeoutMs}ms waiting for an output ` +
`audio frame (have ${this.audioFrames.length})`,
),
);
}, timeoutMs);
const onAudio = () => {
if (this.audioFrames.length <= fromIndex) return;
cleanup();
resolve(this.audioFrames[fromIndex]);
};
this.emitter.on('audio', onAudio);
});
}
close(): void {
try {
this.socket?.close();
} catch {
/* already closed */
}
}
private send(message: JsonObject): void {
this.socketOrThrow().send(JSON.stringify(message));
}
/**
* Lazily materialize a real (1x1) PNG for `host.screen_context_result`:
* the daemon registers the path as an asset, so it should exist on disk.
* Written into the discovery dir, which the fixture already tears down.
*/
private fakeScreenshotPath(): string {
if (!this.screenshotPath) {
const file = path.join(this.discoveryDir, 'fake-appshot.png');
writeFileSync(
file,
Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8' +
'z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
'base64',
),
);
this.screenshotPath = file;
}
return this.screenshotPath;
}
private socketOrThrow(): WebSocket {
if (!this.socket) throw new Error('FakeHost is not connected');
return this.socket;
}
}
// -- daemon session log (JSONL) ------------------------------------------------
export interface LiveLogEvent {
ts: number;
seq: number;
type: string;
payload: JsonObject;
}
export async function readLiveLogEvents(
dataDir: string,
): Promise<LiveLogEvent[]> {
const sessionsDir = path.join(dataDir, 'sessions');
let files: string[];
try {
files = await readdir(sessionsDir);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw error;
}
const events: LiveLogEvent[] = [];
for (const file of files.sort()) {
if (!file.includes('.jsonl')) continue;
let raw: string;
try {
raw = await readFile(path.join(sessionsDir, file), 'utf8');
} catch {
continue;
}
for (const line of raw.split('\n')) {
if (!line.trim()) continue;
try {
events.push(JSON.parse(line) as LiveLogEvent);
} catch {
/* torn tail line while the daemon is mid-write */
}
}
}
return events;
}
/**
* Wait until at least `minCount` session-log events match `predicate`.
* The JSONL log has no push channel, so this is a bounded condition-poll
* with an explicit deadline (25ms period).
*/
export async function waitForLiveLogEvents(
dataDir: string,
predicate: (event: LiveLogEvent) => boolean,
opts: { minCount?: number; timeoutMs?: number; description?: string } = {},
): Promise<LiveLogEvent[]> {
const minCount = opts.minCount ?? 1;
const timeoutMs = opts.timeoutMs ?? 30_000;
const deadline = Date.now() + timeoutMs;
for (;;) {
const matches = (await readLiveLogEvents(dataDir)).filter(predicate);
if (matches.length >= minCount) return matches;
if (Date.now() > deadline) {
throw new Error(
`timed out after ${timeoutMs}ms waiting for ` +
`${opts.description ?? 'live session-log events'} ` +
`(${matches.length}/${minCount})`,
);
}
await sleep(25);
}
}
// -- full fixture ---------------------------------------------------------------
export interface BootLiveStackOptions {
/**
* Builds the fake OpenAI handler once the fixture directories exist, so
* handlers can reference paths inside the workspace.
*/
makeOpenAIHandler: (info: { workspaceDir: string }) => FakeOpenAIHandler;
}
export interface LiveStack {
workspaceDir: string;
homeDir: string;
dataDir: string;
discoveryDir: string;
fakeOpenAI: FakeOpenAIServer;
serve: SpawnedDaemon;
fakeDash: FakeDashScopeServer;
live: SpawnedQwenLive;
host: FakeHost;
/**
* Session created directly by the test harness to pre-spawn the serve
* daemon's ACP child (kept open for the fixture's lifetime). Tests that
* enumerate serve sessions should exclude this id.
*/
prewarmSessionId: string;
dispose(): Promise<void>;
}
export async function bootLiveStack(
options: BootLiveStackOptions,
): Promise<LiveStack> {
const workspaceDir = realpathSync(
mkdtempSync(path.join(tmpdir(), 'qwen-live-e2e-ws-')),
);
const homeDir = mkdtempSync(path.join(tmpdir(), 'qwen-live-e2e-home-'));
const dataDir = mkdtempSync(path.join(tmpdir(), 'qwen-live-e2e-data-'));
const discoveryDir = mkdtempSync(path.join(tmpdir(), 'qwen-live-e2e-disc-'));
const qwenHome = path.join(homeDir, '.qwen');
mkdirSync(qwenHome, { recursive: true });
// Keep the model round-trips predictable: no follow-up suggestion turns.
writeFileSync(
path.join(qwenHome, 'settings.json'),
JSON.stringify({ ui: { enableFollowupSuggestions: false } }),
);
const disposers: Array<() => Promise<void> | void> = [];
const disposeAll = async () => {
for (const dispose of disposers.reverse()) {
try {
await dispose();
} catch {
/* keep tearing down */
}
}
// Debug escape hatch: keep the temp dirs (session JSONL logs live in
// `<dataDir>/sessions`) for post-mortem inspection. KEEP_OUTPUT=true is
// the repo-wide convention (test-helper.ts, globalSetup.ts) and is set
// by the e2e lanes; QWEN_LIVE_E2E_KEEP keeps only these fixtures.
if (
!process.env['QWEN_LIVE_E2E_KEEP'] &&
process.env['KEEP_OUTPUT'] !== 'true'
) {
for (const dir of [workspaceDir, homeDir, dataDir, discoveryDir]) {
rmSync(dir, { recursive: true, force: true });
}
}
};
try {
const fakeOpenAI = await startFakeOpenAIServer(
options.makeOpenAIHandler({ workspaceDir }),
);
disposers.push(() => fakeOpenAI.close());
const serve = await spawnDaemon({
workspaceCwd: workspaceDir,
token: SERVE_TOKEN,
bootTimeoutMs: 30_000,
env: {
HOME: homeDir,
QWEN_HOME: qwenHome,
QWEN_ACP_LOCAL_READ_ROOTS: '',
OPENAI_API_KEY: 'fake-key',
OPENAI_BASE_URL: fakeOpenAI.baseUrl,
OPENAI_MODEL: 'fake-model',
QWEN_MODEL: 'fake-model',
NO_PROXY: '127.0.0.1,localhost',
no_proxy: '127.0.0.1,localhost',
http_proxy: '',
https_proxy: '',
HTTP_PROXY: '',
HTTPS_PROXY: '',
ALL_PROXY: '',
all_proxy: '',
},
});
disposers.push(() => serve.dispose());
// Pre-spawn the serve daemon's ACP child so the qwen-live orchestrator's
// first createSession (inside the 5s tool-dispatch budget) is fast.
const prewarm = await serve.client.createOrAttachSession({
workspaceCwd: workspaceDir,
sessionScope: 'thread',
});
const fakeDash = await startFakeDashScopeServer();
disposers.push(() => fakeDash.close());
const live = await spawnQwenLive({
serveUrl: serve.base,
serveToken: SERVE_TOKEN,
realtimeEndpoint: fakeDash.url,
dataDir,
discoveryDir,
cwd: workspaceDir,
});
disposers.push(() => live.dispose());
const host = new FakeHost(discoveryDir);
disposers.push(() => {
host.close();
});
await host.connect();
return {
workspaceDir,
homeDir,
dataDir,
discoveryDir,
fakeOpenAI,
serve,
fakeDash,
live,
host,
prewarmSessionId: prewarm.sessionId,
dispose: disposeAll,
};
} catch (error) {
await disposeAll();
throw error;
}
}
/**
* Drive the Host `toggle` action and wait for the call to become live:
* returns the realtime connection the daemon opened against the fake
* DashScope server and the call epoch (from the `listening` host.state).
*/
export async function startLiveCall(stack: LiveStack): Promise<{
epoch: number;
conn: Awaited<ReturnType<FakeDashScopeServer['waitForConnection']>>;
}> {
const stateIndex = stack.host.states.length;
const connection = stack.fakeDash.waitForConnection(20_000);
stack.host.action('toggle');
const conn = await connection;
const listening = await stack.host.waitForState(
(entry) => entry.status['state'] === 'listening',
{ timeoutMs: 20_000, fromIndex: stateIndex },
);
return { epoch: listening.epoch, conn };
}
+249
View File
@@ -0,0 +1,249 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* qwen-live M1 — one full call, end to end, against real subprocesses:
* a real `qwen serve` (model side backed by the fake OpenAI server), the
* real `qwen-live` daemon binary, a fake DashScope realtime endpoint, and
* a protocol-v6 FakeHost.
*
* a. the discovery file exists with the documented fields;
* b. FakeHost connect → hello → host.welcome;
* c. `toggle` opens the realtime connection (auth header + model query),
* sends session.update with the 8-tool surface, and the call reaches
* `listening`;
* d. direct-answer path: Host input audio frames reach the provider as
* input_audio_buffer.append, provider output audio reaches the Host as
* bare PCM frames;
* e. handoff path: a handoff function call lands on the real serve daemon
* as a prompt; the receipt (function_call_output), the [COMPLETE]
* context injection, and the [SPEAK_TO_USER] + response.create speech
* request all arrive on the provider socket in order;
* f. SIGTERM removes the discovery file.
*/
import { existsSync } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import {
contextTextOf,
functionCallOutputOf,
type FakeDashScopeConnection,
} from './fake-dashscope-server.js';
import {
bootLiveStack,
liveDiscoveryPath,
QWEN_LIVE_API_KEY,
QWEN_LIVE_REALTIME_MODEL,
type LiveStack,
} from './qwen-live-harness.js';
// Windows: the harness relies on POSIX process semantics (SIGTERM shutdown
// path is part of scenario f). Container sandboxes: the serve daemon's ACP
// child cannot reach the host-loopback fake OpenAI server (same skip as
// qwen-serve-streaming.test.ts).
const SKIP =
process.platform === 'win32' ||
Boolean(
process.env['QWEN_SANDBOX'] &&
process.env['QWEN_SANDBOX'].toLowerCase() !== 'false',
);
const describeE2E = SKIP ? describe.skip : describe;
const EXPECTED_TOOL_NAMES = [
'appshot',
'handoff',
'remain_silent',
'respond_permission',
'session_create',
'session_list',
'session_monitor',
'session_stop',
];
describeE2E('qwen-live M1 — end-to-end voice call', () => {
let stack: LiveStack;
let conn: FakeDashScopeConnection;
let callEpoch = 0;
beforeAll(async () => {
stack = await bootLiveStack({
makeOpenAIHandler: () => () => ({
content: 'm1 backend turn complete',
}),
});
}, 180_000);
afterAll(async () => {
await stack?.dispose();
}, 60_000);
it('publishes a discovery record with the documented fields', async () => {
const record = JSON.parse(
await readFile(liveDiscoveryPath(stack.discoveryDir), 'utf8'),
) as Record<string, unknown>;
expect(record['url']).toBe(stack.live.url);
expect(typeof record['token']).toBe('string');
expect(String(record['token']).length).toBeGreaterThan(0);
expect(record['protocolVersion']).toBe(6);
expect(record['pid']).toBe(stack.live.proc.pid);
expect(String(record['instanceNonce'])).toMatch(/^[A-Za-z0-9_-]{16,256}$/);
});
it('answers host.hello with host.welcome', () => {
const welcome = stack.host.states.find(
(entry) => entry.type === 'host.welcome',
);
expect(welcome).toBeDefined();
expect(Number.isInteger(welcome!.epoch)).toBe(true);
expect(welcome!.status['available']).toBe(true);
expect(welcome!.status['state']).toBe('idle');
});
it('toggle connects to the realtime provider and reaches listening', async () => {
const stateIndex = stack.host.states.length;
const connection = stack.fakeDash.waitForConnection(20_000);
stack.host.action('toggle');
conn = await connection;
expect(conn.authorization).toBe(`Bearer ${QWEN_LIVE_API_KEY}`);
expect(conn.model).toBe(QWEN_LIVE_REALTIME_MODEL);
expect(conn.requestUrl).toContain('/api-ws/v1/realtime');
const update = await stack.fakeDash.waitForMessage(
(message) => message['type'] === 'session.update',
{ timeoutMs: 10_000, description: 'session.update' },
);
const session = update['session'] as Record<string, unknown>;
const tools = session['tools'] as Array<{
type: string;
function: { name: string };
}>;
expect(tools.map((tool) => tool.function.name).sort()).toEqual(
EXPECTED_TOOL_NAMES,
);
expect(typeof session['instructions']).toBe('string');
expect(String(session['instructions']).length).toBeGreaterThan(0);
const listening = await stack.host.waitForState(
(entry) => entry.status['state'] === 'listening',
{ timeoutMs: 20_000, fromIndex: stateIndex },
);
callEpoch = listening.epoch;
expect(callEpoch).toBeGreaterThan(0);
});
it('routes Host input audio up and provider output audio down', async () => {
const pcmIn = Buffer.alloc(3_200);
for (let i = 0; i < pcmIn.length; i++) pcmIn[i] = i % 251;
const inboxIndex = stack.fakeDash.inbox.length;
stack.host.sendAudio(callEpoch, pcmIn);
const append = await stack.fakeDash.waitForMessage(
(message) => message['type'] === 'input_audio_buffer.append',
{ fromIndex: inboxIndex, description: 'input_audio_buffer.append' },
);
expect(Buffer.from(String(append['audio']), 'base64').equals(pcmIn)).toBe(
true,
);
const pcmOut = Buffer.alloc(4_800);
for (let i = 0; i < pcmOut.length; i++) pcmOut[i] = (i * 7) % 253;
const framesBefore = stack.host.audioFrames.length;
conn.respondWithAudio(pcmOut);
const frame = await stack.host.waitForAudioFrame({
fromIndex: framesBefore,
});
expect(frame.equals(pcmOut)).toBe(true);
});
it('hands off to the real serve daemon and injects the result back', async () => {
const inboxIndex = stack.fakeDash.inbox.length;
conn.speakTranscript('fix the failing test');
conn.functionCall({
name: 'handoff',
argumentsJson: '{"task":"fix the failing test"}',
callId: 'call-1',
});
// Receipt: the handoff was admitted by qwen serve.
const receiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === 'call-1',
{
timeoutMs: 30_000,
fromIndex: inboxIndex,
description: 'the handoff function_call_output receipt',
},
);
const receipt = JSON.parse(
functionCallOutputOf(receiptMessage)!.output,
) as Record<string, unknown>;
expect(receipt['status']).toBe('accepted');
expect(receipt['job']).toBe('job_1');
// The real serve daemon received the prompt (model side = fake OpenAI).
await expect
.poll(
() =>
stack.fakeOpenAI.requests.some((request) =>
JSON.stringify(request.body['messages'] ?? []).includes(
'fix the failing test',
),
),
{ timeout: 30_000 },
)
.toBe(true);
// The turn's conclusion flows back as a silent [COMPLETE] context item…
const completeMessage = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return text !== undefined && text.includes('[COMPLETE job_1]');
},
{
timeoutMs: 30_000,
fromIndex: inboxIndex,
description: 'the [COMPLETE job_1] context injection',
},
);
const completeText = contextTextOf(completeMessage)!;
expect(completeText).toMatch(/^\[BACKEND\] /);
expect(completeText).toContain('m1 backend turn complete');
expect(stack.fakeDash.inbox.indexOf(receiptMessage)).toBeLessThan(
stack.fakeDash.inbox.indexOf(completeMessage),
);
// …and as a spoken line: a [SPEAK_TO_USER] item followed by
// response.create (speakToUser wire shape in realtime-session.ts).
const speakMessage = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return text !== undefined && text.startsWith('[SPEAK_TO_USER] ');
},
{
timeoutMs: 15_000,
fromIndex: inboxIndex,
description: 'the [SPEAK_TO_USER] speech request item',
},
);
expect(contextTextOf(speakMessage)).toContain('job_1');
const speakIndex = stack.fakeDash.inbox.indexOf(speakMessage);
await stack.fakeDash.waitForMessage(
(message) => message['type'] === 'response.create',
{
timeoutMs: 15_000,
fromIndex: speakIndex,
description: 'the response.create following [SPEAK_TO_USER]',
},
);
});
it('removes the discovery file on SIGTERM', async () => {
const discoveryFile = liveDiscoveryPath(stack.discoveryDir);
expect(existsSync(discoveryFile)).toBe(true);
await stack.live.dispose(); // SIGTERM → graceful stop
expect(existsSync(discoveryFile)).toBe(false);
});
});
@@ -0,0 +1,234 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* qwen-live M2 — result-injection window discipline (orchestrator/injector):
*
* 1. a backend turn_complete that arrives while a realtime response is in
* flight is NOT injected; it is delivered right after the response
* settles (`response.done`);
* 2. several jobs finishing while the window is closed are batched into a
* single conversation.item.create context injection (Injector.flush
* joins the whole batch into one silent context message).
*
* The fake OpenAI handler gates each backend turn on a marker-keyed deferred
* so the test controls exactly when qwen serve finishes each turn. The
* daemon-side "turn_complete reached the orchestrator" edge is observed
* through the daemon's session JSONL log.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { sleep } from './cli/_daemon-harness.js';
import {
contextTextOf,
functionCallOutputOf,
type FakeDashScopeConnection,
} from './fake-dashscope-server.js';
import {
bootLiveStack,
deferred,
startLiveCall,
waitForLiveLogEvents,
type Deferred,
type LiveStack,
} from './qwen-live-harness.js';
const SKIP =
process.platform === 'win32' ||
Boolean(
process.env['QWEN_SANDBOX'] &&
process.env['QWEN_SANDBOX'].toLowerCase() !== 'false',
);
const describeE2E = SKIP ? describe.skip : describe;
const isTurnComplete = (event: {
type: string;
payload: Record<string, unknown>;
}) =>
event.type === 'backend.event' && event.payload['type'] === 'turn_complete';
describeE2E('qwen-live M2 — injection window', () => {
/** Consumed by the fake model: a gate is deleted the moment it matches. */
const gates = new Map<string, Deferred>();
/** Stable test-side resolve handles (the fake model never touches these). */
const gateHandles = new Map<string, Deferred>();
let stack: LiveStack;
let conn: FakeDashScopeConnection;
let callSeq = 0;
const gatedHandoff = async (
task: string,
extraArgs: Record<string, unknown> = {},
): Promise<Record<string, unknown>> => {
const gate = deferred();
gates.set(task, gate);
gateHandles.set(task, gate);
const callId = `call-h${++callSeq}`;
conn.functionCall({
name: 'handoff',
argumentsJson: JSON.stringify({ task, ...extraArgs }),
callId,
});
const receiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === callId,
{ timeoutMs: 30_000, description: `handoff receipt for ${task}` },
);
const receipt = JSON.parse(
functionCallOutputOf(receiptMessage)!.output,
) as Record<string, unknown>;
expect(receipt['status']).toBe('accepted');
expect(typeof receipt['job']).toBe('string');
return receipt;
};
/** Open a held response and wait until the daemon has registered it. */
const holdResponse = async (): Promise<string> => {
const stateIndex = stack.host.states.length;
const holdId = conn.beginResponse();
await stack.host.waitForState(
(entry) => entry.status['state'] === 'speaking',
{ timeoutMs: 10_000, fromIndex: stateIndex },
);
return holdId;
};
beforeAll(async () => {
stack = await bootLiveStack({
makeOpenAIHandler:
() =>
async ({ body }) => {
// qwen serve sends the WHOLE conversation history with every model
// request, and both tests share one backend session — so match
// markers only against the current turn's LAST message (earlier
// turns' markers stay in the history forever) and consume the gate
// on match so a settled gate can never vacuously match a later
// turn.
const messages = (body['messages'] ?? []) as unknown[];
const lastMessage = JSON.stringify(messages.at(-1) ?? '');
for (const [marker, gate] of gates) {
if (lastMessage.includes(marker)) {
gates.delete(marker);
await gate.promise;
return { content: `finished ${marker}` };
}
}
return { content: 'ok' };
},
});
conn = (await startLiveCall(stack)).conn;
}, 180_000);
afterAll(async () => {
for (const gate of gateHandles.values()) gate.resolve(); // never leave serve hung
await stack?.dispose();
}, 60_000);
it('holds [COMPLETE] while a response is in flight and injects after response.done', async () => {
const inboxIndex = stack.fakeDash.inbox.length;
const receipt = await gatedHandoff('inject-window-task');
const job = String(receipt['job']);
// Close the injection window: a response is now in flight.
const holdId = await holdResponse();
// Let the backend turn finish and wait until the daemon's orchestrator
// has consumed the turn_complete event (session-log sync point).
gateHandles.get('inject-window-task')!.resolve();
await waitForLiveLogEvents(stack.dataDir, isTurnComplete, {
minCount: 1,
timeoutMs: 30_000,
description: 'backend.event turn_complete (inject-window-task)',
});
// Negative assertion needs a bounded settle window: a (buggy) premature
// injection would be written to this loopback socket within milliseconds
// of the log line above — there is no further event to await when the
// implementation is correct.
await sleep(250);
const premature = stack.fakeDash.inbox
.slice(inboxIndex)
.map((message) => contextTextOf(message) ?? '')
.filter((text) => text.includes('[COMPLETE'));
expect(premature).toEqual([]);
// Reopen the window: the queued conclusion must now arrive.
conn.finishResponse(holdId);
const complete = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return text !== undefined && text.includes(`[COMPLETE ${job}]`);
},
{
timeoutMs: 15_000,
fromIndex: inboxIndex,
description: `[COMPLETE ${job}] after response.done`,
},
);
expect(contextTextOf(complete)).toContain('finished inject-window-task');
});
it('batches multiple completions into one context injection', async () => {
// A second backend session so two independent turns can complete.
conn.functionCall({
name: 'session_create',
argumentsJson: JSON.stringify({ label: 'second workstream' }),
callId: 'call-sc',
});
const createdMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === 'call-sc',
{ timeoutMs: 30_000, description: 'session_create receipt' },
);
const created = JSON.parse(
functionCallOutputOf(createdMessage)!.output,
) as Record<string, unknown>;
expect(created['status']).toBe('ok');
const secondSession = String(created['handle']);
const receiptA = await gatedHandoff('batch-task-a');
const receiptB = await gatedHandoff('batch-task-b', {
session: secondSession,
});
expect(receiptB['session']).toBe(secondSession);
const jobA = String(receiptA['job']);
const jobB = String(receiptB['job']);
expect(jobA).not.toBe(jobB);
const inboxIndex = stack.fakeDash.inbox.length;
const holdId = await holdResponse();
gateHandles.get('batch-task-a')!.resolve();
gateHandles.get('batch-task-b')!.resolve();
// 1 turn_complete from the previous test + 2 here.
await waitForLiveLogEvents(stack.dataDir, isTurnComplete, {
minCount: 3,
timeoutMs: 30_000,
description: 'backend.event turn_complete (batch-task-a/b)',
});
await sleep(250); // bounded settle window for the negative assertion
const premature = stack.fakeDash.inbox
.slice(inboxIndex)
.map((message) => contextTextOf(message) ?? '')
.filter((text) => text.includes('[COMPLETE'));
expect(premature).toEqual([]);
conn.finishResponse(holdId);
// The injector flushes the whole queue as ONE combined context item:
// both job conclusions must land in the same conversation.item.create.
const merged = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return text !== undefined && text.includes(`[COMPLETE ${jobA}]`);
},
{
timeoutMs: 15_000,
fromIndex: inboxIndex,
description: `the merged [COMPLETE ${jobA}]/[COMPLETE ${jobB}] injection`,
},
);
const mergedText = contextTextOf(merged)!;
expect(mergedText).toContain(`[COMPLETE ${jobA}]`);
expect(mergedText).toContain(`[COMPLETE ${jobB}]`);
});
});
@@ -0,0 +1,211 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* qwen-live M2 — permission forwarding across the voice boundary:
*
* A real `qwen serve` session (approval mode `default`) hits a `write_file`
* tool call, which raises a daemon permission_request. The qwen-live
* orchestrator must inject a `[PERMISSION req_1]` context item plus a spoken
* ask into the realtime conversation; the voice model's
* `respond_permission {decision:"allow"}` call must be delivered back to
* serve as a vote, after which the tool runs and the turn completes.
*
* A warmup handoff first materializes the orchestrator's default session so
* the test can pin its approval mode deterministically before the
* permission-triggering turn.
*/
import { readFileSync } from 'node:fs';
import * as path from 'node:path';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { fakeToolCall } from './fake-openai-server.js';
import {
contextTextOf,
functionCallOutputOf,
type FakeDashScopeConnection,
} from './fake-dashscope-server.js';
import {
bootLiveStack,
startLiveCall,
type LiveStack,
} from './qwen-live-harness.js';
const SKIP =
process.platform === 'win32' ||
Boolean(
process.env['QWEN_SANDBOX'] &&
process.env['QWEN_SANDBOX'].toLowerCase() !== 'false',
);
const describeE2E = SKIP ? describe.skip : describe;
const PERM_FILE_NAME = 'perm-e2e.txt';
const PERM_FILE_CONTENT = 'permission-granted';
describeE2E('qwen-live M2 — permission relay', () => {
let stack: LiveStack;
let conn: FakeDashScopeConnection;
let permFilePath = '';
beforeAll(async () => {
stack = await bootLiveStack({
makeOpenAIHandler: ({ workspaceDir }) => {
permFilePath = path.join(workspaceDir, PERM_FILE_NAME);
return ({ body }) => {
const messages = JSON.stringify(body['messages'] ?? []);
const hasToolResult =
messages.includes('"role":"tool"') ||
messages.includes('"tool_call_id"');
if (messages.includes('perm-write-task')) {
if (!hasToolResult) {
return {
toolCalls: [
fakeToolCall('write_file', {
file_path: permFilePath,
content: PERM_FILE_CONTENT,
}),
],
};
}
return { content: 'permission turn complete' };
}
if (messages.includes('perm-warmup')) {
return { content: 'warmup done' };
}
return { content: 'ok' };
};
},
});
conn = (await startLiveCall(stack)).conn;
}, 180_000);
afterAll(async () => {
await stack?.dispose();
}, 60_000);
it('relays the permission ask to voice and delivers the allow vote', async () => {
// Warmup: materialize the orchestrator's default serve session.
conn.functionCall({
name: 'handoff',
argumentsJson: JSON.stringify({ task: 'perm-warmup' }),
callId: 'call-w',
});
const warmupReceiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === 'call-w',
{ timeoutMs: 30_000, description: 'the warmup handoff receipt' },
);
const warmupReceipt = JSON.parse(
functionCallOutputOf(warmupReceiptMessage)!.output,
) as Record<string, unknown>;
expect(warmupReceipt['status']).toBe('accepted');
const warmupJob = String(warmupReceipt['job']);
await stack.fakeDash.waitForMessage(
(message) =>
contextTextOf(message)?.includes(`[COMPLETE ${warmupJob}]`) ?? false,
{ timeoutMs: 30_000, description: 'the warmup [COMPLETE] injection' },
);
// Pin the approval mode of the orchestrator-created session so the
// write below deterministically raises a permission_request.
const sessions = await stack.serve.client.listWorkspaceSessions(
stack.workspaceDir,
);
const target = sessions.find(
(session) => session.sessionId !== stack.prewarmSessionId,
);
expect(target).toBeDefined();
await stack.serve.client.setSessionApprovalMode(
target!.sessionId,
'default',
);
// The permission-triggering handoff.
const inboxIndex = stack.fakeDash.inbox.length;
conn.functionCall({
name: 'handoff',
argumentsJson: JSON.stringify({ task: 'perm-write-task' }),
callId: 'call-p',
});
const receiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === 'call-p',
{
timeoutMs: 30_000,
fromIndex: inboxIndex,
description: 'the perm-write-task handoff receipt',
},
);
const receipt = JSON.parse(
functionCallOutputOf(receiptMessage)!.output,
) as Record<string, unknown>;
expect(receipt['status']).toBe('accepted');
const job = String(receipt['job']);
// The ask reaches the voice model: silent [PERMISSION req_1] context…
const permissionMessage = await stack.fakeDash.waitForMessage(
(message) =>
contextTextOf(message)?.includes('[PERMISSION req_1]') ?? false,
{
timeoutMs: 30_000,
fromIndex: inboxIndex,
description: 'the [PERMISSION req_1] context injection',
},
);
const permissionText = contextTextOf(permissionMessage)!;
expect(permissionText).toContain('respond_permission');
expect(permissionText).toContain('wants to run');
// …plus a spoken ask ([SPEAK_TO_USER] speech request).
const speakMessage = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return (
text !== undefined &&
text.startsWith('[SPEAK_TO_USER] ') &&
text.includes('Should I allow it?')
);
},
{
timeoutMs: 15_000,
fromIndex: inboxIndex,
description: 'the spoken permission ask',
},
);
expect(contextTextOf(speakMessage)).toBeDefined();
// The user says yes: respond_permission must deliver the vote to serve.
conn.functionCall({
name: 'respond_permission',
argumentsJson: '{"request_id":"req_1","decision":"allow"}',
callId: 'call-2',
});
const voteReceiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === 'call-2',
{
timeoutMs: 15_000,
fromIndex: inboxIndex,
description: 'the respond_permission receipt',
},
);
const voteReceipt = JSON.parse(
functionCallOutputOf(voteReceiptMessage)!.output,
) as Record<string, unknown>;
expect(voteReceipt['status']).toBe('delivered');
// Serve accepted the vote: the tool ran and the turn completed.
const completeMessage = await stack.fakeDash.waitForMessage(
(message) =>
contextTextOf(message)?.includes(`[COMPLETE ${job}]`) ?? false,
{
timeoutMs: 60_000,
fromIndex: inboxIndex,
description: `[COMPLETE ${job}] after the allow vote`,
},
);
expect(contextTextOf(completeMessage)).toContain(
'permission turn complete',
);
expect(readFileSync(permFilePath, 'utf8')).toBe(PERM_FILE_CONTENT);
});
});
@@ -0,0 +1,208 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* qwen-live M2 — steering semantics of `handoff` receipts:
*
* 1. while a serve turn is running (the fake OpenAI handler holds the
* model response on a gate), a second handoff targeting the same
* session is delivered as a mid-turn message — the receipt reads
* `status:"accepted"` with the 'joined the currently running task'
* note (qwen-code-adaptor's steer path);
* 2. once the session is idle again, a handoff with an explicit `session`
* argument is a plain accepted prompt (new job, no 'joined' note).
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { sleep } from './cli/_daemon-harness.js';
import {
contextTextOf,
functionCallOutputOf,
type FakeDashScopeConnection,
} from './fake-dashscope-server.js';
import {
bootLiveStack,
deferred,
startLiveCall,
withTimeout,
type Deferred,
type LiveStack,
} from './qwen-live-harness.js';
const SKIP =
process.platform === 'win32' ||
Boolean(
process.env['QWEN_SANDBOX'] &&
process.env['QWEN_SANDBOX'].toLowerCase() !== 'false',
);
const describeE2E = SKIP ? describe.skip : describe;
const SLOW_MARKER = 'steer-slow-task';
describeE2E('qwen-live M2 — mid-turn steering', () => {
let stack: LiveStack;
let conn: FakeDashScopeConnection;
let sessionHandle = '';
const slowGate: Deferred = deferred();
const slowRequestSeen: Deferred = deferred();
/** Serialized `messages` of every model request the fake endpoint saw. */
const modelRequests: string[] = [];
const toolCall = async (
name: string,
callId: string,
args: Record<string, unknown>,
): Promise<Record<string, unknown>> => {
conn.functionCall({
name,
argumentsJson: JSON.stringify(args),
callId,
});
const receiptMessage = await stack.fakeDash.waitForMessage(
(message) => functionCallOutputOf(message)?.callId === callId,
{ timeoutMs: 30_000, description: `${name} receipt ${callId}` },
);
return JSON.parse(functionCallOutputOf(receiptMessage)!.output) as Record<
string,
unknown
>;
};
const handoff = (
callId: string,
args: Record<string, unknown>,
): Promise<Record<string, unknown>> => toolCall('handoff', callId, args);
/**
* Poll the session through the protocol's own probe (session_monitor)
* until the orchestrator reports it idle. A steered mid-turn message can
* legitimately be promoted into a follow-up turn after the first one
* completes, so "the [COMPLETE] arrived" does not imply "idle".
*/
let monitorSeq = 0;
const waitForIdleSession = async (
session: string,
timeoutMs = 30_000,
): Promise<void> => {
const deadline = Date.now() + timeoutMs;
for (;;) {
const snapshot = await toolCall(
'session_monitor',
`call-mon-${++monitorSeq}`,
{
session,
},
);
expect(snapshot['status']).toBe('ok');
if (snapshot['state'] === 'idle') return;
if (Date.now() > deadline) {
throw new Error(
`session ${session} still ${String(snapshot['state'])} after ${timeoutMs}ms`,
);
}
await sleep(200);
}
};
beforeAll(async () => {
stack = await bootLiveStack({
makeOpenAIHandler:
() =>
async ({ body }) => {
const messages = JSON.stringify(body['messages'] ?? []);
modelRequests.push(messages);
if (messages.includes(SLOW_MARKER)) {
slowRequestSeen.resolve();
await slowGate.promise; // hang the turn until the test releases it
return { content: 'slow task finished' };
}
return { content: 'ok' };
},
});
conn = (await startLiveCall(stack)).conn;
}, 180_000);
afterAll(async () => {
slowGate.resolve(); // never leave serve hung on teardown
await stack?.dispose();
}, 60_000);
it('joins the running turn when handing off to a busy session', async () => {
const first = await handoff('call-s1', { task: SLOW_MARKER });
expect(first['status']).toBe('accepted');
expect(typeof first['job']).toBe('string');
sessionHandle = String(first['session']);
expect(sessionHandle).toMatch(/^session_\d+$/);
// The serve turn is really running: the model request is parked on the
// gate inside the fake OpenAI handler.
await withTimeout(
slowRequestSeen.promise,
30_000,
'the slow turn to reach the fake model endpoint',
);
const second = await handoff('call-s2', {
task: 'also update the changelog',
session: sessionHandle,
});
expect(second['status']).toBe('accepted');
expect(second['session']).toBe(sessionHandle);
expect(String(second['note'])).toContain('joined');
// Release the turn; its conclusion must flow back as [COMPLETE].
const inboxIndex = stack.fakeDash.inbox.length;
slowGate.resolve();
const complete = await stack.fakeDash.waitForMessage(
(message) => {
const text = contextTextOf(message);
return text !== undefined && /\[COMPLETE job_\d+\]/.test(text);
},
{
timeoutMs: 30_000,
fromIndex: inboxIndex,
description: 'the [COMPLETE] injection for the steered turn',
},
);
expect(contextTextOf(complete)).toContain('slow task finished');
});
it('accepts a plain handoff to the now-idle session', async () => {
// The joined instruction may be promoted into a follow-up turn after the
// first turn completes; wait until the orchestrator reports idle.
await waitForIdleSession(sessionHandle);
// The 'joined' receipt was not just wording: by the time the session is
// idle again, the steered instruction must have reached the model in
// some request (injected mid-turn or promoted to a follow-up turn).
expect(
modelRequests.some((request) =>
request.includes('also update the changelog'),
),
).toBe(true);
const receipt = await handoff('call-s3', {
task: 'one more quick task',
session: sessionHandle,
});
expect(receipt['status']).toBe('accepted');
expect(receipt['session']).toBe(sessionHandle);
expect(typeof receipt['job']).toBe('string');
// Idle session ⇒ a fresh prompt, not a mid-turn join.
expect(String(receipt['note'] ?? '')).not.toContain('joined');
expect(String(receipt['note'] ?? '')).not.toContain('queued');
const job = String(receipt['job']);
await stack.fakeDash.waitForMessage(
(message) =>
contextTextOf(message)?.includes(`[COMPLETE ${job}]`) ?? false,
{
timeoutMs: 30_000,
description: `[COMPLETE ${job}] for the idle handoff`,
},
);
});
});
+128
View File
@@ -4048,6 +4048,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4700,6 +4703,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4717,6 +4723,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4734,6 +4743,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4751,6 +4763,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4768,6 +4783,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4785,6 +4803,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4802,6 +4823,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -4819,6 +4843,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5042,6 +5069,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5056,6 +5086,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5070,6 +5103,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5084,6 +5120,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5098,6 +5137,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5112,6 +5154,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5126,6 +5171,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5140,6 +5188,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7121,6 +7172,9 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7135,6 +7189,9 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7149,6 +7206,9 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7163,6 +7223,9 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7177,6 +7240,9 @@
"loong64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7191,6 +7257,9 @@
"loong64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7205,6 +7274,9 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7219,6 +7291,9 @@
"ppc64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7233,6 +7308,9 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7247,6 +7325,9 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7261,6 +7342,9 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7275,6 +7359,9 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7289,6 +7376,9 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -35231,6 +35321,44 @@
"optional": true
}
}
},
"node_modules/@qwen-code/qwen-live": {
"resolved": "packages/qwen-live",
"link": true
},
"packages/qwen-live": {
"name": "@qwen-code/qwen-live",
"version": "0.1.0",
"license": "Apache-2.0",
"dependencies": {
"@qwen-code/sdk": "file:../sdk-typescript",
"ansi-regex": "^6.2.2",
"proper-lockfile": "^4.1.2",
"ws": "^8.18.0"
},
"bin": {
"qwen-live": "dist/index.js"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/proper-lockfile": "^4.1.4",
"@types/ws": "^8.5.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
"engines": {
"node": ">=22"
}
},
"packages/qwen-live/node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
}
}
}
+1 -1
View File
@@ -55,7 +55,7 @@
"test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests",
"test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests",
"test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
"test:integration:no-ak:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 ./fake-openai-server.test.ts ./test-helper.test.ts ./chat-transcript-contract.test.ts ./qwen-live-m1-call.test.ts ./qwen-live-m2-inject.test.ts ./qwen-live-m2-permission.test.ts ./qwen-live-m2-steering.test.ts ./cli/daemon-invocation-context.test.ts ./cli/list_directory.test.ts ./cli/qwen-serve-routes.test.ts ./cli/qwen-serve-streaming.test.ts ./sdk-typescript/abort-and-lifecycle.test.ts ./sdk-typescript/permission-control.test.ts ./sdk-typescript/sdk-mcp-server.test.ts ./sdk-typescript/subagents.test.ts ./sdk-typescript/system-control.test.ts ./sdk-typescript/tool-control.test.ts",
"test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
"test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.forks.maxForks 2 sdk-typescript",
"test:sdk:python": "python3 -m pytest -c packages/sdk-python/pyproject.toml packages/sdk-python/tests -q",
@@ -457,6 +457,9 @@ export class LiveHostInstaller {
}
async launch(): Promise<LiveHostInstallStatus> {
if (this.platform !== 'darwin') {
return this.setError('Qwen Live Host is available only on macOS.', false);
}
if (this.operation) return await this.operation;
try {
const installed = await this.inspectInstalled();
@@ -947,9 +947,9 @@ describe('ToolRegistry', () => {
'hidden_by_allowlist',
);
expect(registry.isDeferredAndHidden('hidden_by_allowlist')).toBe(false);
expect(registry.getDeferredToolSummary().map((t) => t.name)).not.toContain(
'hidden_by_allowlist',
);
expect(
registry.getDeferredToolSummary().map((t) => t.name),
).not.toContain('hidden_by_allowlist');
});
it('reveals the schema once ToolSearch loads the tool', async () => {
@@ -113,7 +113,23 @@ const DAEMON_PROTOCOL_TYPES_URL = new URL(
import.meta.url,
);
const QWEN_LIVE_PROTOCOL_TYPES_URL = new URL(
'../../../../qwen-live/src/host/types.ts',
import.meta.url,
);
describe('Live Host protocol', () => {
it('keeps the qwen-live daemon contract byte-identical to the cli copy', async () => {
// PROTOCOL_TYPE_PARITY type-checks against the cli copy only; the
// standalone qwen-live daemon validates and emits against its own copy.
// Byte-identity keeps a change to either copy from drifting silently.
const [cliSource, qwenLiveSource] = await Promise.all([
readFile(fileURLToPath(DAEMON_PROTOCOL_TYPES_URL), 'utf8'),
readFile(fileURLToPath(QWEN_LIVE_PROTOCOL_TYPES_URL), 'utf8'),
]);
assert.equal(qwenLiveSource, cliSource);
});
it('stays synchronized with the daemon protocol contract', async () => {
const source = await readFile(
fileURLToPath(DAEMON_PROTOCOL_TYPES_URL),
+2
View File
@@ -0,0 +1,2 @@
dist/
*.tsbuildinfo
+57
View File
@@ -0,0 +1,57 @@
# @qwen-code/qwen-live
Standalone Live voice daemon: a realtime voice control plane that orchestrates
Qwen Code sessions.
`qwen-live` connects three parties:
- **Qwen Live Host** (the macOS overlay app) over the Live Host WebSocket
protocol v6 — the same wire protocol and `~/.qwen/live/daemon.json` discovery
file the built-in `qwen serve` Live integration uses, so an already-installed
Host binary connects without changes.
- **A DashScope realtime voice model** (`qwen-omni` realtime) that owns the
conversation: VAD, direct answers, and a small tool surface for dispatching
work.
- **Qwen Code sessions** through a `BackendAdaptor`. The first adaptor drives a
running `qwen serve` daemon over its REST/SSE surface.
The live session itself is fully owned by this daemon (JSONL logs under
`~/.qwen-live/sessions/`); backend sessions are ordinary Qwen Code sessions
that keep running after a call ends.
## Run
```bash
# Requires a running `qwen serve` and a DashScope API key.
DASHSCOPE_API_KEY=sk-... qwen-live
```
Configuration comes from environment variables (`QWEN_LIVE_*`,
`DASHSCOPE_API_KEY`, `QWEN_SERVER_TOKEN`) with `~/.qwen-live/config.json` as
the file-based fallback. See `src/config.ts` for the full list.
Only one Live daemon may own the Host discovery file at a time. If the
built-in `qwen serve` Live integration is enabled and running, `qwen-live`
fails fast at startup instead of taking over.
## Host bootstrap
The Live Host installer is ported: `GET /live/setup` reports the installed
Host's state, `POST /live/setup/install` downloads, verifies (sha256 +
codesign + team identifier), and installs the latest Host release, and
`POST /live/setup/launch` opens it. macOS only (the same surface `qwen
serve` exposes). All three sit behind the daemon's Bearer token; a browser
context (Origin header) is always refused.
```bash
TOKEN=$(jq -r .token ~/.qwen/live/daemon.json)
PORT=$(jq -r .url ~/.qwen/live/daemon.json | sed 's/.*://')
curl -H "authorization: Bearer $TOKEN" "http://127.0.0.1:$PORT/live/setup"
```
## Status
Incubating inside the qwen-code monorepo, tracking M1+M2 (plus the M1
installer, now ported) of the Live split roadmap (issue #10118). The Host app itself, non-qwen-code adaptors (M4), and retiring
the built-in Live integration (M5) are tracked in later milestones of
issue #10118.
+41
View File
@@ -0,0 +1,41 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Build script for @qwen-code/qwen-live.
*
* Cleans dist plus the incremental build info together (removing dist alone
* leaves tsc's buildinfo claiming everything is up to date, so `tsc --build`
* would emit nothing), compiles TypeScript, and sanity-checks the emit.
*/
import { existsSync, rmSync } from 'node:fs';
import path from 'node:path';
import { createRequire } from 'node:module';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
const require = createRequire(import.meta.url);
const here = path.dirname(fileURLToPath(import.meta.url));
rmSync(path.join(here, 'dist'), { recursive: true, force: true });
rmSync(path.join(here, 'tsconfig.build.tsbuildinfo'), { force: true });
// Resolve tsc through node rather than `npx` so this also works on Windows,
// where execFileSync cannot resolve `npx.cmd` without a shell.
execFileSync(
process.execPath,
[require.resolve('typescript/bin/tsc'), '--build', 'tsconfig.build.json'],
{ cwd: here, stdio: 'inherit' },
);
for (const required of ['index.js', 'daemon.js']) {
const emitted = path.join(here, 'dist', required);
if (!existsSync(emitted)) {
throw new Error(
`build produced no dist/${required} — tsc emitted nothing (stale tsconfig.build.tsbuildinfo?)`,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
{
"name": "@qwen-code/qwen-live",
"version": "0.1.0",
"description": "Standalone Live voice daemon: a realtime voice control plane that orchestrates Qwen Code sessions.",
"repository": {
"type": "git",
"url": "git+https://github.com/QwenLM/qwen-code.git",
"directory": "packages/qwen-live"
},
"type": "module",
"license": "Apache-2.0",
"engines": {
"node": ">=22"
},
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {
"qwen-live": "dist/index.js"
},
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./package.json": "./package.json"
},
"files": [
"dist",
"!dist/**/*.test.*"
],
"scripts": {
"build": "node build.mjs",
"build:ts": "tsc --build tsconfig.build.json",
"clean": "rm -rf dist tsconfig.tsbuildinfo tsconfig.build.tsbuildinfo",
"test": "vitest run",
"test:ci": "vitest run",
"typecheck": "tsc --noEmit",
"watch": "tsc --watch"
},
"dependencies": {
"@qwen-code/sdk": "file:../sdk-typescript",
"ansi-regex": "^6.2.2",
"proper-lockfile": "^4.1.2",
"ws": "^8.18.0"
},
"devDependencies": {
"@types/node": "^22.13.10",
"@types/proper-lockfile": "^4.1.4",
"@types/ws": "^8.5.0",
"typescript": "^5.3.3",
"vitest": "^3.1.1"
},
"bugs": {
"url": "https://github.com/QwenLM/qwen-code/issues"
},
"homepage": "https://github.com/QwenLM/qwen-code/tree/main/packages/qwen-live#readme"
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,837 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* BackendAdaptor implementation that drives a running `qwen serve` daemon
* over its REST/SSE surface via the official `@qwen-code/sdk` DaemonClient.
*
* Notes anchored to daemon behavior this adaptor relies on:
* - `POST /session/:id/prompt` answers 202 with a `promptId` — that is the
* admission receipt the orchestrator's handoff tool returns on.
* - `agent_message_chunk` / `tool_call` live inside
* `session_update.data.update.sessionUpdate`, not as top-level types.
* - `POST /session/:id/mid-turn-message` hardcodes reject-if-idle, which is
* exactly the probe `prompt({steer:true})` needs: accepted means the
* instruction joined the running turn, rejected means the session went
* idle and a normal prompt is the right fallback.
* - `permission_resolved.originatorClientId` names the *voter*, so comparing
* it against the daemon-issued clientId for the session distinguishes "we
* answered" from "someone answered in WebShell". The issued id comes back
* on the create/attach response; self-made ids are rejected by the
* daemon's client registration guard, so every per-session call echoes
* the issued one. Sessions adopted via `session_list` never receive an
* issued id, so the adaptor also records every requestId it voted on and
* attributes a resolution for one of those to itself.
*/
import { DaemonClient } from '@qwen-code/sdk';
import type {
BackendAdaptor,
BackendCapabilities,
BackendEvent,
BackendHandle,
ContentBlock,
PermissionDecision,
PermissionOption,
PermissionOptionKind,
PromptReceipt,
SessionSummary,
} from './types.js';
const ADAPTOR_NAME = 'qwen-code';
/** Feature tags this adaptor refuses to run without (all `since: v1`). */
const REQUIRED_FEATURES = [
'session_create',
'session_prompt',
'session_events',
'session_cancel',
'session_permission_vote',
'session_mid_turn_message_mutation',
] as const;
/** Keep receipts and summaries bounded; backends can produce huge turns. */
const MAX_SUMMARY_CHARS = 4_000;
const MAX_DETAIL_CHARS = 48_000;
/**
* Structural subset of DaemonClient used by this adaptor. Unit tests inject
* a fake; production passes a real DaemonClient (or omits it to have one
* constructed from `baseUrl`/`token`).
*/
export interface DaemonClientLike {
capabilities(): Promise<{
features?: readonly string[];
workspaceCwd?: string;
}>;
createOrAttachSession(
req: Record<string, unknown>,
clientId?: string,
): Promise<{
sessionId: string;
hasActivePrompt?: boolean;
clientId?: string;
}>;
listWorkspaceSessions(
workspaceCwd: string,
options?: Record<string, unknown>,
): Promise<Array<Record<string, unknown>>>;
promptNonBlocking(
sessionId: string,
req: Record<string, unknown>,
signal?: AbortSignal,
clientId?: string,
): Promise<Record<string, unknown>>;
subscribeEvents(
sessionId: string,
opts?: Record<string, unknown>,
): AsyncGenerator<{
id?: number;
v: 1;
type: string;
data: unknown;
promptId?: string;
originatorClientId?: string;
}>;
enqueueMidTurnMessage(
sessionId: string,
message: string,
opts?: Record<string, unknown>,
): Promise<{ accepted: boolean; messageId?: string }>;
cancel(sessionId: string, clientId?: string): Promise<void>;
respondToSessionPermission(
sessionId: string,
requestId: string,
response: Record<string, unknown>,
clientId?: string,
): Promise<boolean>;
uploadSessionAttachment(
sessionId: string,
data: Blob,
name: string,
mimeType: string,
opts?: Record<string, unknown>,
): Promise<Record<string, unknown>>;
updateSessionMetadata(
sessionId: string,
metadata: { displayName?: string },
clientId?: string,
): Promise<{ displayName?: string }>;
}
export interface QwenCodeAdaptorOptions {
/** qwen serve base URL, e.g. `http://127.0.0.1:4170`. */
baseUrl: string;
token?: string;
/** Default cwd for new sessions; falls back to the daemon's workspaceCwd. */
defaultCwd?: string;
/** Stable client identity used for permission-vote attribution. */
clientId?: string;
/** Injection seam for unit tests. */
client?: DaemonClientLike;
}
interface SessionState {
busy: boolean;
activeJobRef?: string;
/** Daemon-issued client id for this session; echoed on every call. */
clientId?: string;
/** Accumulated assistant text for the current turn. */
turnBuffer: string;
/** Options captured per pending permission request, for decision mapping. */
permissionOptions: Map<string, readonly PermissionOption[]>;
/**
* Permission requestIds this adaptor voted on. Sessions adopted via
* session_list carry no daemon-issued clientId, so originator comparison
* alone can never attribute our own vote — this set is the fallback.
* Entries are removed when the request resolves.
*/
ownVotes: Set<string>;
closed: boolean;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
/**
* Map one wire permission option to the adaptor's kind + escalation.
*
* qwen serve stamps every option with the ACP structured `kind`
* (`allow_once` | `allow_always` | `reject_once` | `reject_always` — see
* `toPermissionOptions` in packages/cli acp-integration/permissionUtils.ts);
* that is authoritative when present. An unknown structured kind fails
* closed to 'other'. The word heuristics run only when the backend omitted
* the field entirely.
*/
function classifyOption(
optionId: string,
name: string | undefined,
wireKind: string | undefined,
): { kind: PermissionOptionKind; escalation?: 'once' | 'always' } {
switch (wireKind) {
case 'allow_once':
return { kind: 'proceed', escalation: 'once' };
case 'allow_always':
return { kind: 'proceed', escalation: 'always' };
case 'reject_once':
return { kind: 'reject', escalation: 'once' };
case 'reject_always':
return { kind: 'reject', escalation: 'always' };
default:
break;
}
// Fail closed: a structured kind we do not understand must not be votable
// through a bare "allow"/"deny".
if (wireKind !== undefined) return { kind: 'other' };
// Fallback for backends that omit the structured kind. Snake/kebab ids
// ("proceed_once") must split into words — `\b` treats an underscore as a
// word character and would never match inside them.
const haystack = `${optionId} ${name ?? ''}`
.toLowerCase()
.replace(/[_-]/g, ' ');
const escalation = /\balways\b/.test(haystack)
? ('always' as const)
: /\bonce\b/.test(haystack)
? ('once' as const)
: undefined;
// Word boundaries matter: a bare /no/ would match inside "notify" and
// misroute an allow vote to a reject option.
if (/\b(allow|proceed|approve|yes|accept)\b/.test(haystack)) {
return { kind: 'proceed', ...(escalation ? { escalation } : {}) };
}
if (/\b(deny|reject|refuse|no|cancel)\b/.test(haystack)) {
return { kind: 'reject', ...(escalation ? { escalation } : {}) };
}
return { kind: 'other' };
}
/**
* The narrowest option of the wanted kind. A bare voice "allow" must take
* the one-shot grant, never persist an always-allow rule (serve offers
* [proceed_always_project, proceed_always_user, proceed_once, cancel] —
* first-match would pick the project-wide rule).
*/
function pickLeastEscalating(
options: readonly PermissionOption[],
wanted: PermissionOptionKind,
): PermissionOption | undefined {
const rank = (option: PermissionOption): number =>
option.escalation === 'once' ? 0 : option.escalation === undefined ? 1 : 2;
let best: PermissionOption | undefined;
for (const candidate of options) {
if (candidate.kind !== wanted) continue;
if (best === undefined || rank(candidate) < rank(best)) best = candidate;
}
return best;
}
/**
* Compose the human-readable permission title. Control sequences are
* stripped (the title flows verbatim into the spoken ask and keys the
* broker's standing rule — raw ESC/OSC bytes must not reach speech or
* anchor a trust grant), but NOT truncated to the first line: the
* standing-rule key must span the whole command.
*/
function describeToolCall(toolCall: unknown): string {
if (!isRecord(toolCall)) return 'a tool call';
const name = typeof toolCall['name'] === 'string' ? toolCall['name'] : '';
const command =
typeof toolCall['command'] === 'string' ? toolCall['command'] : '';
const title = typeof toolCall['title'] === 'string' ? toolCall['title'] : '';
const detail = stripControlSequences(command || title);
if (name && detail) return `${name}: ${detail}`;
return name || detail || 'a tool call';
}
/**
* Remove terminal control sequences without the first-line cut that
* sanitizeTitleLine applies — the permission key needs the whole command.
* Mirrors the same sequence families (OSC, CSI, SS2/SS3/DCS, C0/DEL/C1).
*/
function stripControlSequences(text: string): string {
return (
text
// eslint-disable-next-line no-control-regex
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/\x1b[NOP]/g, '')
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g, ' ')
);
}
/**
* Slice the last `max` UTF-16 units, snapping the cut off a split surrogate
* pair: a cut landing between the halves would lead with a lone low
* surrogate that renders/speaks as U+FFFD.
*/
function tailSlice(text: string, max: number): string {
let start = text.length - max;
const unit = text.charCodeAt(start);
if (unit >= 0xdc00 && unit <= 0xdfff) start += 1;
return text.slice(start);
}
function clampTail(text: string, max: number): string {
const trimmed = text.trim();
if (trimmed.length <= max) return trimmed;
return `…${tailSlice(trimmed, max)}`;
}
/**
* One clean line for a tool-call title: first line only, terminal control
* sequences stripped. Minimal inline of core's stripTerminalControlSequences
* (packages/core utils/terminalSafe.ts) — the monolith's voice consumer
* applied the same guard before titles reached the realtime model
* (live-session-coordinator.ts), and neither acp-bridge nor the daemon
* sanitizes upstream.
*/
function sanitizeTitleLine(title: string): string {
const firstLine = title.split(/\r?\n/, 1)[0] ?? '';
return (
firstLine
// OSC: ESC ] ... (BEL | ST)
// eslint-disable-next-line no-control-regex
.replace(/\x1b\][\s\S]*?(?:\x07|\x1b\\)/g, '')
// CSI: ESC [ params intermediates final
// eslint-disable-next-line no-control-regex
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
// SS2/SS3/DCS leaders
// eslint-disable-next-line no-control-regex
.replace(/\x1b[NOP]/g, '')
// Remaining C0 controls + DEL + C1 controls
// eslint-disable-next-line no-control-regex
.replace(/[\x00-\x1f\x7f-\x9f]/g, ' ')
.trim()
);
}
export class QwenCodeAdaptor implements BackendAdaptor {
readonly name = ADAPTOR_NAME;
private readonly client: DaemonClientLike;
private readonly options: QwenCodeAdaptorOptions;
private readonly sessions = new Map<string, SessionState>();
/** Every cwd sessions were created in; listSessions unions across them. */
private readonly sessionCwds = new Set<string>();
private workspaceCwd: string | undefined;
constructor(options: QwenCodeAdaptorOptions) {
this.options = options;
this.client =
options.client ??
(new DaemonClient({
baseUrl: options.baseUrl,
...(options.token ? { token: options.token } : {}),
}) as unknown as DaemonClientLike);
}
capabilities(): BackendCapabilities {
return {
steering: 'native',
imageInput: true,
permissionForwarding: true,
// Session-to-session messaging (proactive speak) is milestone M3.
proactiveSpeak: false,
sessionList: true,
eventDelivery: 'stream',
};
}
async preflight(): Promise<void> {
let caps: Awaited<ReturnType<DaemonClientLike['capabilities']>>;
try {
caps = await this.client.capabilities();
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
const status =
isRecord(error) && typeof error['status'] === 'number'
? error['status']
: undefined;
if (status !== undefined) {
// The daemon answered — it is running but refused the request.
// A "start it" hint would send the operator hunting for a daemon
// that already exists.
throw new Error(
`qwen serve at ${this.options.baseUrl} responded with HTTP ` +
`${status} and refused the request: ${detail}. ` +
'Check the auth token and base URL.',
);
}
throw new Error(
`qwen serve is not reachable at ${this.options.baseUrl}: ` +
`${detail}. ` +
'Start it with `qwen serve` before launching qwen-live.',
);
}
const features = new Set(caps.features ?? []);
const missing = REQUIRED_FEATURES.filter((f) => !features.has(f));
if (missing.length > 0) {
throw new Error(
`qwen serve at ${this.options.baseUrl} is missing required ` +
`capabilities: ${missing.join(', ')}. Upgrade qwen-code.`,
);
}
this.workspaceCwd = caps.workspaceCwd;
}
async createSession(opts?: {
cwd?: string;
label?: string;
}): Promise<BackendHandle> {
const cwd = opts?.cwd ?? this.options.defaultCwd ?? this.workspaceCwd;
const session = await this.client.createOrAttachSession(
{
...(cwd !== undefined ? { workspaceCwd: cwd } : {}),
sessionScope: 'thread',
sourceType: 'qwen-live',
},
this.options.clientId,
);
if (cwd !== undefined) this.sessionCwds.add(cwd);
const state = this.trackSession(session.sessionId, {
busy: session.hasActivePrompt === true,
// The daemon issues the authoritative per-client id on create/attach;
// every later call for this session must echo it (a self-made id is
// rejected by the daemon's client registration guard). Older daemons
// omit it — those calls stay anonymous.
...(typeof session.clientId === 'string'
? { clientId: session.clientId }
: {}),
});
if (opts?.label !== undefined) {
// The daemon session's displayName is what listSessions (here and in
// every other client) reports back — the requested label is useless
// unless it is applied to the daemon session itself.
await this.client.updateSessionMetadata(
session.sessionId,
{ displayName: opts.label },
state.clientId,
);
}
return { id: session.sessionId, adaptor: ADAPTOR_NAME };
}
async listSessions(): Promise<SessionSummary[]> {
const defaultCwd = this.options.defaultCwd ?? this.workspaceCwd;
const cwds = new Set<string>();
if (defaultCwd !== undefined) cwds.add(defaultCwd);
for (const cwd of this.sessionCwds) cwds.add(cwd);
const summaries = new Map<string, SessionSummary>();
for (const cwd of cwds) {
// The daemon catalog accumulates sessions from every client and the
// SDK defaults to a 20-row first page; 1000 is the server-side clamp.
const sessions = await this.client.listWorkspaceSessions(cwd, {
pageSize: 1000,
});
for (const raw of sessions) {
const sessionId = raw['sessionId'];
if (typeof sessionId !== 'string' || summaries.has(sessionId)) {
continue;
}
const tracked = this.sessions.get(sessionId);
const label = raw['displayName'];
const hasActivePrompt = raw['hasActivePrompt'];
summaries.set(sessionId, {
handle: { id: sessionId, adaptor: ADAPTOR_NAME },
...(typeof label === 'string' ? { label } : {}),
cwd,
state: tracked
? tracked.closed
? ('closed' as const)
: tracked.busy
? ('busy' as const)
: ('idle' as const)
: // Untracked (e.g. after a qwen-live restart): the wire record
// itself says whether a prompt is running.
typeof hasActivePrompt === 'boolean'
? hasActivePrompt
? ('busy' as const)
: ('idle' as const)
: ('unknown' as const),
});
}
}
return [...summaries.values()];
}
async prompt(
handle: BackendHandle,
blocks: readonly ContentBlock[],
opts?: { steer?: boolean },
): Promise<PromptReceipt> {
const state = this.trackSession(handle.id);
if (opts?.steer && state.busy) {
// Mid-turn injection is text-only on the wire; a handoff carrying
// image attachments must go through a full prompt so the images are
// not silently dropped — the fall-through below queues it as the
// session's next turn.
const hasImages = blocks.some((block) => block.type === 'image');
if (!hasImages) {
const message = blocks
.filter(
(block): block is Extract<ContentBlock, { type: 'text' }> =>
block.type === 'text',
)
.map((block) => block.text)
.join('\n\n');
let steered: { accepted: boolean; messageId?: string } | undefined;
try {
steered = await this.client.enqueueMidTurnMessage(
handle.id,
message,
state.clientId !== undefined ? { clientId: state.clientId } : {},
);
} catch (error) {
// The mid-turn route 400s on over-long messages; the identical
// payload is admissible as a queued full prompt, so fall through
// instead of failing the handoff. Anything else is a real error.
if (!(isRecord(error) && error['status'] === 400)) throw error;
}
if (steered?.accepted) {
return {
status: 'accepted',
joinedActiveTurn: true,
...(state.activeJobRef !== undefined
? { jobRef: state.activeJobRef }
: {}),
note: 'joined the currently running task',
};
}
}
// Either the turn ended between our busy check and the injection, or
// the payload needs a full prompt: fall through — that is exactly the
// "queued as the next turn" tier.
}
const prompt = await this.buildPromptBlocks(handle.id, blocks);
let accepted: Record<string, unknown>;
try {
accepted = await this.client.promptNonBlocking(
handle.id,
{ prompt },
undefined,
state.clientId,
);
} catch (error) {
// Queue-full comes in two shapes: DaemonHttpError carries a top-level
// 503 status; the SDK's client-side guard throws
// DaemonPendingPromptLimitError (no status field).
if (
isRecord(error) &&
(error['status'] === 503 ||
error['name'] === 'DaemonPendingPromptLimitError')
) {
return {
status: 'rejected',
note: 'the session is busy and its queue is full; wait or stop the current task first',
};
}
throw error;
}
const promptId = accepted['promptId'];
const jobRef = typeof promptId === 'string' ? promptId : undefined;
// Only advance activeJobRef when this prompt actually starts the
// session's turn: a busy-session fallback (image steer, over-long text
// 400) queues behind the running turn, and overwriting the ref here
// would make a later accepted steer report the QUEUED prompt's id as
// the running turn's jobRef. pending_prompt_started establishes it
// when the queued prompt eventually runs.
const wasBusy = state.busy;
state.busy = true;
if (!wasBusy && jobRef !== undefined) state.activeJobRef = jobRef;
return {
status: opts?.steer ? 'queued' : 'accepted',
...(jobRef !== undefined ? { jobRef } : {}),
...(opts?.steer
? { note: 'queued as the next task in that session' }
: {}),
};
}
async *events(
handle: BackendHandle,
opts?: { signal?: AbortSignal },
): AsyncIterable<BackendEvent> {
const state = this.trackSession(handle.id);
const stream = this.client.subscribeEvents(handle.id, {
...(opts?.signal ? { signal: opts.signal } : {}),
...(state.clientId !== undefined ? { clientId: state.clientId } : {}),
});
for await (const envelope of stream) {
const events = this.normalize(state, envelope);
for (const event of events) {
yield event;
if (event.type === 'session_closed') return;
}
}
}
isBusy(handle: BackendHandle): boolean {
return this.sessions.get(handle.id)?.busy === true;
}
async cancel(handle: BackendHandle): Promise<void> {
await this.client.cancel(handle.id, this.sessions.get(handle.id)?.clientId);
}
async respondPermission(
handle: BackendHandle,
requestId: string,
decision: PermissionDecision,
): Promise<'delivered' | 'already_resolved'> {
const state = this.trackSession(handle.id);
const options = state.permissionOptions.get(requestId) ?? [];
let response: Record<string, unknown>;
if (decision === 'cancel') {
response = { outcome: { outcome: 'cancelled' } };
} else {
const wanted: PermissionOptionKind =
decision === 'allow' ? 'proceed' : 'reject';
const option = pickLeastEscalating(options, wanted);
response = option
? { outcome: { outcome: 'selected', optionId: option.optionId } }
: { outcome: { outcome: 'cancelled' } };
}
// Record the vote BEFORE the HTTP round-trip: the daemon publishes the
// SSE permission_resolved before the vote response returns, so waiting
// for `delivered` can lose the race against our own event pump — and on
// adopted sessions the own-vote record is the only byUs signal.
state.ownVotes.add(requestId);
let delivered: boolean;
try {
delivered = await this.client.respondToSessionPermission(
handle.id,
requestId,
response,
state.clientId,
);
} catch (error) {
state.ownVotes.delete(requestId);
throw error;
}
if (delivered) {
state.permissionOptions.delete(requestId);
} else {
// Someone else settled it first; our vote did not count.
state.ownVotes.delete(requestId);
}
return delivered ? 'delivered' : 'already_resolved';
}
async close(): Promise<void> {
this.sessions.clear();
}
// -- internals -----------------------------------------------------------
private trackSession(
sessionId: string,
seed?: Partial<Pick<SessionState, 'busy' | 'clientId'>>,
): SessionState {
let state = this.sessions.get(sessionId);
if (!state) {
state = {
busy: seed?.busy ?? false,
turnBuffer: '',
permissionOptions: new Map(),
ownVotes: new Set(),
closed: false,
};
this.sessions.set(sessionId, state);
} else if (seed?.busy !== undefined) {
state.busy = seed.busy;
}
if (seed?.clientId !== undefined) state.clientId = seed.clientId;
return state;
}
private async buildPromptBlocks(
sessionId: string,
blocks: readonly ContentBlock[],
): Promise<Array<Record<string, unknown>>> {
const prompt: Array<Record<string, unknown>> = [];
for (const block of blocks) {
if (block.type === 'text') {
prompt.push({ type: 'text', text: block.text });
continue;
}
// Image: upload as a session attachment and reference it, so the
// prompt body stays small and the daemon owns the bytes.
const reference = await this.client.uploadSessionAttachment(
sessionId,
new Blob([block.data as BlobPart], { type: block.mimeType }),
block.name ?? 'attachment',
block.mimeType,
);
prompt.push(reference);
}
return prompt;
}
private normalize(
state: SessionState,
envelope: {
type: string;
data: unknown;
promptId?: string;
originatorClientId?: string;
},
): BackendEvent[] {
const data = isRecord(envelope.data) ? envelope.data : {};
switch (envelope.type) {
case 'pending_prompt_started': {
state.busy = true;
state.turnBuffer = '';
const jobRef = envelope.promptId ?? state.activeJobRef;
if (jobRef !== undefined) state.activeJobRef = jobRef;
return [
{ type: 'turn_started', ...(jobRef !== undefined ? { jobRef } : {}) },
];
}
case 'session_update': {
const update = isRecord(data['update']) ? data['update'] : undefined;
if (!update) return [];
const kind = update['sessionUpdate'];
if (kind === 'agent_message_chunk') {
const content = isRecord(update['content'])
? update['content']
: undefined;
const text = content?.['text'];
if (typeof text === 'string') {
state.turnBuffer = `${state.turnBuffer}${text}`;
if (state.turnBuffer.length > MAX_DETAIL_CHARS) {
state.turnBuffer = tailSlice(state.turnBuffer, MAX_DETAIL_CHARS);
}
}
return [];
}
if (kind === 'tool_call') {
const title = update['title'];
const summary =
typeof title === 'string' ? sanitizeTitleLine(title) : '';
return [
{
type: 'progress',
...(envelope.promptId !== undefined
? { jobRef: envelope.promptId }
: {}),
summary: summary || 'running a tool',
},
];
}
return [];
}
case 'turn_complete': {
state.busy = false;
const jobRef = envelope.promptId ?? state.activeJobRef;
state.activeJobRef = undefined;
const detail = state.turnBuffer.trim();
state.turnBuffer = '';
return [
{
type: 'turn_complete',
...(jobRef !== undefined ? { jobRef } : {}),
summary: clampTail(detail, MAX_SUMMARY_CHARS),
...(detail ? { detail } : {}),
},
];
}
case 'turn_error':
case 'prompt_cancelled': {
state.busy = false;
const jobRef = envelope.promptId ?? state.activeJobRef;
state.activeJobRef = undefined;
state.turnBuffer = '';
const message = data['message'] ?? data['error'];
return [
{
type: 'turn_error',
...(jobRef !== undefined ? { jobRef } : {}),
error:
envelope.type === 'prompt_cancelled'
? 'cancelled'
: typeof message === 'string'
? message
: 'the task failed',
},
];
}
case 'permission_request': {
const requestId = data['requestId'];
if (typeof requestId !== 'string') return [];
const rawOptions = Array.isArray(data['options'])
? data['options']
: [];
const options: PermissionOption[] = rawOptions.flatMap((raw) => {
if (!isRecord(raw) || typeof raw['optionId'] !== 'string') return [];
// Wire shape (ACP PermissionOption): optionId + name + kind.
const name =
typeof raw['name'] === 'string' ? raw['name'] : undefined;
const wireKind =
typeof raw['kind'] === 'string' ? raw['kind'] : undefined;
const classified = classifyOption(raw['optionId'], name, wireKind);
return [
{
optionId: raw['optionId'],
...(name !== undefined ? { label: name } : {}),
kind: classified.kind,
...(classified.escalation !== undefined
? { escalation: classified.escalation }
: {}),
},
];
});
state.permissionOptions.set(requestId, options);
return [
{
type: 'permission_request',
requestId,
title: describeToolCall(data['toolCall']),
options,
payload: data,
},
];
}
case 'permission_resolved':
case 'permission_already_resolved': {
const requestId = data['requestId'];
if (typeof requestId !== 'string') return [];
state.permissionOptions.delete(requestId);
// The daemon's originator stamp is authoritative when present:
// another client (WebShell) can resolve the request while our own
// vote is still mid-round-trip, and the short-circuit below would
// otherwise attribute THEIR resolution to us (skipping the spoken
// ask retraction). ownVotes only covers anonymous resolutions —
// adopted sessions carry no issued clientId to compare against.
const votedByUs = state.ownVotes.delete(requestId);
return [
{
type: 'permission_resolved',
requestId,
byUs:
envelope.originatorClientId !== undefined
? state.clientId !== undefined &&
envelope.originatorClientId === state.clientId
: votedByUs,
},
];
}
case 'session_died':
case 'session_closed': {
state.closed = true;
state.busy = false;
return [{ type: 'session_closed' }];
}
default:
return [];
}
}
}
+146
View File
@@ -0,0 +1,146 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The seam between the live orchestrator and a coding-agent backend.
*
* An adaptor owns one backend (a qwen serve daemon, an ACP agent, ...) and
* exposes the small primitive set the orchestrator needs. Capability bits
* describe what the backend can actually do so the orchestrator can degrade
* per feature instead of branching on adaptor names.
*/
/** Opaque backend session identity, scoped to the owning adaptor. */
export interface BackendHandle {
/** Adaptor-scoped stable id (for qwen serve: the session id). */
readonly id: string;
/** Identifies the owning adaptor (e.g. 'qwen-code'). */
readonly adaptor: string;
}
export interface BackendCapabilities {
/** Whether an in-flight turn can accept an appended instruction. */
steering: 'native' | 'queued' | 'none';
/** Whether handoff prompts may carry image attachments. */
imageInput: boolean;
/** Whether permission requests surface as events and accept votes. */
permissionForwarding: boolean;
/** Whether the backend can push speech-worthy messages mid-turn. */
proactiveSpeak: boolean;
/** Whether the backend enumerates existing sessions. */
sessionList: boolean;
eventDelivery: 'stream' | 'per-turn' | 'poll' | 'reply-only';
}
export interface SessionSummary {
handle: BackendHandle;
label?: string;
cwd?: string;
state: 'idle' | 'busy' | 'closed' | 'unknown';
}
/** Receipt returned by prompt(): the task was accepted, not completed. */
export interface PromptReceipt {
status: 'accepted' | 'queued' | 'rejected';
/** Correlates later turn events with this prompt (qwen serve: promptId). */
jobRef?: string;
/** Human note for the realtime model to relay ("queue full", ...). */
note?: string;
/** For steering: the instruction joined the currently running turn. */
joinedActiveTurn?: boolean;
}
export type ContentBlock =
| { type: 'text'; text: string }
| { type: 'image'; mimeType: string; data: Uint8Array; name?: string };
export type PermissionOptionKind = 'proceed' | 'reject' | 'other';
export interface PermissionOption {
optionId: string;
label?: string;
kind: PermissionOptionKind;
/**
* Grant breadth, from the wire's structured kind (`allow_once` vs
* `allow_always`, `reject_once` vs `reject_always`). A bare voice
* "allow" must take the narrowest ('once') option on offer instead of
* persisting an always-allow rule. Absent when the backend gave no
* signal.
*/
escalation?: 'once' | 'always';
}
export type BackendEvent =
| { type: 'turn_started'; jobRef?: string }
| { type: 'progress'; jobRef?: string; summary: string }
| { type: 'speak'; text: string }
| {
type: 'permission_request';
requestId: string;
title: string;
options: readonly PermissionOption[];
payload?: unknown;
}
| {
type: 'permission_resolved';
requestId: string;
/** True when this daemon's own vote settled it (vs WebShell etc.). */
byUs: boolean;
}
| { type: 'turn_complete'; jobRef?: string; summary: string; detail?: string }
| { type: 'turn_error'; jobRef?: string; error: string }
| { type: 'session_closed' };
export type PermissionDecision = 'allow' | 'deny' | 'cancel';
export interface BackendAdaptor {
readonly name: string;
capabilities(): BackendCapabilities;
/**
* Verify the backend is reachable and supports what this adaptor needs.
* Throws a descriptive error when it does not — fail fast at daemon start.
*/
preflight(): Promise<void>;
createSession(opts?: {
cwd?: string;
label?: string;
}): Promise<BackendHandle>;
listSessions(): Promise<SessionSummary[]>;
/**
* Submit one turn. Resolves as soon as the backend admits the prompt.
* When `steer` is set and the session is busy, the adaptor attempts a
* mid-turn injection first and reports the outcome in the receipt.
*/
prompt(
handle: BackendHandle,
blocks: readonly ContentBlock[],
opts?: { steer?: boolean },
): Promise<PromptReceipt>;
/** Long-lived normalized event stream for one session. */
events(
handle: BackendHandle,
opts?: { signal?: AbortSignal },
): AsyncIterable<BackendEvent>;
/** True when the session currently has an active turn. */
isBusy(handle: BackendHandle): boolean;
cancel(handle: BackendHandle): Promise<void>;
respondPermission(
handle: BackendHandle,
requestId: string,
decision: PermissionDecision,
): Promise<'delivered' | 'already_resolved'>;
close(): Promise<void>;
}
+179
View File
@@ -0,0 +1,179 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it } from 'vitest';
import { loadConfig } from './config.js';
const temporaryDirectories: string[] = [];
async function temporaryDataDir(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'qwen-live-config-'));
temporaryDirectories.push(directory);
return directory;
}
async function dataDirWithConfig(config: unknown): Promise<string> {
const dataDir = await temporaryDataDir();
await writeFile(join(dataDir, 'config.json'), JSON.stringify(config));
return dataDir;
}
function thrownMessage(callback: () => unknown): string {
try {
callback();
} catch (error) {
return error instanceof Error ? error.message : String(error);
}
throw new Error('expected the callback to throw');
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe('loadConfig', () => {
it('applies env over file over built-in defaults', async () => {
const dataDir = await dataDirWithConfig({
realtimeApiKey: 'file-key',
realtimeModel: 'file-model',
voice: 'FileVoice',
port: 4171,
});
const envWins = loadConfig({
QWEN_LIVE_DATA_DIR: dataDir,
DASHSCOPE_API_KEY: 'env-key',
QWEN_LIVE_REALTIME_MODEL: 'env-model',
QWEN_LIVE_VOICE: 'EnvVoice',
QWEN_LIVE_PORT: '4172',
});
expect(envWins.realtime.apiKey).toBe('env-key');
expect(envWins.realtime.model).toBe('env-model');
expect(envWins.realtime.voice).toBe('EnvVoice');
expect(envWins.port).toBe(4172);
const fileWins = loadConfig({ QWEN_LIVE_DATA_DIR: dataDir });
expect(fileWins.realtime.apiKey).toBe('file-key');
expect(fileWins.realtime.model).toBe('file-model');
expect(fileWins.realtime.voice).toBe('FileVoice');
expect(fileWins.port).toBe(4171);
const defaults = loadConfig({
QWEN_LIVE_DATA_DIR: await temporaryDataDir(),
DASHSCOPE_API_KEY: 'env-key',
});
expect(defaults.realtime.model).toBe('qwen3.5-omni-plus-realtime');
expect(defaults.realtime.endpoint).toBe('https://dashscope.aliyuncs.com');
expect(defaults.serve.baseUrl).toBe('http://127.0.0.1:4170');
expect(defaults.port).toBe(0);
});
it('fails fast when no realtime API key is configured anywhere', async () => {
const dataDir = await temporaryDataDir();
const message = thrownMessage(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: dataDir }),
);
expect(message).toContain('DASHSCOPE_API_KEY');
expect(message).toContain(join(dataDir, 'config.json'));
});
it('surfaces non-ENOENT config read errors naming the path', async () => {
// A config.json that is a DIRECTORY yields EISDIR on read — the
// portable stand-in for an unreadable file (chmod-based denial is
// unreliable when the suite runs as root). This must NOT be swallowed
// as "no config file".
const dataDir = await temporaryDataDir();
await mkdir(join(dataDir, 'config.json'));
const message = thrownMessage(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: dataDir, DASHSCOPE_API_KEY: 'key' }),
);
expect(message).toContain(
`Could not read config file ${join(dataDir, 'config.json')}`,
);
});
it('parses a config file saved with a UTF-8 BOM', async () => {
const dataDir = await temporaryDataDir();
await writeFile(
join(dataDir, 'config.json'),
'\uFEFF' + JSON.stringify({ realtimeApiKey: 'bom-key', port: 4173 }),
);
const config = loadConfig({ QWEN_LIVE_DATA_DIR: dataDir });
expect(config.realtime.apiKey).toBe('bom-key');
expect(config.port).toBe(4173);
});
it('rejects wrong-typed file port values instead of defaulting to 0', async () => {
for (const port of [true, [4171], { value: 4171 }]) {
const dataDir = await dataDirWithConfig({ realtimeApiKey: 'k', port });
const message = thrownMessage(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: dataDir }),
);
expect(message).toContain(`Invalid "port" in`);
expect(message).toContain(JSON.stringify(port));
}
});
it('names config.json, not QWEN_LIVE_PORT, for a file-sourced bad port', async () => {
const dataDir = await dataDirWithConfig({
realtimeApiKey: 'k',
port: 'abc',
});
const message = thrownMessage(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: dataDir }),
);
expect(message).toContain(
`Invalid "port" in ${join(dataDir, 'config.json')}: "abc"`,
);
expect(message).not.toContain('QWEN_LIVE_PORT');
});
it('names QWEN_LIVE_PORT for an env-sourced bad port', async () => {
const dataDir = await dataDirWithConfig({ realtimeApiKey: 'k' });
expect(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: dataDir, QWEN_LIVE_PORT: '70000' }),
).toThrow('Invalid QWEN_LIVE_PORT: 70000');
});
it('expands a leading ~ in dataDir, defaultCwd, and discoveryDir', async () => {
const dataDir = await dataDirWithConfig({
realtimeApiKey: 'k',
discoveryDir: '~/qwen-live-test-discovery',
});
const config = loadConfig({
QWEN_LIVE_DATA_DIR: dataDir,
QWEN_LIVE_CWD: '~/qwen-live-test-cwd',
});
expect(config.defaultCwd).toBe(join(homedir(), 'qwen-live-test-cwd'));
expect(config.discoveryDir).toBe(
join(homedir(), 'qwen-live-test-discovery'),
);
// dataDir itself expands too; the missing-key error names the real path.
const message = thrownMessage(() =>
loadConfig({ QWEN_LIVE_DATA_DIR: '~/qwen-live-test-nonexistent' }),
);
expect(message).toContain(
join(homedir(), 'qwen-live-test-nonexistent', 'config.json'),
);
});
it('defaults discoveryDir to the stable ~/.qwen base', async () => {
const dataDir = await dataDirWithConfig({ realtimeApiKey: 'k' });
const config = loadConfig({ QWEN_LIVE_DATA_DIR: dataDir });
expect(config.discoveryDir).toBe(join(homedir(), '.qwen'));
});
});
+183
View File
@@ -0,0 +1,183 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Configuration: environment first, `~/.qwen-live/config.json` as fallback,
* built-in defaults last. Hand-rolled validation — the surface is small and
* a schema library would be the package's only heavy dependency.
*/
import { readFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { join } from 'node:path';
import { getStableLiveDiscoveryBaseDir } from './host/discovery.js';
export interface LiveConfig {
realtime: {
endpoint: string;
apiKey: string;
model: string;
voice?: string;
};
serve: {
baseUrl: string;
token?: string;
};
/** Default working directory for handoff-created sessions. */
defaultCwd?: string;
/** Data root: session logs live in `<dataDir>/sessions`. */
dataDir: string;
/** Where the Host discovery file lives (`~/.qwen` for the shipped Host). */
discoveryDir: string;
/** Global shortcut advertised to the Host. */
shortcut?: string;
/** Fixed listen port; 0 (default) lets the kernel pick. */
port: number;
}
const DEFAULT_REALTIME_ENDPOINT = 'https://dashscope.aliyuncs.com';
const DEFAULT_REALTIME_MODEL = 'qwen3.5-omni-plus-realtime';
const DEFAULT_SERVE_URL = 'http://127.0.0.1:4170';
function readConfigFile(path: string): Record<string, unknown> {
let raw: string;
try {
raw = readFileSync(path, 'utf8');
} catch (error) {
// Only a genuinely missing file means "no config". Anything else
// (EACCES, EISDIR, EIO) must surface, or the later missing-key error
// would point the user at a file that already contains the key.
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return {};
throw new Error(
`Could not read config file ${path}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
// Editors commonly save JSON with a UTF-8 BOM; JSON.parse rejects it.
if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
try {
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
return parsed as Record<string, unknown>;
throw new Error('not an object');
} catch (error) {
throw new Error(
`Invalid config file ${path}: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
function str(value: unknown): string | undefined {
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
}
/** Shell-style expansion of a leading `~` to the user's home directory. */
function expandTilde(value: string): string {
if (value === '~') return homedir();
if (value.startsWith('~/') || value.startsWith('~\\')) {
return join(homedir(), value.slice(2));
}
return value;
}
function pathStr(value: unknown): string | undefined {
const trimmed = str(value);
return trimmed === undefined ? undefined : expandTilde(trimmed);
}
function resolvePort(
env: Record<string, string | undefined>,
file: Record<string, unknown>,
configPath: string,
): number {
const envPort = str(env['QWEN_LIVE_PORT']);
if (envPort !== undefined) {
const port = Number(envPort);
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error(`Invalid QWEN_LIVE_PORT: ${envPort}`);
}
return port;
}
const filePort = file['port'];
if (filePort === undefined) return 0;
// Accept the natural JSON spelling ("port": 4171) as well as a string.
// Any other type (true, [4171], {}) is a config mistake and must not
// silently boot on a kernel-picked ephemeral port.
const portRaw =
typeof filePort === 'number'
? String(filePort)
: typeof filePort === 'string'
? filePort.trim()
: undefined;
const port = portRaw ? Number(portRaw) : NaN;
if (!Number.isInteger(port) || port < 0 || port > 65_535) {
throw new Error(
`Invalid "port" in ${configPath}: ${JSON.stringify(filePort)}`,
);
}
return port;
}
export function loadConfig(
env: Record<string, string | undefined> = process.env,
): LiveConfig {
const dataDir =
pathStr(env['QWEN_LIVE_DATA_DIR']) ?? join(homedir(), '.qwen-live');
const configPath = join(dataDir, 'config.json');
const file = readConfigFile(configPath);
const apiKey =
str(env['DASHSCOPE_API_KEY']) ??
str(env['QWEN_LIVE_REALTIME_API_KEY']) ??
str(file['realtimeApiKey']);
if (!apiKey) {
throw new Error(
'A DashScope realtime API key is required: set DASHSCOPE_API_KEY ' +
`or put "realtimeApiKey" in ${configPath}.`,
);
}
const port = resolvePort(env, file, configPath);
const voice = str(env['QWEN_LIVE_VOICE']) ?? str(file['voice']) ?? 'Tina';
const defaultCwd =
pathStr(env['QWEN_LIVE_CWD']) ?? pathStr(file['defaultCwd']);
const serveToken = str(env['QWEN_SERVER_TOKEN']) ?? str(file['serveToken']);
const shortcut = str(env['QWEN_LIVE_SHORTCUT']) ?? str(file['shortcut']);
return {
realtime: {
endpoint:
str(env['QWEN_LIVE_REALTIME_ENDPOINT']) ??
str(file['realtimeEndpoint']) ??
DEFAULT_REALTIME_ENDPOINT,
apiKey,
model:
str(env['QWEN_LIVE_REALTIME_MODEL']) ??
str(file['realtimeModel']) ??
DEFAULT_REALTIME_MODEL,
...(voice ? { voice } : {}),
},
serve: {
baseUrl:
str(env['QWEN_LIVE_SERVE_URL']) ??
str(file['serveUrl']) ??
DEFAULT_SERVE_URL,
...(serveToken ? { token: serveToken } : {}),
},
...(defaultCwd ? { defaultCwd } : {}),
dataDir,
discoveryDir:
pathStr(env['QWEN_LIVE_DISCOVERY_DIR']) ??
pathStr(file['discoveryDir']) ??
getStableLiveDiscoveryBaseDir(),
...(shortcut ? { shortcut } : {}),
port,
};
}
+357
View File
@@ -0,0 +1,357 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { randomBytes } from 'node:crypto';
import { request } from 'node:http';
import type { Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import WebSocket from 'ws';
import { afterEach, describe, expect, it } from 'vitest';
import type { QwenCodeAdaptor } from './adaptor/qwen-code-adaptor.js';
import type { LiveHostInstaller } from './host/live-host-installer.js';
import type { LiveConfig } from './config.js';
import { LiveDaemon } from './daemon.js';
import {
getLiveDiscoveryPath,
type LiveDiscoveryRecord,
} from './host/discovery.js';
import { LIVE_HOST_PROTOCOL_VERSION } from './host/types.js';
import { LiveLogger } from './logger.js';
const temporaryDirectories: string[] = [];
const daemons: LiveDaemon[] = [];
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'qwen-live-daemon-'));
temporaryDirectories.push(directory);
return directory;
}
function fakeAdaptor(): QwenCodeAdaptor {
return { preflight: async () => undefined } as unknown as QwenCodeAdaptor;
}
function fakeInstaller(): LiveHostInstaller {
return {
refresh: async () => ({ state: 'installed', version: '0.1.0' }),
ensureInstalled: async () => ({ state: 'installed', version: '0.1.0' }),
launch: async () => ({ state: 'installed', version: '0.1.0' }),
getStatus: () => ({ state: 'installed', version: '0.1.0' }),
} as unknown as LiveHostInstaller;
}
async function testConfig(): Promise<LiveConfig> {
const base = await temporaryDirectory();
return {
realtime: {
endpoint: 'https://dashscope.example.invalid',
apiKey: 'test-api-key',
model: 'test-model',
},
serve: { baseUrl: 'http://127.0.0.1:1' },
dataDir: join(base, 'data'),
discoveryDir: join(base, 'discovery'),
port: 0,
};
}
function startedDaemon(
config: LiveConfig,
deps: { installer?: LiveHostInstaller } = {},
): LiveDaemon {
const daemon = new LiveDaemon(config, {
adaptor: fakeAdaptor(),
...(deps.installer ? { installer: deps.installer } : {}),
// 'error' level keeps expected warnings (discovery cleanup) off stderr.
logger: new LiveLogger('error'),
});
daemons.push(daemon);
return daemon;
}
async function readDiscoveryRecord(
discoveryDir: string,
): Promise<LiveDiscoveryRecord> {
const raw = await readFile(getLiveDiscoveryPath(discoveryDir), 'utf8');
return JSON.parse(raw) as LiveDiscoveryRecord;
}
async function plantDiscoveryRecord(
discoveryDir: string,
record: LiveDiscoveryRecord,
): Promise<void> {
const discoveryPath = getLiveDiscoveryPath(discoveryDir);
await mkdir(dirname(discoveryPath), { recursive: true, mode: 0o700 });
await writeFile(discoveryPath, `${JSON.stringify(record)}\n`, {
mode: 0o600,
});
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds).unref();
});
}
function connectHost(url: string, headers: Record<string, string>): WebSocket {
const target = url.replace(/^http/, 'ws');
return new WebSocket(`${target}/live/host`, { headers });
}
function hostHeaders(record: LiveDiscoveryRecord): Record<string, string> {
return {
authorization: `Bearer ${record.token}`,
'x-qwen-live-nonce': record.instanceNonce,
};
}
function waitForOpen(socket: WebSocket): Promise<void> {
return new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
}
/** Resolves with the HTTP status of a refused upgrade, rejects on success. */
function waitForRefusal(socket: WebSocket): Promise<number | 'destroyed'> {
return new Promise((resolve, reject) => {
socket.once('unexpected-response', (_req, res) => {
resolve(res.statusCode ?? 'destroyed');
socket.terminate();
});
socket.once('error', () => {
resolve('destroyed');
});
socket.once('open', () => {
reject(new Error('the upgrade was accepted'));
});
});
}
afterEach(async () => {
await Promise.all(daemons.splice(0).map((daemon) => daemon.stop()));
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe('LiveDaemon', () => {
it('stop() without start() resolves quickly', async () => {
const daemon = startedDaemon(await testConfig());
const outcome = await Promise.race([
daemon.stop().then(() => 'stopped' as const),
delay(2_000).then(() => 'timeout' as const),
]);
expect(outcome).toBe('stopped');
});
it('publishes a discovery record and accepts a Host presenting it', async () => {
const config = await testConfig();
const daemon = startedDaemon(config);
const { url, port } = await daemon.start();
expect(url).toBe(`http://127.0.0.1:${port}`);
const record = await readDiscoveryRecord(config.discoveryDir);
expect(record.pid).toBe(process.pid);
expect(record.url).toBe(url);
expect(record.protocolVersion).toBe(LIVE_HOST_PROTOCOL_VERSION);
const socket = connectHost(url, hostHeaders(record));
await waitForOpen(socket);
expect(socket.readyState).toBe(WebSocket.OPEN);
socket.terminate();
});
it('refuses an upgrade that carries an Origin header (CSRF wall)', async () => {
const config = await testConfig();
const daemon = startedDaemon(config);
const { url } = await daemon.start();
const record = await readDiscoveryRecord(config.discoveryDir);
// Even a correct token is refused when Origin marks a browser context.
const socket = connectHost(url, {
...hostHeaders(record),
origin: 'https://evil.example.com',
});
await expect(waitForRefusal(socket)).resolves.toBe(401);
});
it('refuses a wrong bearer token with 401', async () => {
const config = await testConfig();
const daemon = startedDaemon(config);
const { url } = await daemon.start();
const record = await readDiscoveryRecord(config.discoveryDir);
const socket = connectHost(url, {
...hostHeaders(record),
authorization: 'Bearer not-the-token',
});
await expect(waitForRefusal(socket)).resolves.toBe(401);
});
it('stop() resolves within a deadline while a Host keeps reconnecting', async () => {
const config = await testConfig();
const daemon = startedDaemon(config);
const { port } = await daemon.start();
const record = await readDiscoveryRecord(config.discoveryDir);
// A raw upgraded socket that never answers the server's close frame —
// the worst-case peer graceful close would wait on. Resolves with the
// socket once the 101 handshake completed, or undefined when refused.
const rawHostSocket = (): Promise<Socket | undefined> =>
new Promise((resolve) => {
const req = request({
host: '127.0.0.1',
port,
path: '/live/host',
headers: {
...hostHeaders(record),
Connection: 'Upgrade',
Upgrade: 'websocket',
'Sec-WebSocket-Version': '13',
'Sec-WebSocket-Key': randomBytes(16).toString('base64'),
},
});
req.on('upgrade', (_res, socket) => {
socket.on('error', () => {});
resolve(socket);
});
req.on('response', () => {
resolve(undefined);
});
req.on('error', () => {
resolve(undefined);
});
req.end();
});
const sockets: Socket[] = [];
let redialing = true;
const redial = (socket: Socket) => {
socket.on('close', () => {
if (!redialing) return;
setTimeout(() => {
if (!redialing) return;
void rawHostSocket().then((next) => {
if (!next) return;
sockets.push(next);
redial(next);
});
}, 5).unref();
});
};
const first = await rawHostSocket();
expect(first).toBeDefined();
sockets.push(first!);
redial(first!);
try {
const outcome = await Promise.race([
daemon.stop().then(() => 'stopped' as const),
delay(3_000).then(() => 'timeout' as const),
]);
expect(outcome).toBe('stopped');
} finally {
redialing = false;
for (const socket of sockets) socket.destroy();
}
});
it('reclaims a stale dead-owner discovery record and republishes', async () => {
const config = await testConfig();
await plantDiscoveryRecord(config.discoveryDir, {
url: 'http://127.0.0.1:3210',
token: 'stale-token',
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
pid: 999_999,
instanceNonce: 'daemon_instance_nonce_stale_01',
});
const daemon = startedDaemon(config);
const { url } = await daemon.start();
const record = await readDiscoveryRecord(config.discoveryDir);
expect(record.pid).toBe(process.pid);
expect(record.url).toBe(url);
expect(record.instanceNonce).not.toBe('daemon_instance_nonce_stale_01');
expect(record.token).not.toBe('stale-token');
});
it('fails fast on a live discovery owner instead of stealing the file', async () => {
const config = await testConfig();
const planted: LiveDiscoveryRecord = {
url: 'http://127.0.0.1:3210',
token: 'live-owner-token',
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
// This test process is alive, so the record reads as a live owner.
pid: process.pid,
instanceNonce: 'daemon_instance_nonce_live_01',
};
await plantDiscoveryRecord(config.discoveryDir, planted);
const daemon = startedDaemon(config);
await expect(daemon.start()).rejects.toThrow(
/Another Live daemon \(pid \d+\) already owns/,
);
// The live owner's record is untouched.
await expect(readDiscoveryRecord(config.discoveryDir)).resolves.toEqual(
planted,
);
});
it('serves the installer status behind the bearer wall', async () => {
const config = await testConfig();
const daemon = startedDaemon(config, { installer: fakeInstaller() });
const { port } = await daemon.start();
const record = await readDiscoveryRecord(config.discoveryDir);
const get = (path: string, headers: Record<string, string>) =>
new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = request(
{ host: '127.0.0.1', port, path, headers },
(res) => {
let body = '';
res.on('data', (chunk: Buffer) => {
body += chunk.toString();
});
res.on('end', () => resolve({ status: res.statusCode ?? 0, body }));
},
);
req.on('error', reject);
req.end();
});
// Authorized: status JSON comes back.
const ok = await get('/live/setup', hostHeaders(record));
expect(ok.status).toBe(200);
expect(JSON.parse(ok.body)).toMatchObject({
state: 'installed',
version: '0.1.0',
});
// Wrong token: 401, no body leakage.
const denied = await get('/live/setup', {
authorization: 'Bearer wrong-token',
});
expect(denied.status).toBe(401);
// A browser context (Origin) is refused even with the right token.
const browser = await get('/live/setup', {
...hostHeaders(record),
origin: 'https://evil.example',
});
expect(browser.status).toBe(401);
// Unknown paths stay 404.
const missing = await get('/nope', hostHeaders(record));
expect(missing.status).toBe(404);
});
});
+336
View File
@@ -0,0 +1,336 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* LiveDaemon wires everything together: the Host WebSocket endpoint
* (protocol v6, same wire contract as the shipped Qwen Live Host), the
* discovery file that Host binaries poll, the realtime orchestrator, and
* the qwen serve adaptor.
*
* Discovery mutual exclusion: writing `~/.qwen/live/daemon.json` fails fast
* when a live owner (typically qwen serve's built-in Live integration)
* already holds it — the two must not fight over the Host.
*/
import { randomUUID, timingSafeEqual } from 'node:crypto';
import { createServer, type IncomingMessage, type Server } from 'node:http';
import { join } from 'node:path';
import { WebSocketServer, type WebSocket } from 'ws';
import { QwenCodeAdaptor } from './adaptor/qwen-code-adaptor.js';
import { LiveHostInstaller } from './host/live-host-installer.js';
import type { LiveConfig } from './config.js';
import {
handoffLiveDiscoveryOwner,
LiveDiscoveryOwnerActiveError,
removeLiveDiscoveryFile,
writeLiveDiscoveryFile,
} from './host/discovery.js';
import { LiveHostCoordinator } from './host/live-host-coordinator.js';
import { LIVE_HOST_PROTOCOL_VERSION } from './host/types.js';
import { SessionLog } from './log/session-log.js';
import { LiveLogger } from './logger.js';
import { LiveSession } from './orchestrator/live-session.js';
const HOST_WS_PATH = '/live/host';
export interface LiveDaemonDeps {
adaptor?: QwenCodeAdaptor;
/** Installer (tests inject a fake); production installs the real Host. */
installer?: LiveHostInstaller;
logger?: LiveLogger;
}
export class LiveDaemon {
private readonly logger: LiveLogger;
private readonly adaptor: QwenCodeAdaptor;
private readonly installer: LiveHostInstaller;
private readonly token = randomUUID();
private readonly instanceNonce = randomUUID();
private server: Server | undefined;
private wss: WebSocketServer | undefined;
private coordinator: LiveHostCoordinator | undefined;
private session: LiveSession | undefined;
private log: SessionLog | undefined;
private discoveryPublished = false;
private stopping = false;
constructor(
private readonly config: LiveConfig,
deps: LiveDaemonDeps = {},
) {
this.logger = deps.logger ?? new LiveLogger();
this.installer = deps.installer ?? new LiveHostInstaller();
this.adaptor =
deps.adaptor ??
new QwenCodeAdaptor({
baseUrl: config.serve.baseUrl,
...(config.serve.token ? { token: config.serve.token } : {}),
...(config.defaultCwd ? { defaultCwd: config.defaultCwd } : {}),
clientId: `qwen-live-${this.instanceNonce.slice(0, 8)}`,
});
}
async start(): Promise<{ port: number; url: string }> {
// Fail fast when qwen serve is missing or too old — before we take the
// Host discovery file from anyone.
await this.adaptor.preflight();
const coordinator = new LiveHostCoordinator({
daemonInstanceNonce: this.instanceNonce,
...(this.config.shortcut ? { shortcut: this.config.shortcut } : {}),
getProviderReadiness: () =>
this.config.realtime.apiKey
? { state: 'ready' }
: {
state: 'unavailable',
blocker: 'provider_config',
message: 'DashScope realtime API key is not configured.',
},
});
this.coordinator = coordinator;
// The ported coordinator fails closed until the Appshot delivery channel
// is verified (in qwen serve that channel is a separate reverse-RPC hop
// booted lazily). Here the channel is the in-process
// captureScreenContext call, verified by construction.
coordinator.setAppshotReadiness({ state: 'ready' });
const log = new SessionLog({
directory: join(this.config.dataDir, 'sessions'),
liveSessionId: `live-${Date.now()}-${this.instanceNonce.slice(0, 8)}`,
});
this.log = log;
const session = new LiveSession({
host: coordinator,
adaptor: this.adaptor,
realtime: {
endpoint: this.config.realtime.endpoint,
apiKey: this.config.realtime.apiKey,
model: this.config.realtime.model,
...(this.config.realtime.voice
? { voice: this.config.realtime.voice }
: {}),
},
log,
});
this.session = session;
coordinator.setHandlers({
onStart: (call) => session.start(call),
onStop: (call) => session.stop(call),
onInputAudio: (call) => session.pushAudio(call),
});
const port = await this.listen();
const url = `http://127.0.0.1:${port}`;
await this.publishDiscovery(url);
// The single machine-readable stdout line; harnesses parse the port
// from it (same pattern as `qwen serve`).
process.stdout.write(`qwen-live listening on ${url}\n`);
this.logger.info(
`host endpoint ready at ${url}${HOST_WS_PATH} (protocol v${LIVE_HOST_PROTOCOL_VERSION})`,
);
return { port, url };
}
async stop(): Promise<void> {
if (this.stopping) return;
this.stopping = true;
this.session?.dispose();
this.coordinator?.dispose();
if (this.discoveryPublished) {
try {
await removeLiveDiscoveryFile(this.config.discoveryDir, {
pid: process.pid,
instanceNonce: this.instanceNonce,
});
} catch (error) {
this.logger.warn(
`could not remove the discovery file: ${
error instanceof Error ? error.message : String(error)
}`,
);
}
}
await this.log?.close();
// Graceful close waits on the peer; shutdown must not. Any client still
// attached (or attached between dispose() and here) is torn down hard.
if (this.wss) {
for (const client of this.wss.clients) {
client.terminate();
}
}
await new Promise<void>((resolve) => {
if (!this.wss) {
resolve();
return;
}
this.wss.close(() => {
resolve();
});
});
await new Promise<void>((resolve) => {
if (!this.server) {
resolve();
return;
}
this.server.close(() => {
resolve();
});
});
}
// -- internals ------------------------------------------------------------
/**
* Minimal HTTP surface for Host bootstrap: the installer endpoints. The
* same Bearer wall as the WS upgrade guards them — a browser context
* (Origin) is always refused.
*/
private handleRequest(
req: IncomingMessage,
res: import('node:http').ServerResponse,
): void {
const url = (req.url ?? '').split('?', 1)[0];
const route = `${req.method} ${url}`;
if (
(route === 'GET /live/setup' ||
route === 'POST /live/setup/install' ||
route === 'POST /live/setup/launch') &&
this.authorize(req)
) {
void this.serveSetup(route, res);
return;
}
if (route === 'GET /healthz') {
res.statusCode = 200;
res.end('ok');
return;
}
res.statusCode = route.startsWith('GET /live/setup') ? 401 : 404;
res.end();
}
private async serveSetup(
route: string,
res: import('node:http').ServerResponse,
): Promise<void> {
const status =
route === 'GET /live/setup'
? await this.installer.refresh()
: route === 'POST /live/setup/install'
? // force=true is the recovery path: a corrupted-but-present
// installation (bad Info.plist, revoked notarization) would
// otherwise permanently fail inspection while the status still
// says retryable. Force re-downloads and re-verifies.
await this.installer.ensureInstalled(true)
: await this.installer.launch();
res.statusCode = 200;
res.setHeader('content-type', 'application/json');
res.end(JSON.stringify(status));
}
private authorize(req: IncomingMessage): boolean {
// CSRF wall: a browser context always sends Origin; the Host never does.
if (req.headers['origin'] !== undefined) return false;
const auth = req.headers['authorization'];
if (typeof auth !== 'string' || !auth.startsWith('Bearer ')) return false;
const presented = Buffer.from(auth.slice('Bearer '.length));
const expected = Buffer.from(this.token);
return (
presented.length === expected.length &&
timingSafeEqual(presented, expected)
);
}
private listen(): Promise<number> {
const server = createServer((req, res) => {
this.handleRequest(req, res);
});
this.server = server;
const wss = new WebSocketServer({ noServer: true });
this.wss = wss;
server.on('upgrade', (req, socket, head) => {
// A Host redialing its cached discovery URL during shutdown must not
// re-register — stop() would then wait on the fresh lease forever.
if (this.stopping) {
socket.destroy();
return;
}
const path = (req.url ?? '').split('?', 1)[0];
if (path !== HOST_WS_PATH) {
socket.destroy();
return;
}
if (!this.authorize(req)) {
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws: WebSocket) => {
const nonce = req.headers['x-qwen-live-nonce'];
this.coordinator?.attachHost(
ws,
typeof nonce === 'string' ? nonce : undefined,
);
});
});
return new Promise<number>((resolve, reject) => {
server.once('error', reject);
server.listen(this.config.port, '127.0.0.1', () => {
const address = server.address();
if (address === null || typeof address === 'string') {
reject(new Error('could not determine the listening port'));
return;
}
resolve(address.port);
});
});
}
private async publishDiscovery(url: string): Promise<void> {
const record = {
url,
token: this.token,
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
pid: process.pid,
instanceNonce: this.instanceNonce,
};
try {
await writeLiveDiscoveryFile(this.config.discoveryDir, record);
this.discoveryPublished = true;
return;
} catch (error) {
if (!(error instanceof LiveDiscoveryOwnerActiveError)) {
// A dead owner's record can be reclaimed through the handoff path.
// The handoff's commitOwner must not touch the discovery file: the
// handoff holds the directory lock (a nested write would deadlock)
// and its confirm step expects the stale record to still be there.
// Publish the new record after the reclaim completes — the same
// contract qwen serve's wiring follows.
await handoffLiveDiscoveryOwner(
this.config.discoveryDir,
{ pid: process.pid, instanceNonce: this.instanceNonce },
async () => undefined,
{ waitForHandoffGrace: false },
);
await writeLiveDiscoveryFile(this.config.discoveryDir, record);
this.discoveryPublished = true;
return;
}
throw new Error(
`Another Live daemon (pid ${error.ownerPid}) already owns the Host ` +
'discovery file — most likely qwen serve with its built-in Live ' +
'Voice enabled. Stop it (or disable its Live integration) and ' +
'start qwen-live again.',
);
}
}
}
@@ -0,0 +1,346 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import * as fs from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
getLiveDiscoveryPath,
handoffLiveDiscoveryOwner,
LiveDiscoveryOwnerActiveError,
LiveDiscoveryStateError,
removeLiveDiscoveryFile,
writeLiveDiscoveryFile,
type LiveDiscoveryRecord,
} from './discovery.js';
import { LIVE_HOST_PROTOCOL_VERSION } from './types.js';
const temporaryDirectories: string[] = [];
async function temporaryRuntime(): Promise<string> {
const directory = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-live-'));
temporaryDirectories.push(directory);
return directory;
}
function record(instanceNonce: string, pid = process.pid): LiveDiscoveryRecord {
return {
url: 'http://127.0.0.1:3210',
token: 'not-a-real-token',
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
pid,
instanceNonce,
};
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => fs.rm(directory, { recursive: true, force: true })),
);
});
describe('Live discovery file', () => {
it('publishes an atomic mode-0600 record below the runtime directory', async () => {
const runtime = await temporaryRuntime();
const expected = record('daemon_instance_nonce_0001');
const writtenPath = await writeLiveDiscoveryFile(runtime, expected);
expect(writtenPath).toBe(getLiveDiscoveryPath(runtime));
expect(JSON.parse(await fs.readFile(writtenPath, 'utf8'))).toEqual(
expected,
);
// Windows has no POSIX permission bits; the read side already skips
// the mode check there (see discovery.ts).
if (process.platform !== 'win32') {
expect((await fs.stat(writtenPath)).mode & 0o777).toBe(0o600);
}
expect(
(await fs.readdir(path.dirname(writtenPath))).filter((name) =>
name.endsWith('.tmp'),
),
).toEqual([]);
});
it('safely creates a missing nested runtime directory tree', async () => {
const parent = await temporaryRuntime();
const runtime = path.join(parent, 'nested', 'runtime', 'base');
const expected = record('daemon_instance_nonce_nested_01');
await expect(writeLiveDiscoveryFile(runtime, expected)).resolves.toBe(
getLiveDiscoveryPath(runtime),
);
await expect(
fs.readFile(getLiveDiscoveryPath(runtime), 'utf8'),
).resolves.toContain(expected.instanceNonce);
if (process.platform !== 'win32') {
expect(
(await fs.stat(path.dirname(getLiveDiscoveryPath(runtime)))).mode &
0o777,
).toBe(0o700);
}
});
it('rejects a symlinked runtime base without publishing through it', async () => {
if (process.platform === 'win32') return;
const parent = await temporaryRuntime();
const target = path.join(parent, 'target');
const runtimeBaseDir = path.join(parent, 'runtime-link');
await fs.mkdir(target, { mode: 0o700 });
await fs.symlink(target, runtimeBaseDir);
await expect(
writeLiveDiscoveryFile(
runtimeBaseDir,
record('daemon_instance_nonce_symlink_01'),
),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
await expect(fs.readdir(target)).resolves.toEqual([]);
});
it('rejects an unsafe discovery lock shape without replacing it', async () => {
const runtime = await temporaryRuntime();
const discoveryDirectory = path.dirname(getLiveDiscoveryPath(runtime));
const lockPath = path.join(discoveryDirectory, '.daemon.lock');
await fs.mkdir(discoveryDirectory, { mode: 0o700 });
await fs.writeFile(lockPath, 'unsafe', { mode: 0o600 });
await expect(
writeLiveDiscoveryFile(
runtime,
record('daemon_instance_nonce_unsafe_lock_01'),
),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
await expect(fs.readFile(lockPath, 'utf8')).resolves.toBe('unsafe');
await expect(fs.stat(getLiveDiscoveryPath(runtime))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('only removes a record owned by the matching daemon pid and nonce', async () => {
const runtime = await temporaryRuntime();
const current = record('daemon_instance_nonce_0002');
await writeLiveDiscoveryFile(runtime, current);
await expect(
removeLiveDiscoveryFile(runtime, {
pid: current.pid,
instanceNonce: 'daemon_instance_nonce_old0',
}),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
await expect(
removeLiveDiscoveryFile(runtime, {
pid: current.pid + 1,
instanceNonce: current.instanceNonce,
}),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
await expect(
fs.readFile(getLiveDiscoveryPath(runtime), 'utf8'),
).resolves.toContain(current.instanceNonce);
expect(await removeLiveDiscoveryFile(runtime, current)).toBe(true);
await expect(fs.stat(getLiveDiscoveryPath(runtime))).rejects.toMatchObject({
code: 'ENOENT',
});
});
it('allows only one concurrent healthy owner', async () => {
const runtime = await temporaryRuntime();
const first = record('daemon_instance_nonce_0003');
const second = record('daemon_instance_nonce_0004');
const results = await Promise.allSettled([
writeLiveDiscoveryFile(runtime, first),
writeLiveDiscoveryFile(runtime, second),
]);
expect(
results.filter((result) => result.status === 'fulfilled'),
).toHaveLength(1);
const rejected = results.find((result) => result.status === 'rejected');
expect(rejected).toMatchObject({
status: 'rejected',
reason: expect.any(LiveDiscoveryOwnerActiveError),
});
const published = JSON.parse(
await fs.readFile(getLiveDiscoveryPath(runtime), 'utf8'),
) as LiveDiscoveryRecord;
expect([first.instanceNonce, second.instanceNonce]).toContain(
published.instanceNonce,
);
});
it('does not publish over a stale foreign owner without a handoff', async () => {
const runtime = await temporaryRuntime();
const previous = record('daemon_instance_nonce_0005', 999_999);
const replacement = record('daemon_instance_nonce_0006');
await writeLiveDiscoveryFile(runtime, previous);
await expect(
writeLiveDiscoveryFile(runtime, replacement, {
isProcessAlive: (pid) => {
expect(pid).toBe(previous.pid);
return false;
},
}),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
expect(
JSON.parse(await fs.readFile(getLiveDiscoveryPath(runtime), 'utf8')),
).toEqual(previous);
});
it('does not publish over a stale legacy-protocol owner without a handoff', async () => {
const runtime = await temporaryRuntime();
const discoveryPath = getLiveDiscoveryPath(runtime);
const previous = {
...record('daemon_instance_nonce_legacy_01', 999_998),
protocolVersion: 2,
};
const replacement = record('daemon_instance_nonce_current_01');
await fs.mkdir(path.dirname(discoveryPath), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(discoveryPath, `${JSON.stringify(previous)}\n`, {
mode: 0o600,
});
await expect(
writeLiveDiscoveryFile(runtime, replacement, {
isProcessAlive: (pid) => {
expect(pid).toBe(previous.pid);
return false;
},
}),
).rejects.toBeInstanceOf(LiveDiscoveryStateError);
expect(JSON.parse(await fs.readFile(discoveryPath, 'utf8'))).toEqual(
previous,
);
if (process.platform !== 'win32') {
expect((await fs.stat(discoveryPath)).mode & 0o777).toBe(0o600);
}
});
it.each([
['current', LIVE_HOST_PROTOCOL_VERSION, 999_996],
['legacy', 2, 999_995],
])(
'reclaims a valid stale %s-protocol owner during handoff',
async (_label, protocolVersion, pid) => {
const runtime = await temporaryRuntime();
const discoveryPath = getLiveDiscoveryPath(runtime);
const previous = {
...record('daemon_instance_nonce_handoff_old', pid),
protocolVersion,
};
const replacement = record('daemon_instance_nonce_handoff_new');
await fs.mkdir(path.dirname(discoveryPath), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(discoveryPath, `${JSON.stringify(previous)}\n`, {
mode: 0o600,
});
let committed = false;
const wait = vi.fn(async () => undefined);
await expect(
handoffLiveDiscoveryOwner(
runtime,
replacement,
async () => {
committed = true;
},
{
wait,
handoffGraceMs: 37,
isProcessAlive: (ownerPid) => {
expect(ownerPid).toBe(previous.pid);
return false;
},
},
),
).resolves.toEqual({ reclaimed: true });
expect(committed).toBe(true);
expect(wait).toHaveBeenCalledOnce();
expect(wait).toHaveBeenCalledWith(37);
await expect(fs.stat(discoveryPath)).rejects.toMatchObject({
code: 'ENOENT',
});
},
);
it('does not replace a live owner only because its protocol is legacy', async () => {
const runtime = await temporaryRuntime();
const discoveryPath = getLiveDiscoveryPath(runtime);
const previous = {
...record('daemon_instance_nonce_legacy_02', 999_997),
protocolVersion: 2,
};
await fs.mkdir(path.dirname(discoveryPath), {
recursive: true,
mode: 0o700,
});
await fs.writeFile(discoveryPath, `${JSON.stringify(previous)}\n`, {
mode: 0o600,
});
await expect(
writeLiveDiscoveryFile(
runtime,
record('daemon_instance_nonce_current_02'),
{
isProcessAlive: (pid) => {
expect(pid).toBe(previous.pid);
return true;
},
},
),
).rejects.toBeInstanceOf(LiveDiscoveryOwnerActiveError);
expect(JSON.parse(await fs.readFile(discoveryPath, 'utf8'))).toEqual(
previous,
);
});
it('does not overwrite an invalid existing record', async () => {
const runtime = await temporaryRuntime();
const discoveryPath = getLiveDiscoveryPath(runtime);
await fs.mkdir(path.dirname(discoveryPath), { recursive: true });
await fs.writeFile(discoveryPath, '{broken', { mode: 0o600 });
await expect(
writeLiveDiscoveryFile(runtime, record('daemon_instance_nonce_0007')),
).rejects.toThrow('Existing Live discovery record is invalid.');
await expect(fs.readFile(discoveryPath, 'utf8')).resolves.toBe('{broken');
});
it('rejects non-loopback URLs and malformed nonces', async () => {
const runtime = await temporaryRuntime();
await expect(
writeLiveDiscoveryFile(runtime, {
...record('daemon_instance_nonce_0005'),
url: 'https://example.com',
}),
).rejects.toThrow(/loopback/);
await expect(
writeLiveDiscoveryFile(runtime, record('short')),
).rejects.toThrow(/invalid/);
});
it('accepts concrete IPv4 loopback addresses in 127/8', async () => {
const runtime = await temporaryRuntime();
await expect(
writeLiveDiscoveryFile(runtime, {
...record('daemon_instance_nonce_0006'),
url: 'http://127.12.34.56:3210',
}),
).resolves.toBe(getLiveDiscoveryPath(runtime));
});
});
+618
View File
@@ -0,0 +1,618 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { randomUUID } from 'node:crypto';
import { constants as fsConstants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { isIP } from 'node:net';
import { homedir } from 'node:os';
import * as path from 'node:path';
import type { LockOptions } from 'proper-lockfile';
import { LIVE_HOST_PROTOCOL_VERSION } from './types.js';
export const LIVE_DISCOVERY_RELATIVE_PATH = path.join('live', 'daemon.json');
const MAX_DISCOVERY_BYTES = 16 * 1024;
const NONCE_PATTERN = /^[A-Za-z0-9_-]{16,256}$/;
const DEFAULT_HANDOFF_GRACE_MS = 1_000;
const LOCK_OPTIONS: LockOptions = {
realpath: false,
stale: 10_000,
update: 2_000,
retries: {
retries: 120,
minTimeout: 10,
maxTimeout: 100,
factor: 1.2,
randomize: true,
},
};
export interface LiveDiscoveryRecord {
url: string;
token?: string;
protocolVersion: typeof LIVE_HOST_PROTOCOL_VERSION;
pid: number;
instanceNonce: string;
}
export interface LiveDiscoveryOwner {
pid: number;
instanceNonce: string;
}
interface ExistingLiveDiscoveryRecord {
url: string;
token?: string;
protocolVersion: number;
pid: number;
instanceNonce: string;
}
interface LiveDiscoveryDirectory {
directory: string;
dev: number | bigint;
ino: number | bigint;
}
export class LiveDiscoveryOwnerActiveError extends Error {
constructor(readonly ownerPid: number) {
super(`Live discovery is owned by active daemon pid ${ownerPid}.`);
this.name = 'LiveDiscoveryOwnerActiveError';
}
}
export class LiveDiscoveryStateError extends Error {
constructor(cause?: unknown) {
super('Existing Live discovery record is invalid.', { cause });
this.name = 'LiveDiscoveryStateError';
}
}
export class LiveDiscoveryPublicationError extends Error {
readonly published = true;
constructor(cause: unknown) {
super('Live discovery publication completed with cleanup errors.', {
cause,
});
this.name = 'LiveDiscoveryPublicationError';
}
}
export function getStableLiveDiscoveryBaseDir(
homeDirectory = homedir(),
): string {
return path.join(homeDirectory, '.qwen');
}
export function getLiveDiscoveryPath(runtimeBaseDir: string): string {
return path.join(runtimeBaseDir, LIVE_DISCOVERY_RELATIVE_PATH);
}
function assertSafeRecord(record: ExistingLiveDiscoveryRecord): void {
if (
typeof record.url !== 'string' ||
typeof record.instanceNonce !== 'string' ||
(record.token !== undefined && typeof record.token !== 'string')
) {
throw new Error('Live discovery record is invalid.');
}
let url: URL;
try {
url = new URL(record.url);
} catch {
throw new Error('Live discovery URL is invalid.');
}
const hostname = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
const loopback =
hostname === 'localhost' ||
hostname === '::1' ||
(isIP(hostname) === 4 && hostname.startsWith('127.'));
if (
(url.protocol !== 'http:' && url.protocol !== 'https:') ||
!loopback ||
url.username.length > 0 ||
url.password.length > 0
) {
throw new Error('Live discovery URL must be a loopback HTTP URL.');
}
if (
!Number.isSafeInteger(record.protocolVersion) ||
record.protocolVersion <= 0 ||
!Number.isSafeInteger(record.pid) ||
record.pid <= 0 ||
!NONCE_PATTERN.test(record.instanceNonce) ||
(record.token !== undefined &&
(record.token.length === 0 || record.token.length > 4096))
) {
throw new Error('Live discovery record is invalid.');
}
}
function assertRecord(record: LiveDiscoveryRecord): void {
assertSafeRecord(record);
if (record.protocolVersion !== LIVE_HOST_PROTOCOL_VERSION) {
throw new Error('Live discovery record is invalid.');
}
}
function processIsAlive(pid: number): boolean {
// PID reuse is intentionally fail-closed. The locator has no independent
// authenticated nonce probe, so a live replacement process must not be
// mistaken for proof that the recorded daemon is stale.
try {
process.kill(pid, 0);
return true;
} catch (error) {
return (error as NodeJS.ErrnoException).code !== 'ESRCH';
}
}
function delay(milliseconds: number): Promise<void> {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function readExistingRecord(
filePath: string,
): Promise<ExistingLiveDiscoveryRecord | undefined> {
let stat: Awaited<ReturnType<typeof fs.lstat>>;
try {
stat = await fs.lstat(filePath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
if (
!stat.isFile() ||
stat.isSymbolicLink() ||
stat.nlink !== 1 ||
(process.platform !== 'win32' &&
((stat.mode & 0o777) !== 0o600 ||
(typeof process.getuid === 'function' &&
stat.uid !== process.getuid()))) ||
stat.size <= 0 ||
stat.size > MAX_DISCOVERY_BYTES
) {
throw new LiveDiscoveryStateError();
}
let handle: fs.FileHandle;
try {
handle = await fs.open(
filePath,
process.platform === 'win32'
? fsConstants.O_RDONLY
: fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW,
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ELOOP') {
throw new LiveDiscoveryStateError(error);
}
throw error;
}
try {
const handleStat = await handle.stat();
if (
!handleStat.isFile() ||
handleStat.nlink !== 1 ||
handleStat.dev !== stat.dev ||
handleStat.ino !== stat.ino ||
handleStat.size !== stat.size ||
(process.platform !== 'win32' &&
((handleStat.mode & 0o777) !== 0o600 ||
(typeof process.getuid === 'function' &&
handleStat.uid !== process.getuid())))
) {
throw new LiveDiscoveryStateError();
}
const serialized = await handle.readFile('utf8');
let parsed: unknown;
try {
parsed = JSON.parse(serialized);
} catch (error) {
throw new LiveDiscoveryStateError(error);
}
if (
typeof parsed !== 'object' ||
parsed === null ||
Array.isArray(parsed)
) {
throw new LiveDiscoveryStateError();
}
const record = parsed as ExistingLiveDiscoveryRecord;
try {
assertSafeRecord(record);
} catch {
throw new LiveDiscoveryStateError();
}
return record;
} finally {
await handle.close();
}
}
async function inspectDirectory(
directory: string,
requirePrivateMode = true,
): Promise<LiveDiscoveryDirectory> {
const stat = await fs.lstat(directory);
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
(process.platform !== 'win32' &&
((requirePrivateMode && (stat.mode & 0o777) !== 0o700) ||
(typeof process.getuid === 'function' &&
stat.uid !== process.getuid())))
) {
throw new LiveDiscoveryStateError();
}
return { directory, dev: stat.dev, ino: stat.ino };
}
async function assertDirectoryIdentity(
expected: LiveDiscoveryDirectory,
requirePrivateMode = true,
): Promise<void> {
const current = await inspectDirectory(
expected.directory,
requirePrivateMode,
);
if (current.dev !== expected.dev || current.ino !== expected.ino) {
throw new LiveDiscoveryStateError();
}
}
async function ensureDirectory(
directory: string,
requirePrivateMode: boolean,
): Promise<LiveDiscoveryDirectory> {
try {
return await inspectDirectory(directory, requirePrivateMode);
} catch (error) {
try {
await fs.lstat(directory);
throw error;
} catch (statError) {
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
const parent = path.dirname(directory);
if (parent === directory) throw new LiveDiscoveryStateError();
const parentIdentity = await ensureDirectory(parent, false);
await assertDirectoryIdentity(parentIdentity, false);
let created = false;
try {
await fs.mkdir(directory, { mode: 0o700 });
created = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
}
if (created && process.platform !== 'win32') {
await fs.chmod(directory, 0o700);
}
await assertDirectoryIdentity(parentIdentity, false);
return inspectDirectory(directory, requirePrivateMode);
}
async function assertLockShapeIfPresent(lockPath: string): Promise<void> {
let stat: Awaited<ReturnType<typeof fs.lstat>>;
try {
stat = await fs.lstat(lockPath);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return;
throw error;
}
if (
!stat.isDirectory() ||
stat.isSymbolicLink() ||
(process.platform !== 'win32' &&
typeof process.getuid === 'function' &&
stat.uid !== process.getuid())
) {
throw new LiveDiscoveryStateError();
}
}
async function prepareDirectory(
runtimeBaseDir: string,
create = true,
): Promise<LiveDiscoveryDirectory | undefined> {
const directory = path.dirname(getLiveDiscoveryPath(runtimeBaseDir));
let runtimeBaseIdentity: LiveDiscoveryDirectory;
try {
runtimeBaseIdentity = await inspectDirectory(runtimeBaseDir, false);
} catch (error) {
try {
await fs.lstat(runtimeBaseDir);
throw error;
} catch (statError) {
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
if (!create) return undefined;
runtimeBaseIdentity = await ensureDirectory(runtimeBaseDir, false);
}
let created = false;
try {
const identity = await inspectDirectory(directory);
await assertDirectoryIdentity(runtimeBaseIdentity, false);
return identity;
} catch (error) {
try {
await fs.lstat(directory);
throw error;
} catch (statError) {
if ((statError as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
}
if (!create) return undefined;
await assertDirectoryIdentity(runtimeBaseIdentity, false);
try {
await fs.mkdir(directory, { mode: 0o700 });
created = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
}
if (created && process.platform !== 'win32') {
await fs.chmod(directory, 0o700);
}
await assertDirectoryIdentity(runtimeBaseIdentity, false);
return inspectDirectory(directory);
}
async function lockDirectory(target: LiveDiscoveryDirectory): Promise<{
release: () => Promise<void>;
assertHealthy: () => void;
}> {
const lockPath = path.join(target.directory, '.daemon.lock');
await assertLockShapeIfPresent(lockPath);
const lockfile = (await import('proper-lockfile')).default;
let compromised = false;
const release = await lockfile.lock(target.directory, {
...LOCK_OPTIONS,
lockfilePath: lockPath,
onCompromised: () => {
compromised = true;
},
});
try {
await assertLockShapeIfPresent(lockPath);
} catch (error) {
let releaseError: unknown;
await release().catch((candidate: unknown) => {
releaseError = candidate;
});
throw new LiveDiscoveryStateError(
releaseError
? new AggregateError(
[error, releaseError],
'Live discovery lock validation cleanup failed.',
)
: error,
);
}
return {
release,
assertHealthy: () => {
if (compromised) throw new LiveDiscoveryStateError();
},
};
}
async function syncDirectory(directory: string): Promise<void> {
if (process.platform === 'win32') return;
const handle = await fs.open(directory, 'r');
try {
await handle.sync();
} finally {
await handle.close();
}
}
export async function handoffLiveDiscoveryOwner(
runtimeBaseDir: string,
owner: LiveDiscoveryOwner,
commitOwner: () => Promise<void>,
options: {
isProcessAlive?: (pid: number) => boolean;
wait?: (milliseconds: number) => Promise<void>;
handoffGraceMs?: number;
waitForHandoffGrace?: boolean;
} = {},
): Promise<{ reclaimed: boolean }> {
const target = await prepareDirectory(runtimeBaseDir, false);
if (!target) {
await commitOwner();
return { reclaimed: false };
}
const lock = await lockDirectory(target);
let result: { reclaimed: boolean } | undefined;
let operationError: unknown;
try {
lock.assertHealthy();
await assertDirectoryIdentity(target);
const filePath = getLiveDiscoveryPath(runtimeBaseDir);
const current = await readExistingRecord(filePath);
if (
current &&
(current.pid !== owner.pid ||
current.instanceNonce !== owner.instanceNonce)
) {
if (
current.pid === owner.pid ||
(options.isProcessAlive ?? processIsAlive)(current.pid)
) {
throw new LiveDiscoveryOwnerActiveError(current.pid);
}
await commitOwner();
lock.assertHealthy();
await assertDirectoryIdentity(target);
const confirmed = await readExistingRecord(filePath);
if (
!confirmed ||
confirmed.pid !== current.pid ||
confirmed.instanceNonce !== current.instanceNonce
) {
throw new LiveDiscoveryStateError();
}
lock.assertHealthy();
await assertDirectoryIdentity(target);
await fs.unlink(filePath);
await syncDirectory(target.directory);
lock.assertHealthy();
result = { reclaimed: true };
} else {
await commitOwner();
lock.assertHealthy();
result = { reclaimed: false };
}
} catch (error) {
operationError = error;
}
let releaseError: unknown;
await lock.release().catch((error: unknown) => {
releaseError = error;
});
if (releaseError) {
throw new LiveDiscoveryStateError(
operationError
? new AggregateError(
[operationError, releaseError],
'Live discovery handoff cleanup failed.',
)
: releaseError,
);
}
if (operationError) throw operationError;
if (result?.reclaimed && options.waitForHandoffGrace !== false) {
await (options.wait ?? delay)(
options.handoffGraceMs ?? DEFAULT_HANDOFF_GRACE_MS,
);
}
return result!;
}
export async function writeLiveDiscoveryFile(
runtimeBaseDir: string,
record: LiveDiscoveryRecord,
options: { isProcessAlive?: (pid: number) => boolean } = {},
): Promise<string> {
assertRecord(record);
const target = await prepareDirectory(runtimeBaseDir);
if (!target) throw new LiveDiscoveryStateError();
const { directory } = target;
const filePath = getLiveDiscoveryPath(runtimeBaseDir);
const temporaryPath = path.join(
directory,
`.daemon.${process.pid}.${randomUUID()}.tmp`,
);
const lock = await lockDirectory(target);
let handle: fs.FileHandle | undefined;
let operationError: unknown;
let operationSucceeded = false;
let published = false;
try {
lock.assertHealthy();
await assertDirectoryIdentity(target);
const current = await readExistingRecord(filePath);
if (
current &&
(current.pid !== record.pid ||
current.instanceNonce !== record.instanceNonce)
) {
if ((options.isProcessAlive ?? processIsAlive)(current.pid)) {
throw new LiveDiscoveryOwnerActiveError(current.pid);
}
throw new LiveDiscoveryStateError();
}
handle = await fs.open(temporaryPath, 'wx', 0o600);
const serialized = `${JSON.stringify(record)}\n`;
if (Buffer.byteLength(serialized) > MAX_DISCOVERY_BYTES) {
throw new Error('Live discovery record is too large.');
}
await handle.writeFile(serialized, 'utf8');
await handle.chmod(0o600);
await handle.sync();
await handle.close();
handle = undefined;
lock.assertHealthy();
await assertDirectoryIdentity(target);
await fs.rename(temporaryPath, filePath);
published = true;
await syncDirectory(directory);
lock.assertHealthy();
operationSucceeded = true;
} catch (error) {
operationError = error;
}
const cleanupErrors: unknown[] = [];
await handle?.close().catch((error: unknown) => cleanupErrors.push(error));
await fs.unlink(temporaryPath).catch((error: NodeJS.ErrnoException) => {
if (error.code !== 'ENOENT') cleanupErrors.push(error);
});
await lock.release().catch((error: unknown) => cleanupErrors.push(error));
if (!operationSucceeded) {
const cause =
cleanupErrors.length > 0
? new AggregateError(
[operationError, ...cleanupErrors],
'Live discovery publication cleanup failed.',
)
: operationError;
if (published) throw new LiveDiscoveryPublicationError(cause);
if (cleanupErrors.length > 0) {
throw cause;
}
throw operationError;
}
if (cleanupErrors.length > 0) {
throw new LiveDiscoveryPublicationError(
new AggregateError(
cleanupErrors,
'Live discovery publication cleanup failed.',
),
);
}
return filePath;
}
export async function removeLiveDiscoveryFile(
runtimeBaseDir: string,
owner: LiveDiscoveryOwner,
): Promise<boolean> {
if (
!NONCE_PATTERN.test(owner.instanceNonce) ||
!Number.isSafeInteger(owner.pid) ||
owner.pid <= 0
) {
throw new LiveDiscoveryStateError();
}
const target = await prepareDirectory(runtimeBaseDir, false);
if (!target) return false;
const filePath = getLiveDiscoveryPath(runtimeBaseDir);
const lock = await lockDirectory(target);
try {
lock.assertHealthy();
await assertDirectoryIdentity(target);
const current = await readExistingRecord(filePath);
if (!current) return false;
if (
current.instanceNonce !== owner.instanceNonce ||
current.pid !== owner.pid
) {
throw new LiveDiscoveryStateError();
}
lock.assertHealthy();
await assertDirectoryIdentity(target);
await fs.unlink(filePath);
await syncDirectory(target.directory);
lock.assertHealthy();
return true;
} finally {
await lock.release();
}
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,367 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { createHash } from 'node:crypto';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
import { LIVE_HOST_PROTOCOL_VERSION } from './types.js';
import {
downloadLiveHostRelease,
isExpectedLiveHostSignature,
LiveHostInstaller,
LIVE_HOST_DOWNLOAD_TIMEOUT_MS,
LIVE_HOST_MANIFEST_FETCH_TIMEOUT_MS,
LIVE_HOST_OSS_BASE_URL,
LIVE_HOST_RELEASE_BASE_URL,
parseLiveHostReleaseManifest,
resolveLiveHostAssetUrls,
resolveLiveHostManifestUrls,
} from './live-host-installer.js';
const sha = 'a'.repeat(64);
function manifest() {
return {
schemaVersion: 1,
version: '0.1.0',
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
bundleId: 'com.alibaba.qwen-code.live-host',
assets: {
arm64: {
name: 'Qwen-Live-Host-arm64.zip',
size: 123,
sha256: sha,
},
x64: {
name: 'Qwen-Live-Host-x64.zip',
size: 456,
sha256: sha,
},
},
};
}
function manifestForBytes(version: string, bytes: Buffer) {
const checksum = createHash('sha256').update(bytes).digest('hex');
return {
...manifest(),
version,
assets: {
arm64: {
name: 'Qwen-Live-Host-arm64.zip',
size: bytes.byteLength,
sha256: checksum,
},
x64: {
name: 'Qwen-Live-Host-x64.zip',
size: bytes.byteLength,
sha256: checksum,
},
},
};
}
describe('LiveHostInstaller', () => {
it('prefers the OSS mirror and retains the independent GitHub fallback', () => {
expect(LIVE_HOST_OSS_BASE_URL).toBe(
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/live-host',
);
expect(LIVE_HOST_RELEASE_BASE_URL).toBe(
'https://github.com/QwenLM/qwen-code/releases/download/live-host-latest',
);
expect(resolveLiveHostManifestUrls()).toEqual([
`${LIVE_HOST_OSS_BASE_URL}/latest/Qwen-Live-Host-manifest.json`,
`${LIVE_HOST_RELEASE_BASE_URL}/Qwen-Live-Host-manifest.json`,
]);
expect(
resolveLiveHostAssetUrls('0.1.0', 'Qwen-Live-Host-arm64.zip'),
).toEqual([
`${LIVE_HOST_OSS_BASE_URL}/v0.1.0/Qwen-Live-Host-arm64.zip`,
`${LIVE_HOST_RELEASE_BASE_URL}/Qwen-Live-Host-arm64.zip`,
]);
});
it('falls back with a matching GitHub manifest and asset pair', async () => {
const ossBytes = Buffer.from('oss-archive');
const githubBytes = Buffer.from('github-archive');
const ossManifest = manifestForBytes('0.1.0', ossBytes);
const githubManifest = manifestForBytes('0.2.0', githubBytes);
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(Response.json(ossManifest))
.mockResolvedValueOnce(new Response(null, { status: 503 }))
.mockResolvedValueOnce(Response.json(githubManifest))
.mockResolvedValueOnce(
new Response(githubBytes, {
headers: { 'content-length': String(githubBytes.byteLength) },
}),
);
const directory = await fsp.mkdtemp(
path.join(os.tmpdir(), 'live-host-download-test-'),
);
const destination = path.join(directory, 'host.zip');
try {
const release = await downloadLiveHostRelease(
'arm64',
destination,
() => {},
fetchImpl,
);
expect(release.manifest).toEqual(githubManifest);
await expect(fsp.readFile(destination)).resolves.toEqual(githubBytes);
expect(fetchImpl.mock.calls.map(([url]) => String(url))).toEqual([
resolveLiveHostManifestUrls()[0],
resolveLiveHostAssetUrls(
ossManifest.version,
ossManifest.assets.arm64.name,
)[0],
resolveLiveHostManifestUrls()[1],
resolveLiveHostAssetUrls(
githubManifest.version,
githubManifest.assets.arm64.name,
)[1],
]);
} finally {
await fsp.rm(directory, { recursive: true, force: true });
}
});
it('removes a checksum-invalid OSS archive before falling back', async () => {
const expectedOssBytes = Buffer.from('expected-oss-archive');
const corruptOssBytes = Buffer.alloc(expectedOssBytes.byteLength, 0x78);
const githubBytes = Buffer.from('github-archive');
const ossManifest = manifestForBytes('0.1.0', expectedOssBytes);
const githubManifest = manifestForBytes('0.2.0', githubBytes);
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(Response.json(ossManifest))
.mockResolvedValueOnce(
new Response(corruptOssBytes, {
headers: { 'content-length': String(corruptOssBytes.byteLength) },
}),
)
.mockResolvedValueOnce(Response.json(githubManifest))
.mockResolvedValueOnce(
new Response(githubBytes, {
headers: { 'content-length': String(githubBytes.byteLength) },
}),
);
const directory = await fsp.mkdtemp(
path.join(os.tmpdir(), 'live-host-download-test-'),
);
const destination = path.join(directory, 'host.zip');
try {
const release = await downloadLiveHostRelease(
'arm64',
destination,
() => {},
fetchImpl,
);
expect(release.manifest).toEqual(githubManifest);
await expect(fsp.readFile(destination)).resolves.toEqual(githubBytes);
} finally {
await fsp.rm(directory, { recursive: true, force: true });
}
});
it('reports both source failures and removes the destination', async () => {
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValue(new Response(null, { status: 503 }));
const directory = await fsp.mkdtemp(
path.join(os.tmpdir(), 'live-host-download-test-'),
);
const destination = path.join(directory, 'host.zip');
try {
await expect(
downloadLiveHostRelease('arm64', destination, () => {}, fetchImpl),
).rejects.toMatchObject({
message:
'Qwen Live Host download failed. OSS: Live Host manifest download failed (503). GitHub: Live Host manifest download failed (503).',
errors: [
{ message: 'OSS: Live Host manifest download failed (503).' },
{ message: 'GitHub: Live Host manifest download failed (503).' },
],
});
await expect(fsp.stat(destination)).rejects.toMatchObject({
code: 'ENOENT',
});
} finally {
await fsp.rm(directory, { recursive: true, force: true });
}
});
it('passes separate bounded signals to manifest and archive requests', async () => {
const bytes = Buffer.from('signed-live-host-archive');
const expected = manifestForBytes('0.1.0', bytes);
const manifestSignal = AbortSignal.abort('manifest');
const downloadSignal = AbortSignal.abort('download');
const timeout = vi
.spyOn(AbortSignal, 'timeout')
.mockReturnValueOnce(manifestSignal)
.mockReturnValueOnce(downloadSignal);
const fetchImpl = vi
.fn<typeof fetch>()
.mockResolvedValueOnce(Response.json(expected))
.mockResolvedValueOnce(new Response(bytes));
const directory = await fsp.mkdtemp(
path.join(os.tmpdir(), 'live-host-download-test-'),
);
const destination = path.join(directory, 'host.zip');
try {
await downloadLiveHostRelease('arm64', destination, () => {}, fetchImpl);
expect(LIVE_HOST_MANIFEST_FETCH_TIMEOUT_MS).toBe(5 * 60 * 1000);
expect(LIVE_HOST_DOWNLOAD_TIMEOUT_MS).toBe(60 * 60 * 1000);
expect(fetchImpl.mock.calls[0]?.[1]?.signal).toBe(manifestSignal);
expect(fetchImpl.mock.calls[1]?.[1]?.signal).toBe(downloadSignal);
} finally {
timeout.mockRestore();
await fsp.rm(directory, { recursive: true, force: true });
}
});
it('accepts only the Qwen Developer ID team', () => {
expect(
isExpectedLiveHostSignature(
[
'Authority=Developer ID Application: Alibaba Cloud (Singapore) Private Limited (NF4574S59H)',
'TeamIdentifier=NF4574S59H',
].join('\n'),
),
).toBe(true);
expect(
isExpectedLiveHostSignature(
'Authority=Developer ID Application: Other\nTeamIdentifier=OTHER12345',
),
).toBe(false);
expect(
isExpectedLiveHostSignature('Signature=adhoc\nTeamIdentifier=NF4574S59H'),
).toBe(false);
});
it('accepts only the fixed compatible release manifest', () => {
expect(parseLiveHostReleaseManifest(manifest())).toEqual(manifest());
expect(() =>
parseLiveHostReleaseManifest({
...manifest(),
protocolVersion: LIVE_HOST_PROTOCOL_VERSION + 1,
}),
).toThrow(/incompatible/);
expect(() =>
parseLiveHostReleaseManifest({
...manifest(),
assets: {
...manifest().assets,
arm64: { ...manifest().assets.arm64, name: 'other.zip' },
},
}),
).toThrow(/asset/);
});
it('launches an existing verified installation without downloading', async () => {
const inspectInstalled = vi.fn(async () => ({ version: '0.1.0' }));
const installLatest = vi.fn();
const launch = vi.fn(async () => {});
const installer = new LiveHostInstaller({
platform: 'darwin',
architecture: 'arm64',
inspectInstalled,
installLatest,
launch,
});
await expect(installer.ensureInstalled()).resolves.toEqual({
state: 'installed',
version: '0.1.0',
});
expect(installLatest).not.toHaveBeenCalled();
expect(launch).toHaveBeenCalledOnce();
});
it('coalesces concurrent installs and exposes progress', async () => {
let finish: ((value: { version: string }) => void) | undefined;
const installLatest = vi.fn(
async (
_architecture: 'arm64' | 'x64',
onStatus: (status: { state: 'downloading'; progress: number }) => void,
) => {
onStatus({ state: 'downloading', progress: 0.5 });
return await new Promise<{ version: string }>((resolve) => {
finish = resolve;
});
},
);
const installer = new LiveHostInstaller({
platform: 'darwin',
architecture: 'x64',
inspectInstalled: async () => undefined,
installLatest,
launch: async () => {},
});
const first = installer.ensureInstalled();
const second = installer.ensureInstalled();
await vi.waitFor(() => {
expect(installer.getStatus()).toEqual({
state: 'downloading',
progress: 0.5,
});
});
finish?.({ version: '0.1.0' });
await expect(first).resolves.toMatchObject({ state: 'installed' });
await expect(second).resolves.toMatchObject({ state: 'installed' });
expect(installLatest).toHaveBeenCalledOnce();
});
it('fails closed on unsupported platforms and architectures', async () => {
const installLatest = vi.fn();
const linux = new LiveHostInstaller({
platform: 'linux',
architecture: 'x64',
installLatest,
});
await expect(linux.ensureInstalled()).resolves.toMatchObject({
state: 'error',
retryable: false,
});
const unsupported = new LiveHostInstaller({
platform: 'darwin',
architecture: 'ia32',
inspectInstalled: async () => undefined,
installLatest,
});
await expect(unsupported.ensureInstalled()).resolves.toMatchObject({
state: 'error',
retryable: true,
});
expect(installLatest).not.toHaveBeenCalled();
});
it('keeps an installation failure retryable', async () => {
const installer = new LiveHostInstaller({
platform: 'darwin',
architecture: 'arm64',
inspectInstalled: async () => undefined,
installLatest: async () => {
throw new Error('checksum verification failed');
},
launch: async () => {},
});
await expect(installer.ensureInstalled()).resolves.toEqual({
state: 'error',
message: 'checksum verification failed',
retryable: true,
});
});
});
@@ -0,0 +1,504 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { createHash, randomUUID } from 'node:crypto';
import * as fs from 'node:fs';
import * as fsp from 'node:fs/promises';
import * as os from 'node:os';
import * as path from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { promisify } from 'node:util';
import { LIVE_HOST_PROTOCOL_VERSION } from './types.js';
const execFileAsync = promisify(execFile);
export const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host';
export const LIVE_HOST_TEAM_IDENTIFIER = 'NF4574S59H';
export const LIVE_HOST_APP_PATH = '/Applications/Qwen Live Host.app';
export const LIVE_HOST_OSS_BASE_URL =
'https://qwen-code-assets.oss-cn-hangzhou.aliyuncs.com/live-host';
export const LIVE_HOST_RELEASE_BASE_URL =
'https://github.com/QwenLM/qwen-code/releases/download/live-host-latest';
export const LIVE_HOST_MANIFEST_NAME = 'Qwen-Live-Host-manifest.json';
export const LIVE_HOST_MANIFEST_FETCH_TIMEOUT_MS = 5 * 60 * 1000;
export const LIVE_HOST_DOWNLOAD_TIMEOUT_MS = 60 * 60 * 1000;
const LIVE_HOST_APP_NAME = 'Qwen Live Host.app';
const MAX_DOWNLOAD_BYTES = 512 * 1024 * 1024;
const COMMAND_TIMEOUT_MS = 2 * 60 * 1000;
const SHA256_PATTERN = /^[a-f0-9]{64}$/;
const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
export type LiveHostArchitecture = 'arm64' | 'x64';
export type LiveHostInstallState =
| 'missing'
| 'checking'
| 'downloading'
| 'verifying'
| 'installing'
| 'launching'
| 'installed'
| 'error';
export interface LiveHostInstallStatus {
state: LiveHostInstallState;
version?: string;
progress?: number;
message?: string;
retryable?: boolean;
}
export interface LiveHostReleaseAsset {
name: string;
size: number;
sha256: string;
}
export interface LiveHostReleaseManifest {
schemaVersion: 1;
version: string;
protocolVersion: number;
bundleId: string;
assets: Record<LiveHostArchitecture, LiveHostReleaseAsset>;
}
interface InstalledLiveHost {
version: string;
}
export interface LiveHostInstallerDeps {
platform?: NodeJS.Platform;
architecture?: string;
inspectInstalled?: () => Promise<InstalledLiveHost | undefined>;
installLatest?: (
architecture: LiveHostArchitecture,
onStatus: (status: LiveHostInstallStatus) => void,
) => Promise<InstalledLiveHost>;
launch?: () => Promise<void>;
}
export function isExpectedLiveHostSignature(output: string): boolean {
return (
/^Authority=Developer ID Application:/m.test(output) &&
new RegExp(`^TeamIdentifier=${LIVE_HOST_TEAM_IDENTIFIER}$`, 'm').test(
output,
)
);
}
function architecture(value: string): LiveHostArchitecture {
if (value === 'arm64' || value === 'x64') return value;
throw new Error(`Qwen Live Host is unavailable for architecture ${value}.`);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
function parseAsset(
value: unknown,
expectedName: string,
): LiveHostReleaseAsset {
if (!isRecord(value)) throw new Error('Live Host manifest asset is invalid.');
const name = value['name'];
const size = value['size'];
const sha256 = value['sha256'];
if (
name !== expectedName ||
!Number.isSafeInteger(size) ||
Number(size) <= 0 ||
Number(size) > MAX_DOWNLOAD_BYTES ||
typeof sha256 !== 'string' ||
!SHA256_PATTERN.test(sha256)
) {
throw new Error('Live Host manifest asset is invalid.');
}
return { name, size: Number(size), sha256 };
}
export function parseLiveHostReleaseManifest(
value: unknown,
): LiveHostReleaseManifest {
if (!isRecord(value) || !isRecord(value['assets'])) {
throw new Error('Live Host manifest is invalid.');
}
const version = value['version'];
if (
value['schemaVersion'] !== 1 ||
typeof version !== 'string' ||
!VERSION_PATTERN.test(version) ||
value['protocolVersion'] !== LIVE_HOST_PROTOCOL_VERSION ||
value['bundleId'] !== LIVE_HOST_BUNDLE_ID
) {
throw new Error('Live Host manifest is incompatible.');
}
return {
schemaVersion: 1,
version,
protocolVersion: LIVE_HOST_PROTOCOL_VERSION,
bundleId: LIVE_HOST_BUNDLE_ID,
assets: {
arm64: parseAsset(value['assets']['arm64'], 'Qwen-Live-Host-arm64.zip'),
x64: parseAsset(value['assets']['x64'], 'Qwen-Live-Host-x64.zip'),
},
};
}
async function run(file: string, args: string[]): Promise<string> {
const { stdout } = await execFileAsync(file, args, {
encoding: 'utf8',
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
});
return stdout.trim();
}
async function readBundleValue(appPath: string, key: string): Promise<string> {
return await run('/usr/bin/plutil', [
'-extract',
key,
'raw',
'-o',
'-',
path.join(appPath, 'Contents', 'Info.plist'),
]);
}
async function inspectApp(appPath: string): Promise<InstalledLiveHost> {
const stat = await fsp.lstat(appPath);
if (!stat.isDirectory() || stat.isSymbolicLink()) {
throw new Error('Qwen Live Host installation is not a regular app bundle.');
}
const bundleId = await readBundleValue(appPath, 'CFBundleIdentifier');
if (bundleId !== LIVE_HOST_BUNDLE_ID) {
throw new Error('Qwen Live Host bundle identity is invalid.');
}
const version = await readBundleValue(appPath, 'CFBundleShortVersionString');
if (!VERSION_PATTERN.test(version)) {
throw new Error('Qwen Live Host version is invalid.');
}
await run('/usr/bin/codesign', [
'--verify',
'--deep',
'--strict',
'--verbose=2',
appPath,
]);
const signature = await execFileAsync(
'/usr/bin/codesign',
['-dv', '--verbose=4', appPath],
{
encoding: 'utf8',
timeout: COMMAND_TIMEOUT_MS,
maxBuffer: 1024 * 1024,
},
);
const signatureOutput = `${signature.stdout}${signature.stderr}`;
if (!isExpectedLiveHostSignature(signatureOutput)) {
throw new Error('Qwen Live Host signing identity is invalid.');
}
await run('/usr/sbin/spctl', ['-a', '-t', 'exec', appPath]);
return { version };
}
async function inspectInstalledHost(): Promise<InstalledLiveHost | undefined> {
try {
return await inspectApp(LIVE_HOST_APP_PATH);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined;
throw error;
}
}
export function resolveLiveHostManifestUrls(): [string, string] {
return [
`${LIVE_HOST_OSS_BASE_URL}/latest/${LIVE_HOST_MANIFEST_NAME}`,
`${LIVE_HOST_RELEASE_BASE_URL}/${LIVE_HOST_MANIFEST_NAME}`,
];
}
export function resolveLiveHostAssetUrls(
version: string,
assetName: string,
): [string, string] {
return [
`${LIVE_HOST_OSS_BASE_URL}/v${version}/${assetName}`,
`${LIVE_HOST_RELEASE_BASE_URL}/${assetName}`,
];
}
async function fetchManifest(
url: string,
fetchImpl: typeof fetch,
): Promise<LiveHostReleaseManifest> {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(LIVE_HOST_MANIFEST_FETCH_TIMEOUT_MS),
});
if (!response.ok) {
await response.body?.cancel().catch(() => {});
throw new Error(`Live Host manifest download failed (${response.status}).`);
}
return parseLiveHostReleaseManifest(await response.json());
}
async function downloadAsset(
asset: LiveHostReleaseAsset,
url: string,
destination: string,
onProgress: (progress: number) => void,
fetchImpl: typeof fetch,
): Promise<void> {
const response = await fetchImpl(url, {
signal: AbortSignal.timeout(LIVE_HOST_DOWNLOAD_TIMEOUT_MS),
});
if (!response.ok || !response.body) {
await response.body?.cancel().catch(() => {});
throw new Error(`Live Host download failed (${response.status}).`);
}
const contentLength = Number(response.headers.get('content-length'));
if (
Number.isFinite(contentLength) &&
contentLength > 0 &&
contentLength !== asset.size
) {
await response.body.cancel().catch(() => {});
throw new Error('Live Host download size does not match its manifest.');
}
const hash = createHash('sha256');
let received = 0;
const guard = new Transform({
transform(chunk: Buffer, _encoding, callback) {
received += chunk.byteLength;
if (received > asset.size || received > MAX_DOWNLOAD_BYTES) {
callback(new Error('Live Host download exceeded its manifest size.'));
return;
}
hash.update(chunk);
onProgress(Math.min(1, received / asset.size));
callback(null, chunk);
},
});
await pipeline(
Readable.fromWeb(response.body as Parameters<typeof Readable.fromWeb>[0]),
guard,
fs.createWriteStream(destination, { flags: 'wx', mode: 0o600 }),
);
if (received !== asset.size || hash.digest('hex') !== asset.sha256) {
throw new Error('Live Host checksum verification failed.');
}
}
export async function downloadLiveHostRelease(
currentArchitecture: LiveHostArchitecture,
destination: string,
onProgress: (progress: number) => void,
fetchImpl: typeof fetch = fetch,
): Promise<{
manifest: LiveHostReleaseManifest;
asset: LiveHostReleaseAsset;
}> {
const manifestUrls = resolveLiveHostManifestUrls();
const labels = ['OSS', 'GitHub'];
const errors: Error[] = [];
for (let index = 0; index < manifestUrls.length; index += 1) {
try {
await fsp.rm(destination, { force: true });
const manifest = await fetchManifest(manifestUrls[index], fetchImpl);
const asset = manifest.assets[currentArchitecture];
const assetUrl = resolveLiveHostAssetUrls(manifest.version, asset.name)[
index
];
await downloadAsset(asset, assetUrl, destination, onProgress, fetchImpl);
return { manifest, asset };
} catch (error) {
errors.push(
new Error(`${labels[index]}: ${errorMessage(error)}`, { cause: error }),
);
}
}
await fsp.rm(destination, { force: true });
throw new AggregateError(
errors,
`Qwen Live Host download failed. ${errors
.map((error) => error.message)
.join(' ')}`,
);
}
async function installLatestHost(
currentArchitecture: LiveHostArchitecture,
onStatus: (status: LiveHostInstallStatus) => void,
): Promise<InstalledLiveHost> {
const temporaryDirectory = await fsp.mkdtemp(
path.join(os.tmpdir(), 'qwen-live-host-'),
);
const archivePath = path.join(temporaryDirectory, 'Qwen-Live-Host.zip');
const extractedPath = path.join(temporaryDirectory, 'extracted');
const candidatePath = path.join(extractedPath, LIVE_HOST_APP_NAME);
const stagingPath = `/Applications/.Qwen Live Host.install-${randomUUID()}.app`;
const backupPath = `/Applications/.Qwen Live Host.backup-${randomUUID()}.app`;
let movedExisting = false;
let installedCandidate = false;
try {
onStatus({ state: 'downloading', progress: 0 });
const { manifest } = await downloadLiveHostRelease(
currentArchitecture,
archivePath,
(progress) => {
onStatus({ state: 'downloading', progress });
},
);
onStatus({ state: 'verifying' });
await fsp.mkdir(extractedPath, { mode: 0o700 });
await run('/usr/bin/ditto', ['-x', '-k', archivePath, extractedPath]);
const candidate = await inspectApp(candidatePath);
if (candidate.version !== manifest.version) {
throw new Error('Live Host package version does not match its manifest.');
}
onStatus({ state: 'installing', version: manifest.version });
await run('/usr/bin/ditto', [candidatePath, stagingPath]);
await inspectApp(stagingPath);
try {
await fsp.rename(LIVE_HOST_APP_PATH, backupPath);
movedExisting = true;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
}
await fsp.rename(stagingPath, LIVE_HOST_APP_PATH);
installedCandidate = true;
const installed = await inspectApp(LIVE_HOST_APP_PATH);
if (installed.version !== manifest.version) {
throw new Error('Installed Live Host version is invalid.');
}
if (movedExisting) {
await fsp.rm(backupPath, { recursive: true, force: true });
}
return installed;
} catch (error) {
if (installedCandidate) {
await fsp.rm(LIVE_HOST_APP_PATH, { recursive: true, force: true });
}
if (movedExisting) {
await fsp.rename(backupPath, LIVE_HOST_APP_PATH).catch(() => {});
}
throw error;
} finally {
await fsp.rm(stagingPath, { recursive: true, force: true }).catch(() => {});
await fsp.rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function launchInstalledHost(): Promise<void> {
await run('/usr/bin/open', [LIVE_HOST_APP_PATH]);
}
function errorMessage(error: unknown): string {
if (error instanceof Error && error.message) return error.message;
return 'Qwen Live Host setup failed.';
}
export class LiveHostInstaller {
private status: LiveHostInstallStatus = { state: 'missing' };
private operation: Promise<LiveHostInstallStatus> | undefined;
private readonly platform: NodeJS.Platform;
private readonly currentArchitecture: string;
private readonly inspectInstalled: () => Promise<
InstalledLiveHost | undefined
>;
private readonly installLatest: NonNullable<
LiveHostInstallerDeps['installLatest']
>;
private readonly launchHost: () => Promise<void>;
constructor(deps: LiveHostInstallerDeps = {}) {
this.platform = deps.platform ?? process.platform;
this.currentArchitecture = deps.architecture ?? process.arch;
this.inspectInstalled = deps.inspectInstalled ?? inspectInstalledHost;
this.installLatest = deps.installLatest ?? installLatestHost;
this.launchHost = deps.launch ?? launchInstalledHost;
}
getStatus(): LiveHostInstallStatus {
return { ...this.status };
}
async refresh(): Promise<LiveHostInstallStatus> {
if (this.operation) return await this.operation;
if (this.platform !== 'darwin') {
return this.setError('Qwen Live Host is available only on macOS.', false);
}
this.status = { state: 'checking' };
try {
const installed = await this.inspectInstalled();
this.status = installed
? { state: 'installed', version: installed.version }
: { state: 'missing' };
} catch (error) {
this.setError(errorMessage(error), true);
}
return this.getStatus();
}
ensureInstalled(force = false): Promise<LiveHostInstallStatus> {
if (this.operation) return this.operation;
const operation = this.runInstall(force).finally(() => {
if (this.operation === operation) this.operation = undefined;
});
this.operation = operation;
return operation;
}
async launch(): Promise<LiveHostInstallStatus> {
if (this.platform !== 'darwin') {
return this.setError('Qwen Live Host is available only on macOS.', false);
}
if (this.operation) return await this.operation;
try {
const installed = await this.inspectInstalled();
if (!installed)
return this.setError('Qwen Live Host is not installed.', true);
this.status = { state: 'launching', version: installed.version };
await this.launchHost();
this.status = { state: 'installed', version: installed.version };
} catch (error) {
this.setError(errorMessage(error), true);
}
return this.getStatus();
}
private async runInstall(force: boolean): Promise<LiveHostInstallStatus> {
if (this.platform !== 'darwin') {
return this.setError('Qwen Live Host is available only on macOS.', false);
}
let currentArchitecture: LiveHostArchitecture;
try {
currentArchitecture = architecture(this.currentArchitecture);
this.status = { state: 'checking' };
const installed = force ? undefined : await this.inspectInstalled();
const ready =
installed ??
(await this.installLatest(currentArchitecture, (status) => {
this.status = { ...status };
}));
this.status = { state: 'launching', version: ready.version };
await this.launchHost();
this.status = { state: 'installed', version: ready.version };
} catch (error) {
this.setError(errorMessage(error), true);
}
return this.getStatus();
}
private setError(message: string, retryable: boolean): LiveHostInstallStatus {
this.status = { state: 'error', message, retryable };
return this.getStatus();
}
}
+197
View File
@@ -0,0 +1,197 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
export const LIVE_HOST_PROTOCOL_VERSION = 6 as const;
export const LIVE_HOST_BUNDLE_ID = 'com.alibaba.qwen-code.live-host' as const;
export const LIVE_INPUT_AUDIO_EPOCH_BYTES = 8;
export type LiveState =
| 'unavailable'
| 'idle'
| 'starting'
| 'listening'
| 'thinking'
| 'speaking'
| 'stopping'
| 'error';
export type LiveBlocker =
| 'host_missing'
| 'host_disconnected'
| 'host_version'
| 'microphone_permission'
| 'accessibility_permission'
| 'screen_recording_permission'
| 'audio_input'
| 'audio_output'
| 'global_shortcut'
| 'appshot'
| 'provider_config'
| 'provider_unreachable';
export type LiveRequirementState =
| 'ready'
| 'missing'
| 'denied'
| 'unavailable'
| 'checking';
export interface LiveSessionLocator {
workspaceCwd: string;
workspaceId?: string;
sessionId: string;
}
export interface LiveStatus {
v: 1;
available: boolean;
state: LiveState;
shortcut: string;
blocker?: LiveBlocker;
message?: string;
callId?: string;
inputMuted?: boolean;
outputMuted?: boolean;
transcript?: string;
caption?: string;
statusText?: string;
pendingPermission?: {
workspaceId: string;
sessionId: string;
};
requirements?: Partial<
Record<
| 'host'
| 'microphone'
| 'accessibility'
| 'screenRecording'
| 'audioInput'
| 'audioOutput'
| 'globalShortcut'
| 'appshot'
| 'provider',
LiveRequirementState
>
>;
host?: {
version?: string;
protocolVersion?: number;
};
}
export type LiveHostStatus = LiveStatus;
export type LivePermissionState = 'granted' | 'denied' | 'not_determined';
export interface LiveHostHello {
type: 'host.hello';
protocolVersion: number;
hostVersion: string;
bundleId: string;
instanceNonce: string;
permissions: {
microphone: LivePermissionState;
accessibility: LivePermissionState;
screenRecording: LivePermissionState;
};
selfChecks: {
audioInput: boolean;
audioOutput: boolean;
globalShortcut: boolean;
appshot: boolean;
};
}
export type LiveHostAction =
| {
type: 'host.action';
action: 'toggle' | 'new' | 'stop';
epoch?: number;
}
| {
type: 'host.action';
action: 'mute';
inputMuted?: boolean;
outputMuted?: boolean;
epoch?: number;
};
export interface LiveHostPong {
type: 'host.pong';
pingId: string;
}
export interface LiveHostShortcutResult {
type: 'host.shortcut_result';
requestId: string;
shortcut: string;
success: boolean;
error?: string;
}
export type LiveHostScreenContextResult =
| {
type: 'host.screen_context_result';
requestId: string;
success: true;
appName: string;
windowTitle?: string;
accessibilityText: string;
screenshotPath: string;
}
| {
type: 'host.screen_context_result';
requestId: string;
success: false;
error: string;
};
export type LiveHostMessage =
| LiveHostHello
| LiveHostAction
| LiveHostPong
| LiveHostShortcutResult
| LiveHostScreenContextResult;
export type LiveDaemonMessage =
| {
type: 'host.welcome';
protocolVersion: typeof LIVE_HOST_PROTOCOL_VERSION;
daemonInstanceNonce: string;
heartbeatIntervalMs: number;
epoch: number;
status: LiveHostStatus;
}
| { type: 'host.state'; epoch: number; status: LiveHostStatus }
| { type: 'host.ping'; pingId: string }
| { type: 'host.clear_output'; epoch: number }
| { type: 'host.set_shortcut'; requestId: string; shortcut: string }
| {
type: 'host.capture_screen_context';
requestId: string;
epoch: number;
}
| {
type: 'host.error';
code: 'invalid_message' | 'stale_epoch';
message: string;
};
export interface LiveMuteUpdate {
inputMuted?: boolean;
outputMuted?: boolean;
}
export interface LiveProviderReadiness {
state: 'ready' | 'checking' | 'unavailable';
blocker?: Extract<LiveBlocker, 'provider_config' | 'provider_unreachable'>;
message?: string;
}
export interface LiveAppshotReadiness {
state: 'ready' | 'checking' | 'unavailable';
message?: string;
}
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env node
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* `qwen-live` bin entry: load config, start the daemon, exit cleanly on
* SIGINT/SIGTERM.
*/
import { realpathSync, statSync } from 'node:fs';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { loadConfig } from './config.js';
import { LiveDaemon } from './daemon.js';
import { LiveLogger } from './logger.js';
export { loadConfig, type LiveConfig } from './config.js';
export { LiveDaemon } from './daemon.js';
export type {
BackendAdaptor,
BackendCapabilities,
BackendEvent,
BackendHandle,
} from './adaptor/types.js';
async function main(): Promise<void> {
const logger = new LiveLogger();
// A stray rejection in a background chain (event pump, auto-approval)
// must be diagnosable, not process-fatal.
process.on('unhandledRejection', (reason) => {
logger.error(
`unhandled rejection: ${
reason instanceof Error
? (reason.stack ?? reason.message)
: String(reason)
}`,
);
});
let daemon: LiveDaemon;
try {
daemon = new LiveDaemon(loadConfig());
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
return;
}
let shuttingDown = false;
const shutdown = (signal: string) => {
if (shuttingDown) return;
shuttingDown = true;
logger.info(`received ${signal}, shutting down`);
daemon
.stop()
.catch((error: unknown) => {
logger.error(
`shutdown failed: ${
error instanceof Error ? error.message : String(error)
}`,
);
})
.finally(() => {
process.exit(0);
});
};
process.on('SIGINT', () => {
shutdown('SIGINT');
});
process.on('SIGTERM', () => {
shutdown('SIGTERM');
});
try {
await daemon.start();
} catch (error) {
logger.error(error instanceof Error ? error.message : String(error));
await daemon.stop().catch(() => undefined);
process.exitCode = 1;
}
}
// Only run as a daemon when invoked as the bin, not when imported. npm
// installs bins as symlinks and Node resolves import.meta.url through them,
// so compare against the realpath (same pattern as packages/cli/src/cli.ts);
// pathToFileURL also percent-encodes metacharacters (# ? %) the way Node did
// when it formed import.meta.url.
let invokedDirectly = false;
if (process.argv[1] !== undefined) {
try {
const entry = realpathSync(process.argv[1]);
// Direct file invocation…
invokedDirectly = import.meta.url === pathToFileURL(entry).href;
// …or directory-form invocation (`node packages/qwen-live`): Node
// resolves the directory against package.json main; compare against
// the built entry the bin ships so main() still runs.
if (
!invokedDirectly &&
statSync(entry, { throwIfNoEntry: false })?.isDirectory()
) {
invokedDirectly =
import.meta.url === pathToFileURL(join(entry, 'dist', 'index.js')).href;
}
} catch {
invokedDirectly = import.meta.url === pathToFileURL(process.argv[1]).href;
}
}
if (invokedDirectly) {
void main();
}
@@ -0,0 +1,163 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdtemp, readFile, rm, stat } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { SessionLog } from './session-log.js';
interface LoggedLine {
ts: number;
seq: number;
type: string;
payload: Record<string, unknown>;
}
const temporaryDirectories: string[] = [];
async function temporaryDirectory(): Promise<string> {
const directory = await mkdtemp(join(tmpdir(), 'qwen-live-log-'));
temporaryDirectories.push(directory);
return directory;
}
async function readLines(path: string): Promise<LoggedLine[]> {
const raw = await readFile(path, 'utf8');
return raw
.split('\n')
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line) as LoggedLine);
}
afterEach(async () => {
await Promise.all(
temporaryDirectories
.splice(0)
.map((directory) => rm(directory, { recursive: true, force: true })),
);
});
describe('SessionLog', () => {
it('writes one JSON line per event with ts, monotonic seq, type, and payload', async () => {
const base = await temporaryDirectory();
let tick = 100;
const log = new SessionLog({
directory: join(base, 'logs'),
liveSessionId: 'live_test',
now: () => tick,
});
log.write('session.start', { liveSessionId: 'live_test' });
tick = 250;
log.write('tool.call', { name: 'start_job' });
tick = 400;
log.write('session.end', {});
await log.close();
expect(log.filePath).toBe(join(base, 'logs', 'live_test.jsonl'));
const lines = await readLines(log.filePath);
expect(lines).toEqual([
{
ts: 100,
seq: 1,
type: 'session.start',
payload: { liveSessionId: 'live_test' },
},
{ ts: 250, seq: 2, type: 'tool.call', payload: { name: 'start_job' } },
{ ts: 400, seq: 3, type: 'session.end', payload: {} },
]);
const sequences = lines.map((line) => line.seq);
expect(sequences).toEqual([...sequences].sort((a, b) => a - b));
});
it('creates a 0700 directory and a 0600 log file', async () => {
const base = await temporaryDirectory();
const directory = join(base, 'nested', 'logs');
const log = new SessionLog({ directory, liveSessionId: 'live_modes' });
log.write('session.start', {});
await log.close();
if (process.platform !== 'win32') {
expect((await stat(directory)).mode & 0o777).toBe(0o700);
expect((await stat(log.filePath)).mode & 0o777).toBe(0o600);
}
await expect(readFile(log.filePath, 'utf8')).resolves.toContain(
'session.start',
);
});
it('rotates to a .1 file past maxBytes and keeps writing to a fresh file', async () => {
const base = await temporaryDirectory();
const log = new SessionLog({
directory: base,
liveSessionId: 'live_rotate',
maxBytes: 250,
now: () => 1,
});
// Two ~100-byte lines fit; the third overflows and triggers rotation.
log.write('backend.event', { marker: 'first', filler: 'x'.repeat(20) });
log.write('backend.event', { marker: 'second', filler: 'x'.repeat(20) });
log.write('backend.event', { marker: 'third', filler: 'x'.repeat(20) });
await log.close();
const rotatedPath = `${log.filePath}.1`;
// The rotated-out stream flushes its remaining buffer asynchronously
// after the rename; poll until both files hold their final content.
await vi.waitFor(async () => {
const rotated = await readLines(rotatedPath);
expect(rotated.map((line) => line.payload['marker'])).toEqual([
'first',
'second',
]);
const current = await readLines(log.filePath);
expect(current.map((line) => line.payload['marker'])).toEqual(['third']);
expect(current[0]?.seq).toBe(3);
});
});
it('does not throw when written after close', async () => {
const base = await temporaryDirectory();
const log = new SessionLog({ directory: base, liveSessionId: 'live_late' });
log.write('session.start', {});
await log.close();
// close() is terminal: a late write must not silently reopen a stream
// whose buffer would be lost at process.exit. Deleting the file first
// proves the write was a no-op rather than a reopen.
await rm(log.filePath);
expect(() => {
log.write('session.end', {});
}).not.toThrow();
await expect(stat(log.filePath)).rejects.toMatchObject({ code: 'ENOENT' });
await log.close();
});
it('flips to silent no-op mode after a write failure', async () => {
const base = await temporaryDirectory();
const log = new SessionLog({
directory: base,
liveSessionId: 'live_fail',
});
const circular: Record<string, unknown> = {};
circular['self'] = circular;
// JSON.stringify throws on the circular payload; the log must swallow it.
expect(() => {
log.write('error', circular);
}).not.toThrow();
// Every later write is silently dropped.
expect(() => {
log.write('session.start', {});
}).not.toThrow();
await log.close();
await expect(stat(log.filePath)).rejects.toThrow();
});
});
+148
View File
@@ -0,0 +1,148 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Append-only JSONL log for one live session. This is the only durable
* record of a call (the harness no longer records live conversations), and
* doubles as the timing-bug forensics source: event names line up with the
* orchestrator's state machine.
*
* Redaction rules: never log API keys or raw audio; screenshots are logged
* as asset ids, not bytes.
*/
import {
createWriteStream,
mkdirSync,
openSync,
renameSync,
type WriteStream,
} from 'node:fs';
import { join } from 'node:path';
const DEFAULT_MAX_BYTES = 32 * 1024 * 1024;
export type SessionLogEventType =
| 'session.start'
| 'session.end'
| 'vad.speech_started'
| 'vad.speech_stopped'
| 'transcript.user'
| 'transcript.assistant'
| 'response.created'
| 'response.done'
| 'response.cancelled'
| 'tool.call'
| 'tool.result'
| 'backend.event'
| 'inject.context'
| 'inject.speech'
| 'permission.request'
| 'permission.decision'
| 'playback.started'
| 'playback.cleared'
| 'error';
export interface SessionLogOptions {
directory: string;
liveSessionId: string;
maxBytes?: number;
now?: () => number;
}
export class SessionLog {
private stream: WriteStream | undefined;
private readonly path: string;
private readonly maxBytes: number;
private readonly now: () => number;
private seq = 0;
private bytes = 0;
private rotations = 0;
private failed = false;
private closed = false;
constructor(private readonly options: SessionLogOptions) {
this.path = join(options.directory, `${options.liveSessionId}.jsonl`);
this.maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
this.now = options.now ?? Date.now;
}
/**
* Logging must never take the call down: failures flip to no-op mode, and
* writes after close() are dropped (a reopened stream would only lose its
* buffer at process.exit).
*/
write(type: SessionLogEventType, payload: Record<string, unknown>): void {
if (this.failed || this.closed) return;
try {
const line = `${JSON.stringify({
ts: this.now(),
seq: ++this.seq,
type,
payload,
})}\n`;
const bytes = Buffer.byteLength(line);
if (this.bytes + bytes > this.maxBytes) {
this.rotate();
}
this.ensureStream().write(line);
this.bytes += bytes;
} catch {
this.failed = true;
}
}
/** Terminal: later write() calls are documented no-ops. */
async close(): Promise<void> {
this.closed = true;
const stream = this.stream;
this.stream = undefined;
if (!stream) return;
await new Promise<void>((resolve) => {
stream.end(() => {
resolve();
});
});
}
get filePath(): string {
return this.path;
}
private ensureStream(): WriteStream {
if (!this.stream) {
mkdirSync(this.options.directory, { recursive: true, mode: 0o700 });
// Open synchronously so the file exists on disk before write() returns;
// rotate() relies on a synchronous rename of an existing path.
const fd = openSync(this.path, 'a', 0o600);
this.stream = createWriteStream(this.path, { fd });
this.stream.on('error', () => {
this.failed = true;
});
}
return this.stream;
}
/**
* Rotation must complete before the next line is written, or the freshly
* reopened stream would share the old inode and the rename would drag it
* along (observed as reordered lines in the rotated file and a missing
* main file). Rename synchronously first, then let the old stream flush
* its remaining buffer into the rotated file it already points at.
*/
private rotate(): void {
const stream = this.stream;
this.stream = undefined;
this.bytes = 0;
const rotatedTo = `${this.path}.${++this.rotations}`;
try {
renameSync(this.path, rotatedTo);
} catch {
/* nothing to rotate */
}
stream?.end();
}
}
+48
View File
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Minimal stderr logger. The repository bans `console`; a daemon still needs
* operator-facing diagnostics, and stderr is the right channel for them
* (stdout carries the machine-readable listening line only).
*/
export type LogLevel = 'debug' | 'info' | 'warn' | 'error';
const LEVEL_ORDER: Record<LogLevel, number> = {
debug: 10,
info: 20,
warn: 30,
error: 40,
};
export class LiveLogger {
constructor(
private readonly minLevel: LogLevel = (process.env[
'QWEN_LIVE_LOG_LEVEL'
] as LogLevel | undefined) ?? 'info',
) {}
private write(level: LogLevel, message: string): void {
if (LEVEL_ORDER[level] < (LEVEL_ORDER[this.minLevel] ?? 20)) return;
process.stderr.write(
`[qwen-live] ${new Date().toISOString()} ${level.toUpperCase()} ${message}\n`,
);
}
debug(message: string): void {
this.write('debug', message);
}
info(message: string): void {
this.write('info', message);
}
warn(message: string): void {
this.write('warn', message);
}
error(message: string): void {
this.write('error', message);
}
}
@@ -0,0 +1,414 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { Injector } from './injector.js';
import type { InjectorItem, InjectorSink } from './injector.js';
const QUIET_GAP_MS = 800;
class FakeSink implements InjectorSink {
contextCalls: string[] = [];
speechCalls: string[] = [];
injected: Array<{ item: InjectorItem; spoken: boolean }> = [];
contextResult = true;
speechResult = true;
injectContext(text: string): boolean {
this.contextCalls.push(text);
return this.contextResult;
}
injectSpeech(text: string): boolean {
this.speechCalls.push(text);
return this.speechResult;
}
onInjected(item: InjectorItem, spoken: boolean): void {
this.injected.push({ item, spoken });
}
}
function complete(context: string, spoken?: string): InjectorItem {
return { kind: 'complete', context, ...(spoken ? { spoken } : {}) };
}
let sink: FakeSink;
let injector: Injector;
function makeInjector(options?: {
quietGapMs?: number;
progressThrottleMs?: number;
}): Injector {
injector = new Injector({
sink,
now: () => Date.now(),
...(options?.quietGapMs !== undefined
? { quietGapMs: options.quietGapMs }
: {}),
...(options?.progressThrottleMs !== undefined
? { progressThrottleMs: options.progressThrottleMs }
: {}),
});
return injector;
}
beforeEach(() => {
vi.useFakeTimers();
// Far past the initial playbackDeadline (0) + quiet gap: window starts open.
vi.setSystemTime(1_000_000);
sink = new FakeSink();
makeInjector();
});
afterEach(() => {
injector.dispose();
vi.useRealTimers();
});
describe('Injector window conditions', () => {
it('holds items while the user is speaking and delivers on speech stop', () => {
injector.noteSpeechStarted();
injector.enqueue(complete('tests passed'));
expect(sink.contextCalls).toEqual([]);
expect(injector.pendingCount).toBe(1);
injector.noteSpeechStopped();
expect(sink.contextCalls).toEqual(['tests passed']);
expect(injector.pendingCount).toBe(0);
});
it('holds items while a realtime response is in flight and delivers on done', () => {
injector.noteResponseCreated();
injector.enqueue(complete('build finished'));
expect(sink.contextCalls).toEqual([]);
expect(injector.pendingCount).toBe(1);
injector.noteResponseDone();
expect(sink.contextCalls).toEqual(['build finished']);
});
it('waits out the estimated playback plus the quiet gap', () => {
// 48,000 bytes of 24 kHz mono PCM16 ≈ 1,000 ms of audio.
injector.noteOutputAudio(48_000);
injector.enqueue(complete('done'));
expect(sink.contextCalls).toEqual([]);
// One tick before playback end + quiet gap: still closed.
vi.advanceTimersByTime(1_000 + QUIET_GAP_MS - 1);
expect(sink.contextCalls).toEqual([]);
vi.advanceTimersByTime(1);
expect(sink.contextCalls).toEqual(['done']);
});
it('stacks playback estimates for consecutive audio chunks', () => {
injector.noteOutputAudio(48_000);
injector.noteOutputAudio(48_000);
injector.enqueue(complete('done'));
vi.advanceTimersByTime(2_000 + QUIET_GAP_MS - 1);
expect(sink.contextCalls).toEqual([]);
vi.advanceTimersByTime(1);
expect(sink.contextCalls).toEqual(['done']);
});
it('reopens the window immediately when output audio is cleared', () => {
injector.noteOutputAudio(48_000 * 60);
injector.enqueue(complete('interrupted'));
vi.advanceTimersByTime(500);
expect(sink.contextCalls).toEqual([]);
injector.noteOutputCleared();
expect(sink.contextCalls).toEqual(['interrupted']);
});
});
describe('Injector batching', () => {
it('flushes a held batch as one context injection and one spoken line, in order', () => {
injector.noteResponseCreated();
injector.enqueue({
kind: 'complete',
context: 'job_1 finished',
spoken: 'Job one finished.',
jobHandle: 'job_1',
});
injector.enqueue({
kind: 'progress',
context: 'job_2 is halfway',
spoken: 'Job two is halfway.',
jobHandle: 'job_2',
});
injector.enqueue({
kind: 'permission',
context: 'job_3 wants to edit a file',
spoken: 'Job three needs permission.',
jobHandle: 'job_3',
requestId: 'req_1',
});
expect(injector.pendingCount).toBe(3);
injector.noteResponseDone();
expect(sink.contextCalls).toEqual([
// Permission asks are moved to the front of the batch so the
// size-capped context join can never truncate their handles.
'job_3 wants to edit a file\njob_1 finished\njob_2 is halfway',
]);
expect(sink.speechCalls).toEqual([
'Job three needs permission. Job one finished. Job two is halfway.',
]);
expect(sink.injected).toHaveLength(3);
expect(injector.pendingCount).toBe(0);
});
it('injects silently when no batch item carries a spoken line', () => {
injector.enqueue(complete('quiet update'));
expect(sink.contextCalls).toEqual(['quiet update']);
expect(sink.speechCalls).toEqual([]);
expect(sink.injected).toEqual([
{ item: complete('quiet update'), spoken: false },
]);
});
});
describe('Injector progress throttling', () => {
it('drops a second progress item for the same job inside the throttle window', () => {
injector.enqueue({ kind: 'progress', context: 'p1', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p1']);
vi.advanceTimersByTime(60_000);
injector.enqueue({ kind: 'progress', context: 'p2', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p1']);
expect(injector.pendingCount).toBe(0);
// Past the 5-minute throttle the same job may report again.
vi.advanceTimersByTime(5 * 60_000);
injector.enqueue({ kind: 'progress', context: 'p3', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p1', 'p3']);
});
it('throttles per job handle, not globally', () => {
injector.enqueue({ kind: 'progress', context: 'a1', jobHandle: 'job_a' });
vi.advanceTimersByTime(1_000);
injector.enqueue({ kind: 'progress', context: 'b1', jobHandle: 'job_b' });
expect(sink.contextCalls).toEqual(['a1', 'b1']);
});
it('keeps at most one queued progress item per job (newest wins)', () => {
makeInjector({ progressThrottleMs: 0 });
injector.noteResponseCreated();
injector.enqueue({
kind: 'progress',
context: 'stale',
jobHandle: 'job_1',
});
injector.enqueue({
kind: 'progress',
context: 'fresh',
jobHandle: 'job_1',
});
expect(injector.pendingCount).toBe(1);
injector.noteResponseDone();
expect(sink.contextCalls).toEqual(['fresh']);
});
it('keys jobless progress on the full context, not a shared prefix', () => {
injector.noteResponseCreated();
// Identical first 32 chars, diverging afterwards — the shape of two
// permission-retraction notices for one session.
const shared = '[BACKEND session_1] The permission request ';
injector.enqueue({ kind: 'progress', context: `${shared}(req_1) done.` });
injector.enqueue({ kind: 'progress', context: `${shared}(req_2) done.` });
expect(injector.pendingCount).toBe(2);
injector.noteResponseDone();
expect(sink.contextCalls).toEqual([
`${shared}(req_1) done.\n${shared}(req_2) done.`,
]);
});
it('dedups true jobless duplicates to a single queued item', () => {
makeInjector({ progressThrottleMs: 0 });
injector.noteResponseCreated();
injector.enqueue({ kind: 'progress', context: 'same jobless notice' });
injector.enqueue({ kind: 'progress', context: 'same jobless notice' });
expect(injector.pendingCount).toBe(1);
});
it('clears the throttle stamp when speech start drops queued progress', () => {
injector.noteResponseCreated();
injector.enqueue({ kind: 'progress', context: 'p1', jobHandle: 'job_1' });
expect(injector.pendingCount).toBe(1);
// Barge-in drops the undelivered progress; the throttle window must not
// survive it, or the job goes silent for the whole window.
injector.noteSpeechStarted();
expect(injector.pendingCount).toBe(0);
injector.noteSpeechStopped();
injector.noteResponseDone();
vi.advanceTimersByTime(1_000);
injector.enqueue({ kind: 'progress', context: 'p2', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p2']);
});
it('keeps the throttle stamp for progress that was actually delivered', () => {
injector.enqueue({ kind: 'progress', context: 'p1', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p1']);
injector.noteSpeechStarted();
injector.noteSpeechStopped();
vi.advanceTimersByTime(1_000);
injector.enqueue({ kind: 'progress', context: 'p2', jobHandle: 'job_1' });
expect(sink.contextCalls).toEqual(['p1']);
});
});
describe('Injector queue maintenance', () => {
it('drops queued progress on speech start but keeps conclusions and permission asks', () => {
injector.noteResponseCreated();
injector.enqueue(complete('finished'));
injector.enqueue({ kind: 'progress', context: 'halfway', jobHandle: 'j' });
injector.enqueue({
kind: 'permission',
context: 'needs approval',
requestId: 'req_1',
});
expect(injector.pendingCount).toBe(3);
injector.noteSpeechStarted();
expect(injector.pendingCount).toBe(2);
injector.noteResponseDone();
injector.noteSpeechStopped();
expect(sink.contextCalls).toEqual(['needs approval\nfinished']);
});
it('retracts a queued permission ask by request id', () => {
injector.noteResponseCreated();
injector.enqueue({
kind: 'permission',
context: 'ask one',
requestId: 'req_1',
});
injector.enqueue({
kind: 'permission',
context: 'ask two',
requestId: 'req_2',
});
expect(injector.retractPermission('req_1')).toBe(true);
expect(injector.pendingCount).toBe(1);
expect(injector.retractPermission('req_unknown')).toBe(false);
injector.noteResponseDone();
expect(sink.contextCalls).toEqual(['ask two']);
});
it('dispose clears the queue and nothing is delivered afterwards', () => {
injector.noteResponseCreated();
injector.enqueue(complete('stale one'));
injector.enqueue({
kind: 'permission',
context: 'stale ask',
requestId: 'req_1',
});
expect(injector.pendingCount).toBe(2);
injector.dispose();
expect(injector.pendingCount).toBe(0);
injector.noteResponseDone();
vi.advanceTimersByTime(60_000);
expect(sink.contextCalls).toEqual([]);
expect(sink.speechCalls).toEqual([]);
});
});
describe('Injector transport refusal', () => {
it('requeues the batch and retries after the quiet gap when the sink refuses', () => {
sink.contextResult = false;
sink.speechResult = false;
injector.enqueue(complete('important', 'Say this.'));
expect(sink.contextCalls).toEqual(['important']);
expect(sink.speechCalls).toEqual(['Say this.']);
expect(sink.injected).toEqual([]);
expect(injector.pendingCount).toBe(1);
sink.contextResult = true;
sink.speechResult = true;
vi.advanceTimersByTime(QUIET_GAP_MS);
expect(sink.contextCalls).toEqual(['important', 'important']);
expect(sink.speechCalls).toEqual(['Say this.', 'Say this.']);
expect(sink.injected).toHaveLength(1);
expect(injector.pendingCount).toBe(0);
});
it('counts a spoken-only acceptance as delivered', () => {
sink.contextResult = false;
sink.speechResult = true;
injector.enqueue(complete('body', 'Spoken line.'));
expect(injector.pendingCount).toBe(0);
expect(sink.injected).toHaveLength(1);
});
});
describe('Injector size caps', () => {
it('caps the combined context injection at 6000 chars', () => {
injector.noteSpeechStarted();
injector.enqueue(complete('a'.repeat(4_000)));
injector.enqueue(complete('b'.repeat(4_000)));
injector.noteSpeechStopped();
expect(sink.contextCalls).toHaveLength(1);
expect(sink.contextCalls[0]).toHaveLength(6_000);
// The first item survives whole; the second is what the cap trims.
expect(sink.contextCalls[0]!.startsWith('a'.repeat(4_000))).toBe(true);
});
it('joins spoken lines whole and stops before breaching 280 chars', () => {
injector.noteSpeechStarted();
injector.enqueue(complete('one', 'x'.repeat(200)));
injector.enqueue(complete('two', 'y'.repeat(200)));
injector.noteSpeechStopped();
expect(sink.speechCalls).toHaveLength(1);
// Whole-line greedy join: the second line would breach the cap, so
// only the first is spoken — never a mid-line cut of line two.
expect(sink.speechCalls[0]).toBe('x'.repeat(200));
});
it('truncates a single over-long spoken line with an ellipsis', () => {
injector.noteSpeechStarted();
injector.enqueue(complete('ctx', 'z'.repeat(400)));
injector.noteSpeechStopped();
expect(sink.speechCalls).toHaveLength(1);
expect(sink.speechCalls[0]).toBe(`${'z'.repeat(280)}…`);
});
});
@@ -0,0 +1,260 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Result backflow: takes normalized backend happenings and injects them into
* the realtime conversation at safe moments.
*
* Spoken/detail split: every item lands as silent context (the model can
* answer follow-ups from it), and speech-worthy items additionally trigger a
* short verbatim spoken line.
*
* The injection window is closed while any of these hold:
* 1. the user is speaking (VAD),
* 2. a realtime response is in flight,
* 3. output audio is still estimated to be playing.
* Protocol v6 has no playback acknowledgement from the Host, so (3) is a
* conservative estimate: bytes sent ÷ 48,000 B/s (24 kHz mono PCM16) plus a
* quiet gap. The v7 protocol upgrade replaces this with a real receipt.
*/
const QUIET_GAP_MS = 800;
const RECHECK_MIN_MS = 100;
const PROGRESS_THROTTLE_MS = 5 * 60_000;
const MAX_SPOKEN_CHARS = 280;
const MAX_CONTEXT_CHARS = 6_000;
/** 24 kHz mono PCM16. */
const PLAYBACK_BYTES_PER_MS = 48;
export type InjectorItemKind =
| 'complete'
| 'progress'
| 'permission'
| 'error'
| 'speak';
export interface InjectorItem {
kind: InjectorItemKind;
/** Silent context body (without prefix). */
context: string;
/** Verbatim spoken line; omitted items inject silently. */
spoken?: string;
jobHandle?: string;
/** For permission items: lets a remote resolution retract the ask. */
requestId?: string;
}
export interface InjectorSink {
/** Silent context injection; false when the transport refused. */
injectContext(text: string): boolean;
/** Verbatim speech request; false when the transport refused. */
injectSpeech(text: string): boolean;
onInjected?(item: InjectorItem, spoken: boolean): void;
}
export interface InjectorOptions {
sink: InjectorSink;
now?: () => number;
quietGapMs?: number;
progressThrottleMs?: number;
}
/**
* Progress-throttle key: the job handle when the item has one, otherwise the
* item's full context (the map is per-call and bounded in practice).
*/
function progressKeyOf(item: InjectorItem): string {
return item.jobHandle ?? `ctx:${item.context}`;
}
export class Injector {
private readonly sink: InjectorSink;
private readonly now: () => number;
private readonly quietGapMs: number;
private readonly progressThrottleMs: number;
private queue: InjectorItem[] = [];
private speechInProgress = false;
private responseInFlight = false;
private playbackDeadline = 0;
private lastProgressAt = new Map<string, number>();
private timer: ReturnType<typeof setTimeout> | undefined;
private disposed = false;
constructor(options: InjectorOptions) {
this.sink = options.sink;
this.now = options.now ?? Date.now;
this.quietGapMs = options.quietGapMs ?? QUIET_GAP_MS;
this.progressThrottleMs =
options.progressThrottleMs ?? PROGRESS_THROTTLE_MS;
}
// -- window state signals (fed by the orchestrator) ----------------------
noteSpeechStarted(): void {
this.speechInProgress = true;
// Barge-in semantics: pending progress is stale the moment the user
// speaks; conclusions and permission asks stay queued. A dropped item
// was never delivered, so its throttle stamp must not stand — the job's
// next progress report may come well within the window.
const kept: InjectorItem[] = [];
for (const item of this.queue) {
if (item.kind === 'progress') {
this.lastProgressAt.delete(progressKeyOf(item));
} else {
kept.push(item);
}
}
this.queue = kept;
}
noteSpeechStopped(): void {
this.speechInProgress = false;
this.poke();
}
noteResponseCreated(): void {
this.responseInFlight = true;
}
noteResponseDone(): void {
this.responseInFlight = false;
this.poke();
}
noteOutputAudio(bytes: number): void {
const start = Math.max(this.now(), this.playbackDeadline);
this.playbackDeadline = start + bytes / PLAYBACK_BYTES_PER_MS;
}
noteOutputCleared(): void {
this.playbackDeadline = 0;
this.poke();
}
// -- queue --------------------------------------------------------------
enqueue(item: InjectorItem): void {
if (this.disposed) return;
if (item.kind === 'progress') {
// Throttle per job; jobless progress is keyed on its full context so
// distinct notices (which may share a long common prefix) never
// collide on one throttle window.
const key = progressKeyOf(item);
const last = this.lastProgressAt.get(key) ?? 0;
if (this.now() - last < this.progressThrottleMs) return;
this.lastProgressAt.set(key, this.now());
// At most one queued progress item per key.
this.queue = this.queue.filter(
(queued) =>
!(queued.kind === 'progress' && progressKeyOf(queued) === key),
);
}
this.queue.push(item);
this.poke();
}
/** Retract a queued permission ask that was resolved elsewhere. */
retractPermission(requestId: string): boolean {
const before = this.queue.length;
this.queue = this.queue.filter(
(item) => !(item.kind === 'permission' && item.requestId === requestId),
);
return this.queue.length !== before;
}
get pendingCount(): number {
return this.queue.length;
}
dispose(): void {
this.disposed = true;
this.queue = [];
if (this.timer !== undefined) clearTimeout(this.timer);
this.timer = undefined;
}
// -- delivery -----------------------------------------------------------
private windowClosedForMs(): number {
if (this.speechInProgress || this.responseInFlight) return -1;
const quietAt = this.playbackDeadline + this.quietGapMs;
const wait = quietAt - this.now();
return wait > 0 ? wait : 0;
}
private poke(): void {
if (this.disposed || this.queue.length === 0) return;
const wait = this.windowClosedForMs();
if (wait < 0) return; // reopened by a state signal later
if (wait === 0) {
this.flush();
return;
}
if (this.timer !== undefined) clearTimeout(this.timer);
this.timer = setTimeout(
() => {
this.timer = undefined;
this.poke();
},
Math.max(wait, RECHECK_MIN_MS),
);
this.timer.unref?.();
}
private flush(): void {
if (this.queue.length === 0) return;
// Permission asks first: the context join is size-capped, and a
// truncated [PERMISSION] entry would lose the handle the model needs
// for respond_permission.
const batch = [
...this.queue.filter((item) => item.kind === 'permission'),
...this.queue.filter((item) => item.kind !== 'permission'),
];
this.queue = [];
// One combined silent context injection for the whole batch.
const context = batch
.map((item) => item.context)
.join('\n')
.slice(0, MAX_CONTEXT_CHARS);
const contextAccepted = this.sink.injectContext(context);
// One combined spoken line for the speech-worthy items — whole lines
// only, since the model is told to read the text verbatim.
const spokenLines = batch
.map((item) => item.spoken)
.filter((line): line is string => typeof line === 'string' && !!line);
let spoken = '';
for (const line of spokenLines) {
const candidate = spoken ? `${spoken} ${line}` : line;
if (candidate.length > MAX_SPOKEN_CHARS && spoken) break;
spoken =
candidate.length > MAX_SPOKEN_CHARS
? `${candidate.slice(0, MAX_SPOKEN_CHARS)}…`
: candidate;
}
let spokenAccepted = false;
if (spoken) {
spokenAccepted = this.sink.injectSpeech(spoken);
}
if (!contextAccepted && !spokenAccepted) {
// Transport refused (socket busy/closed): requeue and retry shortly.
this.queue = [...batch, ...this.queue];
if (this.timer !== undefined) clearTimeout(this.timer);
this.timer = setTimeout(() => {
this.timer = undefined;
this.poke();
}, this.quietGapMs);
this.timer.unref?.();
return;
}
for (const item of batch) {
this.sink.onInjected?.(item, spokenLines.length > 0);
}
}
}
@@ -0,0 +1,952 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { mkdtemp, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type {
BackendAdaptor,
BackendCapabilities,
BackendEvent,
BackendHandle,
ContentBlock,
PermissionDecision,
PermissionOption,
PromptReceipt,
SessionSummary,
} from '../adaptor/types.js';
import type { LiveScreenContextCapture } from '../host/live-host-coordinator.js';
import type { LiveState } from '../host/types.js';
import type { SessionLog } from '../log/session-log.js';
import type {
openQwenRealtimeSession,
QwenRealtimeCallbacks,
QwenRealtimeConfig,
QwenRealtimeSession,
RealtimeCloseInfo,
RealtimeCloseOptions,
RealtimeFunctionCallRef,
RealtimeTranscriptEntry,
} from '../realtime/realtime-session.js';
import { LIVE_SESSION_TOOLS } from '../tools/definitions.js';
import { LiveSession } from './live-session.js';
const PERMISSION_OPTIONS: readonly PermissionOption[] = [
{ optionId: 'allow', kind: 'proceed' },
{ optionId: 'deny', kind: 'reject' },
];
const delay = (ms: number): Promise<void> =>
new Promise((resolve) => {
setTimeout(resolve, ms);
});
// -- test doubles -----------------------------------------------------------
/** Minimal push-driven async queue backing FakeAdaptor.events(). */
class AsyncQueue<T> implements AsyncIterable<T> {
private readonly buffered: T[] = [];
private readonly waiters: Array<(result: IteratorResult<T>) => void> = [];
private ended = false;
push(item: T): void {
const waiter = this.waiters.shift();
if (waiter) {
waiter({ value: item, done: false });
return;
}
this.buffered.push(item);
}
end(): void {
this.ended = true;
for (const waiter of this.waiters.splice(0)) {
waiter({ value: undefined as never, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
if (this.buffered.length > 0) {
const value = this.buffered.shift() as T;
return Promise.resolve({ value, done: false });
}
if (this.ended) {
return Promise.resolve({ value: undefined as never, done: true });
}
return new Promise((resolve) => {
this.waiters.push(resolve);
});
},
};
}
}
class FakeAdaptor implements BackendAdaptor {
readonly name = 'fake';
busy = false;
promptReceipt: PromptReceipt = { status: 'accepted', jobRef: 'p1' };
summaries: SessionSummary[] = [];
readonly queues = new Map<string, AsyncQueue<BackendEvent>>();
private sessionSeq = 0;
readonly createSession = vi.fn(
async (_opts?: {
cwd?: string;
label?: string;
}): Promise<BackendHandle> => ({
id: `s${++this.sessionSeq}`,
adaptor: this.name,
}),
);
readonly prompt = vi.fn(
async (
_handle: BackendHandle,
_blocks: readonly ContentBlock[],
_opts?: { steer?: boolean },
): Promise<PromptReceipt> => this.promptReceipt,
);
readonly cancel = vi.fn(async (_handle: BackendHandle): Promise<void> => {});
readonly respondPermission = vi.fn(
async (
_handle: BackendHandle,
_requestId: string,
_decision: PermissionDecision,
): Promise<'delivered' | 'already_resolved'> => 'delivered',
);
capabilities(): BackendCapabilities {
return {
steering: 'native',
imageInput: true,
permissionForwarding: true,
proactiveSpeak: true,
sessionList: true,
eventDelivery: 'stream',
};
}
async preflight(): Promise<void> {}
async listSessions(): Promise<SessionSummary[]> {
return this.summaries;
}
events(handle: BackendHandle): AsyncIterable<BackendEvent> {
return this.queue(handle.id);
}
isBusy(_handle: BackendHandle): boolean {
return this.busy;
}
async close(): Promise<void> {}
queue(backendId: string): AsyncQueue<BackendEvent> {
let queue = this.queues.get(backendId);
if (!queue) {
queue = new AsyncQueue<BackendEvent>();
this.queues.set(backendId, queue);
}
return queue;
}
}
/**
* FakeAdaptor whose events() hands out one stream per subscription: the
* first can be ended (without session_closed) to simulate a dropped SSE
* stream; the pump must resubscribe and land on the second.
*/
class ResubscribeAdaptor extends FakeAdaptor {
readonly streams = [
new AsyncQueue<BackendEvent>(),
new AsyncQueue<BackendEvent>(),
];
eventsCalls = 0;
override events(): AsyncIterable<BackendEvent> {
const stream = this.streams[
Math.min(this.eventsCalls, this.streams.length - 1)
] as AsyncQueue<BackendEvent>;
this.eventsCalls += 1;
return stream;
}
}
function createFakeHost(capture: LiveScreenContextCapture) {
const states: Array<Exclude<LiveState, 'unavailable' | 'idle'>> = [];
return {
states,
setCallState: vi.fn(
(
_epoch: number,
state: Exclude<LiveState, 'unavailable' | 'idle'>,
): boolean => {
states.push(state);
return true;
},
),
setCoordinator: vi.fn(
(
_epoch: number,
_locator: { workspaceCwd: string; sessionId: string },
): boolean => true,
),
sendOutputAudio: vi.fn(
(_epoch: number, _pcm16: Uint8Array): boolean => true,
),
clearOutput: vi.fn((_epoch: number): void => {}),
setCaption: vi.fn((_epoch: number, _caption: string): boolean => true),
setStatusText: vi.fn(
(_epoch: number, _statusText?: string): boolean => true,
),
setTranscript: vi.fn(
(_epoch: number, _transcript: string): boolean => true,
),
failCall: vi.fn((_epoch: number, _message?: string): boolean => true),
captureScreenContext: vi.fn(
async (_callerSessionId: string): Promise<LiveScreenContextCapture> =>
capture,
),
};
}
function createFakeRealtime() {
return {
callEpoch: 1,
closed: new Promise<RealtimeCloseInfo>(() => {}),
pushAudio: vi.fn((_pcm16: Uint8Array): boolean => true),
commitInputAudio: vi.fn((): boolean => true),
clearInputAudio: vi.fn((): boolean => true),
cancelResponse: vi.fn((): boolean => true),
submitFunctionOutput: vi.fn(
(_ref: RealtimeFunctionCallRef, _output: string): boolean => true,
),
sendBackendContext: vi.fn((_text: string): boolean => true),
speakToUser: vi.fn((_message: string): boolean => true),
takeTranscriptTail: vi.fn((): readonly RealtimeTranscriptEntry[] => []),
close: vi.fn((_options?: RealtimeCloseOptions): void => {}),
};
}
type FakeRealtime = ReturnType<typeof createFakeRealtime>;
// -- rig ---------------------------------------------------------------------
let tempDir: string;
let pngPath: string;
beforeAll(async () => {
tempDir = await mkdtemp(join(tmpdir(), 'qwen-live-session-test-'));
pngPath = join(tempDir, 'shot.png');
// A real (if tiny) PNG signature so image blocks carry non-empty bytes.
await writeFile(
pngPath,
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 1, 2, 3, 4]),
);
});
afterAll(async () => {
await rm(tempDir, { recursive: true, force: true });
});
interface Rig {
session: LiveSession;
adaptor: FakeAdaptor;
logWrites: ReturnType<typeof vi.fn>;
host: ReturnType<typeof createFakeHost>;
realtime: FakeRealtime;
config: QwenRealtimeConfig;
callbacks: QwenRealtimeCallbacks;
}
async function startSession(adaptorArg?: FakeAdaptor): Promise<Rig> {
const adaptor = adaptorArg ?? new FakeAdaptor();
const host = createFakeHost({
appName: 'Safari',
windowTitle: 'Docs',
accessibilityText: 'visible text',
screenshotPath: pngPath,
});
const realtime = createFakeRealtime();
const logWrites = vi.fn();
let config: QwenRealtimeConfig | undefined;
let callbacks: QwenRealtimeCallbacks = {};
const openRealtime: typeof openQwenRealtimeSession = (cfg, cbs = {}) => {
config = cfg;
callbacks = cbs;
return Promise.resolve(realtime as unknown as QwenRealtimeSession);
};
const session = new LiveSession({
host,
adaptor,
realtime: {
endpoint: 'https://dashscope.example.com',
model: 'qwen-omni-turbo-realtime',
voice: 'Cherry',
},
log: { write: logWrites, close: async () => {} } as unknown as SessionLog,
openRealtime,
});
await session.start({ epoch: 1, callId: 'call-1', mode: 'new' });
if (!config) throw new Error('openRealtime was not called');
return {
session,
adaptor,
host,
realtime,
config,
callbacks,
logWrites,
};
}
let callSeq = 0;
function callTool(
callbacks: QwenRealtimeCallbacks,
name: string,
args: Record<string, unknown>,
activeTranscript: readonly RealtimeTranscriptEntry[] = [],
): void {
callSeq += 1;
callbacks.onFunctionCall?.({
callEpoch: 1,
responseId: `resp_${callSeq}`,
callId: `fc_${callSeq}`,
name,
arguments: JSON.stringify(args),
activeTranscript,
});
}
function receipts(realtime: FakeRealtime): Array<Record<string, unknown>> {
return realtime.submitFunctionOutput.mock.calls.map(
([, output]) => JSON.parse(output) as Record<string, unknown>,
);
}
async function awaitReceipts(
realtime: FakeRealtime,
count: number,
): Promise<Array<Record<string, unknown>>> {
await vi.waitFor(() => {
expect(realtime.submitFunctionOutput).toHaveBeenCalledTimes(count);
});
return receipts(realtime);
}
// -- tests --------------------------------------------------------------------
describe('LiveSession', () => {
it('start opens the realtime session with the live tool surface and walks starting → listening', async () => {
const { config, host } = await startSession();
expect(config.tools).toBe(LIVE_SESSION_TOOLS);
expect(config.instructions.length).toBeGreaterThan(0);
expect(host.states).toEqual(['starting', 'listening']);
});
it('handoff creates a default session and prompts with the task plus voice context', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'fix tests' }, [
{ role: 'user', text: 'please fix the failing tests' },
]);
const [receipt] = await awaitReceipts(realtime, 1);
expect(adaptor.createSession).toHaveBeenCalledTimes(1);
expect(adaptor.prompt).toHaveBeenCalledTimes(1);
const blocks = adaptor.prompt.mock.calls[0]?.[1];
expect(blocks).toBeDefined();
const text = blocks?.[0];
if (text?.type !== 'text') throw new Error('expected a leading text block');
expect(text.text).toContain('fix tests');
expect(text.text).toContain('<recent_voice_context>');
expect(text.text).toContain('please fix the failing tests');
expect(receipt).toMatchObject({
status: 'accepted',
job: 'job_1',
session: 'session_1',
});
});
it('handoff to a busy session steers instead of prompting fresh', async () => {
const { adaptor, callbacks, realtime } = await startSession();
adaptor.busy = true;
callTool(callbacks, 'handoff', { task: 'also run lint' });
await awaitReceipts(realtime, 1);
expect(adaptor.prompt.mock.calls[0]?.[2]).toEqual({ steer: true });
});
it('a steer that joined the running turn reuses that job instead of orphaning it', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'run the tests' });
const [first] = await awaitReceipts(realtime, 1);
expect(first).toMatchObject({ job: 'job_1', session: 'session_1' });
adaptor.queue('s1').push({ type: 'turn_started', jobRef: 'p1' });
// The backend joins the steer to the running turn: same jobRef back.
adaptor.busy = true;
adaptor.promptReceipt = {
status: 'accepted',
jobRef: 'p1',
joinedActiveTurn: true,
note: 'joined the currently running task',
};
callTool(callbacks, 'handoff', { task: 'also run lint' });
const [, second] = await awaitReceipts(realtime, 2);
expect(second).toMatchObject({ job: 'job_1', session: 'session_1' });
// One job only — and the turn's completion retires it.
adaptor.queue('s1').push({
type: 'turn_complete',
jobRef: 'p1',
summary: 'all done.',
});
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
});
adaptor.busy = false;
callTool(callbacks, 'session_monitor', { session: 'session_1' });
const [, , monitor] = await awaitReceipts(realtime, 3);
expect(monitor).toEqual({
status: 'ok',
session: 'session_1',
state: 'idle',
});
});
it('handoff attaches appshot-registered assets as image blocks', async () => {
const { adaptor, callbacks, host, realtime } = await startSession();
callTool(callbacks, 'appshot', {});
const [appshotReceipt] = await awaitReceipts(realtime, 1);
expect(host.captureScreenContext).toHaveBeenCalledTimes(1);
expect(appshotReceipt).toMatchObject({
status: 'ok',
app: 'Safari',
window: 'Docs',
asset: 'asset_1',
});
callTool(callbacks, 'handoff', {
task: 'describe this window',
input_refs: ['asset_1'],
});
await awaitReceipts(realtime, 2);
const blocks = adaptor.prompt.mock.calls[0]?.[1];
expect(blocks).toHaveLength(2);
const image = blocks?.[1];
if (image?.type !== 'image') throw new Error('expected an image block');
expect(image.mimeType).toBe('image/png');
expect(image.data.byteLength).toBeGreaterThan(0);
});
it('session_list returns handles and states for backend sessions', async () => {
const { adaptor, callbacks, realtime } = await startSession();
adaptor.summaries = [
{
handle: { id: 'a', adaptor: 'fake' },
label: 'One',
cwd: '/tmp/a',
state: 'idle',
},
{ handle: { id: 'b', adaptor: 'fake' }, state: 'busy' },
];
callTool(callbacks, 'session_list', {});
const [receipt] = await awaitReceipts(realtime, 1);
expect(receipt).toMatchObject({ status: 'ok' });
expect(receipt?.['sessions']).toEqual([
{ handle: 'session_1', label: 'One', cwd: '/tmp/a', state: 'idle' },
{ handle: 'session_2', state: 'busy' },
]);
});
it('session_stop cancels the backend turn and marks the job cancelled', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'long task' });
await awaitReceipts(realtime, 1);
callTool(callbacks, 'session_stop', { job: 'job_1' });
const [, stopReceipt] = await awaitReceipts(realtime, 2);
expect(adaptor.cancel).toHaveBeenCalledTimes(1);
expect(stopReceipt).toMatchObject({
status: 'cancelling',
session: 'session_1',
});
callTool(callbacks, 'session_monitor', { job: 'job_1' });
const [, , monitorReceipt] = await awaitReceipts(realtime, 3);
expect(monitorReceipt).toMatchObject({
job: 'job_1',
job_state: 'cancelled',
});
});
it('injects turn_complete and turn_error events as context plus speech', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'run the tests' });
await awaitReceipts(realtime, 1);
const queue = adaptor.queue('s1');
queue.push({
type: 'turn_complete',
jobRef: 'p1',
summary: 'done: all tests pass',
});
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
});
expect(realtime.sendBackendContext.mock.calls[0]?.[0]).toMatch(
/^\[COMPLETE job_1\]/,
);
expect(realtime.speakToUser).toHaveBeenCalledTimes(1);
expect(realtime.speakToUser.mock.calls[0]?.[0]).toContain('job_1');
queue.push({ type: 'turn_error', jobRef: 'p1', error: 'lint exploded' });
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(2);
});
expect(realtime.sendBackendContext.mock.calls[1]?.[0]).toMatch(
/^\[ERROR job_1\]/,
);
});
it('routes permission requests to the voice and relays the answer back', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'clean tmp' });
await awaitReceipts(realtime, 1);
const queue = adaptor.queue('s1');
queue.push({
type: 'permission_request',
requestId: 'r1',
title: 'Bash: rm -rf /tmp',
options: PERMISSION_OPTIONS,
});
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
});
expect(realtime.sendBackendContext.mock.calls[0]?.[0]).toContain(
'[PERMISSION req_1]',
);
expect(realtime.speakToUser).toHaveBeenCalledTimes(1);
callTool(callbacks, 'respond_permission', {
request_id: 'req_1',
decision: 'allow',
});
const [, respondReceipt] = await awaitReceipts(realtime, 2);
expect(adaptor.respondPermission).toHaveBeenCalledTimes(1);
const [, requestId, decision] =
adaptor.respondPermission.mock.calls[0] ?? [];
expect(requestId).toBe('r1');
expect(decision).toBe('allow');
expect(respondReceipt).toEqual({ status: 'delivered' });
});
it('relays a respond_permission note to the backend session after the vote', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'clean tmp' });
await awaitReceipts(realtime, 1);
adaptor.queue('s1').push({
type: 'permission_request',
requestId: 'r1',
title: 'Bash: rm -rf /tmp',
options: PERMISSION_OPTIONS,
});
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
});
adaptor.busy = true;
callTool(callbacks, 'respond_permission', {
request_id: 'req_1',
decision: 'allow',
note: 'only the cache subfolder',
});
const [, respondReceipt] = await awaitReceipts(realtime, 2);
expect(respondReceipt).toEqual({ status: 'delivered' });
expect(adaptor.respondPermission).toHaveBeenCalledWith(
expect.objectContaining({ id: 's1' }),
'r1',
'allow',
);
// The vote channel has no note field: the constraint rides the prompt
// path to the same session, steered into the running turn.
expect(adaptor.prompt).toHaveBeenCalledTimes(2);
const [handle, blocks, opts] = adaptor.prompt.mock.calls[1] ?? [];
expect(handle).toEqual(expect.objectContaining({ id: 's1' }));
const text = blocks?.[0];
if (text?.type !== 'text') throw new Error('expected a text block');
expect(text.text).toContain('only the cache subfolder');
expect(text.text).toContain('Bash: rm -rf /tmp');
expect(opts).toEqual({ steer: true });
});
it('delivers a note-less respond_permission without a follow-up prompt', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'clean tmp' });
await awaitReceipts(realtime, 1);
adaptor.queue('s1').push({
type: 'permission_request',
requestId: 'r1',
title: 'Bash: rm -rf /tmp',
options: PERMISSION_OPTIONS,
});
await vi.waitFor(() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
});
callTool(callbacks, 'respond_permission', {
request_id: 'req_1',
decision: 'deny',
});
const [, respondReceipt] = await awaitReceipts(realtime, 2);
expect(respondReceipt).toEqual({ status: 'delivered' });
// Only the handoff prompt — no constraint relay was needed.
expect(adaptor.prompt).toHaveBeenCalledTimes(1);
});
it('retracts a queued permission ask resolved elsewhere before injection', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'clean tmp' });
await awaitReceipts(realtime, 1);
const queue = adaptor.queue('s1');
// Close the injection window: a realtime response is in flight.
callbacks.onResponseCreated?.({
callEpoch: 1,
responseId: 'resp_open',
authority: 'direct',
});
queue.push({
type: 'permission_request',
requestId: 'r2',
title: 'Bash: rm -rf /tmp',
options: PERMISSION_OPTIONS,
});
await delay(30);
expect(realtime.sendBackendContext).not.toHaveBeenCalled();
// Resolved from the WebShell before the voice ever asked.
queue.push({ type: 'permission_resolved', requestId: 'r2', byUs: false });
await delay(30);
// Reopen the window: the retracted ask must not surface.
callbacks.onResponseDone?.({ callEpoch: 1, responseId: 'resp_open' });
await delay(50);
expect(realtime.sendBackendContext).not.toHaveBeenCalled();
expect(realtime.speakToUser).not.toHaveBeenCalled();
});
it('barge-in clears the host output', async () => {
const { callbacks, host } = await startSession();
callbacks.onBargeIn?.({ callEpoch: 1, responseId: 'resp_x' });
expect(host.clearOutput).toHaveBeenCalledTimes(1);
});
it('stop resolves immediately when no response is in flight', async () => {
const { realtime, session } = await startSession();
const outcome = await session.stop({ epoch: 1, callId: 'call-1' });
expect(outcome).toBeUndefined();
expect(realtime.close).toHaveBeenCalledTimes(1);
});
it('stop waits for the in-flight response to settle', async () => {
const { callbacks, realtime, session } = await startSession();
callbacks.onResponseCreated?.({
callEpoch: 1,
responseId: 'resp_1',
authority: 'direct',
});
let settled = false;
const pending = session
.stop({ epoch: 1, callId: 'call-1' })
.then((outcome) => {
settled = true;
return outcome;
});
await delay(150);
expect(settled).toBe(false);
callbacks.onResponseDone?.({ callEpoch: 1, responseId: 'resp_1' });
await vi.waitFor(
() => {
expect(settled).toBe(true);
},
{ timeout: 3_000, interval: 100 },
);
expect(await pending).toBeUndefined();
expect(realtime.close).toHaveBeenCalledTimes(1);
});
it('stop drain settles on the input-commit ack instead of burning the budget', async () => {
// Default drain budget: 30 s. The commit ack must settle the stop in
// milliseconds — resolving only at the deadline is the bug.
const { callbacks, realtime, session } = await startSession();
callbacks.onSpeechStarted?.({ callEpoch: 1 });
let settled = false;
const pending = session
.stop({ epoch: 1, callId: 'call-1' })
.then((outcome) => {
settled = true;
return outcome;
});
expect(realtime.commitInputAudio).toHaveBeenCalledTimes(1);
await delay(150);
expect(settled).toBe(false);
callbacks.onInputCommitted?.({ callEpoch: 1 });
await vi.waitFor(
() => {
expect(settled).toBe(true);
},
{ timeout: 2_000, interval: 50 },
);
expect(await pending).toBeUndefined();
expect(realtime.close).toHaveBeenCalledTimes(1);
});
it('stop fails fast when the trailing speech cannot be committed', async () => {
const { callbacks, realtime, session } = await startSession();
realtime.commitInputAudio.mockReturnValue(false);
callbacks.onSpeechStarted?.({ callEpoch: 1 });
const outcome = await session.stop({ epoch: 1, callId: 'call-1' });
expect(outcome).toEqual({
error: 'Live Voice could not commit the final spoken input.',
});
});
it('keeps the call state at stopping when response.created arrives during the drain', async () => {
const { callbacks, host, session } = await startSession();
callbacks.onSpeechStarted?.({ callEpoch: 1 });
let settled = false;
const pending = session
.stop({ epoch: 1, callId: 'call-1' })
.then((outcome) => {
settled = true;
return outcome;
});
callbacks.onInputCommitted?.({ callEpoch: 1 });
// semantic_vad create_response: the committed trailing speech spawns a
// response mid-drain. It must hold the drain open, but never flip the
// coordinator back to 'speaking' — that would strand the stopping call.
callbacks.onResponseCreated?.({
callEpoch: 1,
responseId: 'resp_tail',
authority: 'direct',
});
expect(host.states[host.states.length - 1]).toBe('stopping');
await delay(150);
expect(settled).toBe(false);
callbacks.onResponseDone?.({ callEpoch: 1, responseId: 'resp_tail' });
await vi.waitFor(
() => {
expect(settled).toBe(true);
},
{ timeout: 2_000, interval: 50 },
);
expect(await pending).toBeUndefined();
expect(host.states).not.toContain('speaking');
});
it('speaks error strings with mid-token periods untruncated', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'read config' });
await awaitReceipts(realtime, 1);
const queue = adaptor.queue('s1');
queue.push({
type: 'turn_error',
jobRef: 'p1',
error: 'ENOENT: open /home/user/.qwen-live/config.json',
});
await vi.waitFor(() => {
expect(realtime.speakToUser).toHaveBeenCalledTimes(1);
});
expect(realtime.speakToUser.mock.calls[0]?.[0]).toContain(
'/home/user/.qwen-live/config.json',
);
queue.push({
type: 'turn_error',
jobRef: 'p1',
error: 'Connection refused: 10.0.0.1:4170',
});
await vi.waitFor(() => {
expect(realtime.speakToUser).toHaveBeenCalledTimes(2);
});
expect(realtime.speakToUser.mock.calls[1]?.[0]).toContain('10.0.0.1:4170');
});
it('speaks the closing sentence of a long CJK summary', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: '跑测试' });
await awaitReceipts(realtime, 1);
// >200 chars, multiple sentences, no whitespace after 。 (standard CJK
// typography): the spoken line must be the LAST sentence, complete.
const body = `${'任务进行中'.repeat(50)}。`;
const closing = '所有测试都通过了。';
adaptor.queue('s1').push({
type: 'turn_complete',
jobRef: 'p1',
summary: `${body}${closing}`,
});
await vi.waitFor(() => {
expect(realtime.speakToUser).toHaveBeenCalledTimes(1);
});
expect(realtime.speakToUser.mock.calls[0]?.[0]).toBe(
`Task job_1 finished. ${closing}`,
);
});
it('resubscribes after the event stream ends without session_closed', async () => {
const adaptor = new ResubscribeAdaptor();
const { callbacks, realtime } = await startSession(adaptor);
callTool(callbacks, 'handoff', { task: 'long task' });
await awaitReceipts(realtime, 1);
expect(adaptor.eventsCalls).toBe(1);
// The stream drops without a session_closed (daemon restart, broken
// SSE connection) — the session must not go permanently unobserved.
adaptor.streams[0]?.end();
adaptor.streams[1]?.push({
type: 'turn_complete',
jobRef: 'p1',
summary: 'all tests pass.',
});
await vi.waitFor(
() => {
expect(realtime.sendBackendContext).toHaveBeenCalledTimes(1);
},
{ timeout: 5_000, interval: 100 },
);
expect(realtime.sendBackendContext.mock.calls[0]?.[0]).toMatch(
/^\[COMPLETE job_1\]/,
);
expect(adaptor.eventsCalls).toBe(2);
}, 10_000);
it('pushAudio forwards frames to realtime but not while stopping', async () => {
const { callbacks, realtime, session } = await startSession();
const frame = Buffer.from([1, 2]);
expect(
session.pushAudio({ epoch: 1, callId: 'call-1', pcm16: frame }),
).toBe(true);
expect(realtime.pushAudio).toHaveBeenCalledTimes(1);
expect(realtime.pushAudio).toHaveBeenCalledWith(frame);
callbacks.onResponseCreated?.({
callEpoch: 1,
responseId: 'resp_1',
authority: 'direct',
});
const stopPending = session.stop({ epoch: 1, callId: 'call-1' });
expect(
session.pushAudio({ epoch: 1, callId: 'call-1', pcm16: frame }),
).toBe(true);
expect(realtime.pushAudio).toHaveBeenCalledTimes(1);
callbacks.onResponseDone?.({ callEpoch: 1, responseId: 'resp_1' });
await stopPending;
});
it('rebuilds the default session after it closes mid-call', async () => {
const { adaptor, callbacks, realtime, logWrites } = await startSession();
// First handoff creates and uses the default session…
callTool(callbacks, 'handoff', { task: 'first task' });
await awaitReceipts(realtime, 1);
const firstCreateCalls = adaptor.createSession.mock.calls.length;
// …then that session closes (WebShell deletion / daemon restart).
// The pump consumes the event asynchronously; wait until the closure
// has been observed before handing off again.
adaptor.queue('s1').push({ type: 'session_closed' });
adaptor.queue('s1').end();
await vi.waitFor(() => {
expect(logWrites).toHaveBeenCalledWith(
'backend.event',
expect.objectContaining({ type: 'session_closed' }),
);
});
// A session-less handoff must NOT re-target the dead session.
callTool(callbacks, 'handoff', { task: 'second task' });
await awaitReceipts(realtime, 2);
expect(adaptor.createSession.mock.calls.length).toBeGreaterThan(
firstCreateCalls,
);
const targets = adaptor.prompt.mock.calls.map(
(call) => (call[0] as { id: string }).id,
);
expect(new Set(targets).size).toBe(targets.length);
});
it('reconciles stale running jobs when the backend reports idle', async () => {
const { adaptor, callbacks, realtime } = await startSession();
callTool(callbacks, 'handoff', { task: 'a task' });
await awaitReceipts(realtime, 1);
// The turn_complete is emitted while no one consumes it (pump aborted
// between calls); mark the adaptor idle and list sessions.
adaptor.busy = false;
adaptor.summaries = [
{ handle: { id: 's1', adaptor: 'fake' }, state: 'idle' },
];
callTool(callbacks, 'session_list', {});
await awaitReceipts(realtime, 2);
const list = receipts(realtime)[1];
// The stale running job must not survive the idle reconciliation.
expect(list?.['sessions']).toEqual([
{ handle: 'session_1', state: 'idle' },
]);
});
});
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,378 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it, vi } from 'vitest';
import type {
BackendAdaptor,
BackendHandle,
PermissionOption,
} from '../adaptor/types.js';
import { PermissionBroker } from './permission-broker.js';
const BACKEND: BackendHandle = { id: 's1', adaptor: 'fake' };
const OPTIONS: readonly PermissionOption[] = [
{ optionId: 'allow', kind: 'proceed' },
{ optionId: 'deny', kind: 'reject' },
];
function createAdaptor() {
return {
respondPermission: vi.fn(
async (): Promise<'delivered' | 'already_resolved'> => 'delivered',
),
};
}
type FakeAdaptor = ReturnType<typeof createAdaptor>;
interface Rig {
adaptor: FakeAdaptor;
broker: PermissionBroker;
clock: { now: number };
logEvents: Array<{ type: string; payload: Record<string, unknown> }>;
}
function createBroker(options?: { ruleTtlMs?: number }): Rig {
const adaptor = createAdaptor();
const clock = { now: 1_000_000 };
const logEvents: Rig['logEvents'] = [];
const broker = new PermissionBroker({
adaptor: adaptor as unknown as BackendAdaptor,
now: () => clock.now,
...(options?.ruleTtlMs !== undefined
? { ruleTtlMs: options.ruleTtlMs }
: {}),
log: (type, payload) => {
logEvents.push({ type, payload });
},
});
return { adaptor, broker, clock, logEvents };
}
function request(
broker: PermissionBroker,
fields?: { requestId?: string; sessionHandle?: string; title?: string },
) {
return broker.onRequest({
requestId: fields?.requestId ?? 'r1',
backend: BACKEND,
sessionHandle: fields?.sessionHandle ?? 'session_1',
title: fields?.title ?? 'Bash: rm -rf /a',
options: OPTIONS,
});
}
describe('PermissionBroker', () => {
it('records pending asks with incrementing handles when no rule matches', async () => {
const { adaptor, broker } = createBroker();
const first = await request(broker, { requestId: 'r1' });
expect(first.autoAnswered).toBe(false);
expect(first.pending.requestHandle).toBe('req_1');
expect(first.pending.requestId).toBe('r1');
expect(first.pending.sessionHandle).toBe('session_1');
const second = await request(broker, { requestId: 'r2' });
expect(second.autoAnswered).toBe(false);
expect(second.pending.requestHandle).toBe('req_2');
expect(broker.pendingCount).toBe(2);
expect(adaptor.respondPermission).not.toHaveBeenCalled();
});
it('delivers an allow vote and clears the pending ask', async () => {
const { adaptor, broker } = createBroker();
await request(broker, { requestId: 'r1' });
const outcome = await broker.respond('req_1', 'allow');
expect(outcome).toBe('delivered');
expect(adaptor.respondPermission).toHaveBeenCalledWith(
BACKEND,
'r1',
'allow',
);
expect(broker.pendingCount).toBe(0);
expect(broker.resolveHandle('req_1')).toBeUndefined();
});
it('translates allow_always to a one-shot allow and auto-answers identical repeats', async () => {
const { adaptor, broker } = createBroker();
await request(broker, { requestId: 'r1', title: 'Bash: rm -rf /a' });
await broker.respond('req_1', 'allow_always');
// The protocol vote never carries the standing grant.
expect(adaptor.respondPermission).toHaveBeenLastCalledWith(
BACKEND,
'r1',
'allow',
);
// Same session, same normalized title: the grant covers the repeat.
const ask = await request(broker, {
requestId: 'r2',
title: 'Bash: rm -rf /a ',
});
expect(ask.autoAnswered).toBe(true);
expect(adaptor.respondPermission).toHaveBeenLastCalledWith(
BACKEND,
'r2',
'allow',
);
expect(broker.pendingCount).toBe(0);
});
it('scopes the standing rule to the whole approved command, not its prefix', async () => {
const { broker } = createBroker();
await request(broker, {
requestId: 'r1',
title: 'Bash: git push origin main',
});
await broker.respond('req_1', 'allow_always');
await request(broker, { requestId: 'r2', title: 'Bash: rm -rf /a' });
await broker.respond('req_2', 'allow_always');
// A flag variant is a different command; the user approved a command,
// not its destructive variants.
const forced = await request(broker, {
requestId: 'r3',
title: 'Bash: git push --force origin main',
});
expect(forced.autoAnswered).toBe(false);
// Same two-token prefix, different target: likewise not covered.
const differentTarget = await request(broker, {
requestId: 'r4',
title: 'Bash: rm -rf /b',
});
expect(differentTarget.autoAnswered).toBe(false);
// The identical repeat is.
const repeat = await request(broker, {
requestId: 'r5',
title: 'Bash: git push origin main',
});
expect(repeat.autoAnswered).toBe(true);
});
it('does not auto-answer requests with a different title key', async () => {
const { broker } = createBroker();
await request(broker, { requestId: 'r1', title: 'Bash: rm -rf /a' });
await broker.respond('req_1', 'allow_always');
const ask = await request(broker, {
requestId: 'r2',
title: 'Bash: ls -la',
});
expect(ask.autoAnswered).toBe(false);
});
it('stops auto-answering once the standing rule expires', async () => {
const { broker, clock } = createBroker({ ruleTtlMs: 60_000 });
await request(broker, { requestId: 'r1' });
await broker.respond('req_1', 'allow_always');
clock.now += 59_999;
const beforeExpiry = await request(broker, { requestId: 'r2' });
expect(beforeExpiry.autoAnswered).toBe(true);
clock.now += 2;
const afterExpiry = await request(broker, { requestId: 'r3' });
expect(afterExpiry.autoAnswered).toBe(false);
});
it('scopes standing rules to the session that granted them', async () => {
const { broker } = createBroker();
await request(broker, { requestId: 'r1', sessionHandle: 'session_1' });
await broker.respond('req_1', 'allow_always');
const otherSession = await request(broker, {
requestId: 'r2',
sessionHandle: 'session_2',
});
expect(otherSession.autoAnswered).toBe(false);
});
it('delivers deny votes and reports unknown handles', async () => {
const { adaptor, broker } = createBroker();
await request(broker, { requestId: 'r1' });
const denied = await broker.respond('req_1', 'deny');
expect(denied).toBe('delivered');
expect(adaptor.respondPermission).toHaveBeenCalledWith(
BACKEND,
'r1',
'deny',
);
const missing = await broker.respond('req_99', 'allow');
expect(missing).toBe('not_found');
expect(adaptor.respondPermission).toHaveBeenCalledTimes(1);
});
it('onResolved returns the pending ask once and clears it', async () => {
const { broker } = createBroker();
await request(broker, { requestId: 'r1' });
const pending = broker.onResolved('r1');
expect(pending?.requestHandle).toBe('req_1');
expect(broker.pendingCount).toBe(0);
expect(broker.onResolved('r1')).toBeUndefined();
});
it('logs requests and decisions with the auto flag', async () => {
const { broker, logEvents } = createBroker();
await request(broker, { requestId: 'r1', title: 'Bash: rm -rf /a' });
await broker.respond('req_1', 'allow_always');
await request(broker, { requestId: 'r2', title: 'Bash: rm -rf /a' });
expect(logEvents.map((event) => event.type)).toEqual([
'permission.request',
'permission.decision',
'permission.request',
'permission.decision',
]);
expect(logEvents[0]?.payload).toMatchObject({
requestHandle: 'req_1',
requestId: 'r1',
session: 'session_1',
title: 'Bash: rm -rf /a',
});
expect(logEvents[1]?.payload).toMatchObject({
requestHandle: 'req_1',
decision: 'allow',
auto: false,
outcome: 'delivered',
});
expect(logEvents[3]?.payload).toMatchObject({
requestHandle: 'req_2',
requestId: 'r2',
decision: 'allow',
auto: true,
});
});
it("records the user's spoken constraint note in the decision log", async () => {
const { broker, logEvents } = createBroker();
await request(broker, { requestId: 'r1' });
const outcome = await broker.respond('req_1', 'allow', 'only this file');
expect(outcome).toBe('delivered');
const decision = logEvents.find(
(event) => event.type === 'permission.decision',
);
expect(decision?.payload).toMatchObject({
requestHandle: 'req_1',
decision: 'allow',
note: 'only this file',
});
});
it('clears the pending ask when the backend reports it already resolved', async () => {
const { adaptor, broker } = createBroker();
await request(broker, { requestId: 'r1' });
adaptor.respondPermission.mockResolvedValueOnce('already_resolved');
const outcome = await broker.respond('req_1', 'allow');
expect(outcome).toBe('already_resolved');
expect(broker.pendingCount).toBe(0);
expect(broker.resolveHandle('req_1')).toBeUndefined();
// The handle is gone: a second relay reports not_found instead of
// re-voting on a settled request.
expect(await broker.respond('req_1', 'allow')).toBe('not_found');
expect(adaptor.respondPermission).toHaveBeenCalledTimes(1);
});
it('falls back to a spoken ask when the silent auto-approval fails to deliver', async () => {
const { adaptor, broker, logEvents } = createBroker();
const first = await request(broker, { requestId: 'r1' });
await broker.respond(first.pending.requestHandle, 'allow_always');
// The standing rule matches, but the backend is unreachable: the ask
// must surface to the user instead of crashing the caller.
adaptor.respondPermission.mockRejectedValueOnce(
new Error('daemon unreachable'),
);
const second = await request(broker, { requestId: 'r2' });
expect(second.autoAnswered).toBe(false);
expect(broker.resolveHandle(second.pending.requestHandle)).toBeDefined();
const failure = logEvents.find(
(event) =>
event.type === 'permission.decision' &&
event.payload['outcome'] === 'delivery_failed',
);
expect(failure?.payload).toMatchObject({ auto: true, decision: 'allow' });
});
it('keeps the whole command after colons in the standing-rule key', async () => {
// `git commit -m "fix: bug"` — the command itself contains a colon;
// the key must not truncate at it (or a different -m message with the
// same prefix would silently match).
const { broker } = createBroker();
await request(broker, {
requestId: 'r1',
title: 'Shell: git commit -m "fix: bug"',
});
await broker.respond('req_1', 'allow_always');
const different = await request(broker, {
requestId: 'r2',
title: 'Shell: git commit -m "fix: something else entirely"',
});
expect(different.autoAnswered).toBe(false);
const identical = await request(broker, {
requestId: 'r3',
title: 'Shell: git commit -m "fix: bug"',
});
expect(identical.autoAnswered).toBe(true);
});
it('does not case-fold the standing-rule key — /a and /A are different paths', async () => {
const { broker } = createBroker();
await request(broker, { requestId: 'r1', title: 'Bash: rm -rf /a' });
await broker.respond('req_1', 'allow_always');
const different = await request(broker, {
requestId: 'r2',
title: 'Bash: rm -rf /A',
});
expect(different.autoAnswered).toBe(false);
});
it('an explicit deny revokes the standing rule that matched the request', async () => {
const { broker, adaptor } = createBroker();
await request(broker, { requestId: 'r1', title: 'Bash: rm -rf /a' });
await broker.respond('req_1', 'allow_always');
// The silent auto-answer fails and falls back to a spoken ask…
adaptor.respondPermission.mockImplementation(async () => {
throw new Error('daemon unreachable');
});
const fallback = await request(broker, {
requestId: 'r2',
title: 'Bash: rm -rf /a',
});
expect(fallback.autoAnswered).toBe(false);
// …and the user says deny. The rule must not survive the refusal.
adaptor.respondPermission.mockImplementation(
async () => 'delivered' as const,
);
await broker.respond('req_2', 'deny');
const again = await request(broker, {
requestId: 'r3',
title: 'Bash: rm -rf /a',
});
expect(again.autoAnswered).toBe(false);
});
});
@@ -0,0 +1,240 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Voice-side permission handling.
*
* Design points carried over from the split design doc (§7):
* - "allow always" never reaches the backend as a persistent grant. The
* protocol vote is always a one-shot allow; the standing rule lives here
* with a TTL and is applied by silently auto-answering similar requests.
* - A request resolved elsewhere (WebShell) retracts the queued spoken ask.
*/
import type {
BackendAdaptor,
BackendHandle,
PermissionOption,
} from '../adaptor/types.js';
const DEFAULT_RULE_TTL_MS = 30 * 60_000;
const MAX_RULES = 64;
export interface PendingPermission {
requestHandle: string;
requestId: string;
backend: BackendHandle;
sessionHandle: string;
title: string;
options: readonly PermissionOption[];
createdAt: number;
}
export interface PermissionBrokerOptions {
adaptor: BackendAdaptor;
now?: () => number;
ruleTtlMs?: number;
log?: (
type: 'permission.request' | 'permission.decision',
payload: Record<string, unknown>,
) => void;
}
export type SpokenPermissionDecision = 'allow' | 'allow_always' | 'deny';
export interface PermissionAskEvent {
pending: PendingPermission;
/** True when a standing rule answered it silently — nothing to speak. */
autoAnswered: boolean;
}
interface StandingRule {
sessionHandle: string;
titleKey: string;
expiresAt: number;
}
/**
* Standing-rule key: tool name plus the WHOLE normalized detail (trimmed,
* whitespace-collapsed — but case-preserved: `/a` and `/A` are different
* directories on Linux, and commands routinely contain colons, so the
* split is on the FIRST colon only and the entire remainder is the
* detail). A voice "always allow" is a trust grant; it must cover only
* repeats of the exact command the user heard and approved —
* `git push origin main` never silently covers
* `git push --force origin main`, and `git commit -m "fix: bug"` never
* covers `git commit -m "fix: something else entirely"`.
*/
function titleKeyOf(title: string): string {
const separator = title.indexOf(':');
const head = separator === -1 ? title : title.slice(0, separator);
const detail = separator === -1 ? '' : title.slice(separator + 1);
const normalized = detail.trim().split(/\s+/).join(' ');
return `${head.trim()}:${normalized}`;
}
export class PermissionBroker {
private readonly pending = new Map<string, PendingPermission>();
private readonly pendingByRequestId = new Map<string, string>();
private rules: StandingRule[] = [];
private seq = 0;
private readonly now: () => number;
private readonly ruleTtlMs: number;
constructor(private readonly options: PermissionBrokerOptions) {
this.now = options.now ?? Date.now;
this.ruleTtlMs = options.ruleTtlMs ?? DEFAULT_RULE_TTL_MS;
}
/**
* A permission_request arrived from the backend. Returns what the caller
* should do: speak the ask, or nothing (a standing rule auto-answered).
*/
async onRequest(fields: {
requestId: string;
backend: BackendHandle;
sessionHandle: string;
title: string;
options: readonly PermissionOption[];
}): Promise<PermissionAskEvent> {
const pending: PendingPermission = {
requestHandle: `req_${++this.seq}`,
requestId: fields.requestId,
backend: fields.backend,
sessionHandle: fields.sessionHandle,
title: fields.title,
options: fields.options,
createdAt: this.now(),
};
this.pending.set(pending.requestHandle, pending);
this.pendingByRequestId.set(pending.requestId, pending.requestHandle);
this.options.log?.('permission.request', {
requestHandle: pending.requestHandle,
requestId: pending.requestId,
session: pending.sessionHandle,
title: pending.title,
});
if (this.matchesRule(pending)) {
// A failed silent delivery must not crash the caller or swallow the
// request: fall back to asking the user aloud.
try {
await this.deliver(pending, 'allow', true);
return { pending, autoAnswered: true };
} catch (error) {
this.options.log?.('permission.decision', {
requestHandle: pending.requestHandle,
requestId: pending.requestId,
decision: 'allow',
auto: true,
outcome: 'delivery_failed',
error: error instanceof Error ? error.message : String(error),
});
return { pending, autoAnswered: false };
}
}
return { pending, autoAnswered: false };
}
/**
* The user answered aloud (via the respond_permission tool). `note` is a
* spoken constraint the user attached ("only this file"); the vote channel
* cannot carry it, so it is recorded in the decision log here and relayed
* to the backend session by the caller.
*/
async respond(
requestHandle: string,
decision: SpokenPermissionDecision,
note?: string,
): Promise<'delivered' | 'already_resolved' | 'not_found'> {
const pending = this.pending.get(requestHandle.trim());
if (!pending) return 'not_found';
if (decision === 'allow_always') {
this.remember(pending);
}
if (decision === 'deny') {
// An explicit refusal must outrank any standing rule that matched
// this same request (the silent auto-answer failed and fell back to
// asking): without revocation the invisible rule silently overrides
// the user's spoken "no" on the next identical request. Broker-local
// per the design note — the protocol vote has no revocation concept.
const key = titleKeyOf(pending.title);
this.rules = this.rules.filter(
(rule) =>
rule.sessionHandle !== pending.sessionHandle || rule.titleKey !== key,
);
}
return await this.deliver(
pending,
decision === 'deny' ? 'deny' : 'allow',
false,
note,
);
}
/** A resolution arrived from the event stream (possibly our own vote). */
onResolved(requestId: string): PendingPermission | undefined {
const handle = this.pendingByRequestId.get(requestId);
if (handle === undefined) return undefined;
this.pendingByRequestId.delete(requestId);
const pending = this.pending.get(handle);
this.pending.delete(handle);
return pending;
}
resolveHandle(requestHandle: string): PendingPermission | undefined {
return this.pending.get(requestHandle.trim());
}
get pendingCount(): number {
return this.pending.size;
}
private matchesRule(pending: PendingPermission): boolean {
const now = this.now();
this.rules = this.rules.filter((rule) => rule.expiresAt > now);
const key = titleKeyOf(pending.title);
return this.rules.some(
(rule) =>
rule.sessionHandle === pending.sessionHandle && rule.titleKey === key,
);
}
private remember(pending: PendingPermission): void {
this.rules.push({
sessionHandle: pending.sessionHandle,
titleKey: titleKeyOf(pending.title),
expiresAt: this.now() + this.ruleTtlMs,
});
while (this.rules.length > MAX_RULES) this.rules.shift();
}
private async deliver(
pending: PendingPermission,
decision: 'allow' | 'deny',
auto: boolean,
note?: string,
): Promise<'delivered' | 'already_resolved'> {
const outcome = await this.options.adaptor.respondPermission(
pending.backend,
pending.requestId,
decision,
);
this.options.log?.('permission.decision', {
requestHandle: pending.requestHandle,
requestId: pending.requestId,
decision,
auto,
outcome,
...(note ? { note } : {}),
});
if (outcome === 'delivered' || outcome === 'already_resolved') {
this.pending.delete(pending.requestHandle);
this.pendingByRequestId.delete(pending.requestId);
}
return outcome;
}
}
@@ -0,0 +1,58 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Realtime model instructions for the orchestration tool surface. Rewritten
* from the PR #7859 single-backend prompt for the session-dispatch model:
* the voice model is the user's control tower over many coding sessions,
* not the front half of one bound session.
*/
const DEFAULT_INSTRUCTIONS = `## Identity, tone, and role
You are Qwen Code, a general-purpose agentic assistant. You are the user's single voice entry point to everything their coding sessions can do: files, commands, apps, documents, research, and long-running work.
Be concise, clear, and efficient. Keep responses tight and useful—no fluff. Talk like a trusted collaborator: natural, warm, and easy to follow, with light energy and zero ceremony.
## Operating model
You coordinate coding sessions that do the actual work. The user cannot see your tools; present everything as done by you. Never mention "sessions", "backends", "tools", or how the system is put together unless the user asks about the machinery explicitly.
* Anything that touches files, runs commands, needs current information, creates artifacts, or takes real action goes through \`handoff\`. When unsure whether a handoff would help, hand off.
* Respond directly only when the request is clearly self-contained conversation.
* NEVER refuse a request yourself, and never claim you lack an ability without trying. The executing session judges feasibility and safety; pass the request through with \`handoff\` and let it decide.
* When the user asks about the screen or visible content, call \`appshot\`; for anything deeper than describing what is visible, follow with a \`handoff\` and attach the capture.
* Multiple sessions may be working at once. \`session_list\` shows what exists; refer to sessions the way the user does ("the test one"), and use handles only as tool arguments, never aloud.
## Receipts, results, and honesty
* Tools return receipts and snapshots, never final results. A receipt means the work is queued or running — nothing more.
* Never say work is done, created, or successful without evidence: a receipt for "started", a [COMPLETE] message for "finished". If you have not seen it, say it is still in progress.
* Results arrive as [COMPLETE] or [PROGRESS] context messages. [BACKEND]-style context messages are silent context: never respond merely because one arrived.
* A [SPEAK_TO_USER] message is an explicit one-shot speech request: speak exactly the text after the prefix, verbatim, without additions or tool calls.
* Before your first tool call in a user turn, say one short, neutral sentence about what you are about to do ("Let me get that going."). Never promise outcomes in it. Then call the tool immediately. Do not repeat the acknowledgement for follow-up calls in the same turn.
## Steering, stopping, and interruptions
* New instructions, corrections, or constraints for running work: \`handoff\` to the same session immediately. Running work is always steerable — never claim otherwise.
* The user interrupting your speech never stops any work. Work stops only through \`session_stop\`, and only when the user clearly asks for that.
## Permissions
* A [PERMISSION] message means a session is waiting for the user's approval. Read it out briefly and plainly — what wants to run, in everyday words — and relay their answer with \`respond_permission\`.
* Never answer a permission request on the user's behalf, and never pressure them either way.
## Presenting results
* When a [COMPLETE] arrives at a natural moment, give the user the key takeaway in one or two spoken sentences: what happened, what changed, what needs them next.
* Do not read out tables, diffs, code, paths, or structured data. Offer the gist; the details are on their screen when they want them.
* Follow the user's stated preferences about update frequency and verbosity for the rest of the task.`;
export function buildLiveInstructions(startupContext?: string): string {
return startupContext
? `${DEFAULT_INSTRUCTIONS}\n\n${startupContext}`
: DEFAULT_INSTRUCTIONS;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { describe, expect, it } from 'vitest';
import { escapeAnsiCtrlCodes } from './sanitize.js';
describe('escapeAnsiCtrlCodes', () => {
it('escapes ANSI escape sequences into their JSON-escaped form', () => {
expect(escapeAnsiCtrlCodes('\u001b[31mred\u001b[0m')).toBe(
'\\u001b[31mred\\u001b[0m',
);
});
it('escapes consistently on repeated calls despite the shared global regex', () => {
// The module holds one global regex whose lastIndex must be reset
// between uses; a stale lastIndex would skip leading matches.
expect(escapeAnsiCtrlCodes('\u001b[2Jcleared')).toBe('\\u001b[2Jcleared');
expect(escapeAnsiCtrlCodes('\u001b[2Jcleared')).toBe('\\u001b[2Jcleared');
});
it('returns clean strings unchanged', () => {
const clean = 'Realtime provider rejected the request (429).';
expect(escapeAnsiCtrlCodes(clean)).toBe(clean);
expect(escapeAnsiCtrlCodes('')).toBe('');
});
});
@@ -0,0 +1,23 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import ansiRegex from 'ansi-regex';
const regex = ansiRegex();
/**
* Ported from packages/cli/src/ui/utils/textUtils.ts (escapeAnsiCtrlCodes),
* reduced to the string path this package actually uses: escape ANSI/control
* sequences in untrusted text before it reaches logs or model context.
* Returns the input unchanged when it contains no ANSI sequences.
*/
export function escapeAnsiCtrlCodes(text: string): string {
if (text.search(regex) === -1) {
return text; // No changes return original string
}
regex.lastIndex = 0; // needed for global regex
return text.replace(regex, (match) => JSON.stringify(match).slice(1, -1));
}
+37
View File
@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Minimal socket abstraction shared by the realtime client and its tests.
*
* Ported verbatim from packages/cli/src/ui/voice/voice-stream-session.ts so
* the ported realtime client (and its FakeSocket-based tests) keep the exact
* injection seam they were written against.
*/
export interface SocketLike {
readyState: number;
OPEN: number;
bufferedAmount?: number;
send: (data: string | Uint8Array) => void;
close: () => void;
on: (event: string, cb: (...args: unknown[]) => void) => void;
}
/**
* Derive the WebSocket origin for a DashScope-style HTTP base URL: swap the
* scheme and strip a trailing `/compatible-mode/v1` or `/v1` path prefix.
*/
export function deriveWebSocketBase(baseUrl: string): string {
const url = new URL(baseUrl);
const wsScheme = url.protocol === 'https:' ? 'wss:' : 'ws:';
let prefix = url.pathname.replace(/\/+$/, '');
if (prefix.endsWith('/compatible-mode/v1')) {
prefix = prefix.slice(0, -'/compatible-mode/v1'.length);
} else if (prefix.endsWith('/v1')) {
prefix = prefix.slice(0, -'/v1'.length);
}
return `${wsScheme}//${url.host}${prefix}`;
}
+213
View File
@@ -0,0 +1,213 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* The realtime model's tool surface: seven receipt-style dispatch tools plus
* remain_silent. Descriptions encode the two disciplines every tool obeys:
* tools return receipts and snapshots (never long-task results — those flow
* back through injection), and the model must not claim work happened
* without a receipt.
*/
import {
REMAIN_SILENT_TOOL_NAME,
type RealtimeToolDefinition,
} from '../realtime/realtime-session.js';
export const APPSHOT_TOOL_NAME = 'appshot';
export const SESSION_LIST_TOOL_NAME = 'session_list';
export const SESSION_CREATE_TOOL_NAME = 'session_create';
export const HANDOFF_TOOL_NAME = 'handoff';
export const SESSION_MONITOR_TOOL_NAME = 'session_monitor';
export const SESSION_STOP_TOOL_NAME = 'session_stop';
export const RESPOND_PERMISSION_TOOL_NAME = 'respond_permission';
const APPSHOT_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: APPSHOT_TOOL_NAME,
description:
'Capture what the user currently sees on screen. Returns the frontmost ' +
'app, window title, and an accessibility-text summary, and registers ' +
'the screenshot as an asset you can attach to a later handoff via ' +
'input_refs. Use it whenever the user refers to "this", "the screen", ' +
'or visible content.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
};
const SESSION_LIST_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: SESSION_LIST_TOOL_NAME,
description:
'List the coding sessions you can dispatch work to, with their short ' +
'handles, working directories, and whether each is idle or busy. Call ' +
'this before referring to any session you have not listed yet in this ' +
'call.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
};
const SESSION_CREATE_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: SESSION_CREATE_TOOL_NAME,
description:
'Create a new coding session. Only needed when the user explicitly ' +
'wants separate parallel workstreams; handoff without a session picks ' +
'or creates a sensible default on its own.',
parameters: {
type: 'object',
properties: {
label: {
type: 'string',
description: 'Optional human label for the session.',
},
cwd: {
type: 'string',
description: 'Optional working directory for the session.',
},
},
additionalProperties: false,
},
},
};
const HANDOFF_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: HANDOFF_TOOL_NAME,
description:
"Send the user's request to a coding session for execution. This is " +
'the default action for anything that touches files, runs commands, ' +
'needs the screen inspected in depth, or requires up-to-date ' +
"information. Pass the user's own words in `task`; do not rewrite " +
'them. Returns a receipt immediately — the result arrives later as a ' +
'[COMPLETE] context message. Targeting a busy session appends the ' +
'instruction to its running task (the receipt says how it landed). ' +
'Before your first tool call in a user turn, say one short neutral ' +
'sentence about what you are doing; never promise the outcome.',
parameters: {
type: 'object',
properties: {
task: {
type: 'string',
description: "The user's request, in their own words.",
},
session: {
type: 'string',
description:
'Target session handle from session_list (e.g. "session_1"). ' +
'Omit to use the default session.',
},
input_refs: {
type: 'array',
items: { type: 'string' },
description:
'Asset ids to attach (e.g. an appshot screenshot: "asset_1").',
},
},
required: ['task'],
additionalProperties: false,
},
},
capturesTranscript: true,
};
const SESSION_MONITOR_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: SESSION_MONITOR_TOOL_NAME,
description:
'Get a progress snapshot for a session or job (state plus recent ' +
'activity). Use it only when the user asks how something is going; ' +
'completed work announces itself without polling.',
parameters: {
type: 'object',
properties: {
session: { type: 'string', description: 'Session handle.' },
job: { type: 'string', description: 'Job reference (e.g. "job_2").' },
},
additionalProperties: false,
},
},
};
const SESSION_STOP_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: SESSION_STOP_TOOL_NAME,
description:
'Cancel the running task in a session. This is the only way to stop ' +
'work: the user interrupting your speech never cancels tasks. Call it ' +
'only when the user clearly asks to stop or abandon the work.',
parameters: {
type: 'object',
properties: {
session: { type: 'string', description: 'Session handle.' },
job: { type: 'string', description: 'Job reference.' },
},
additionalProperties: false,
},
},
};
const RESPOND_PERMISSION_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: RESPOND_PERMISSION_TOOL_NAME,
description:
"Relay the user's spoken answer to a pending [PERMISSION] request. " +
'`allow_always` also lets similar requests through silently for a ' +
'while. Only call this after the user actually answered; never decide ' +
'for them.',
parameters: {
type: 'object',
properties: {
request_id: {
type: 'string',
description: 'The id from the [PERMISSION] message (e.g. "req_3").',
},
decision: {
type: 'string',
enum: ['allow', 'allow_always', 'deny'],
},
note: {
type: 'string',
description:
"Optional constraint the user added ('only this file'); it is " +
'relayed to the coding session together with the vote.',
},
},
required: ['request_id', 'decision'],
additionalProperties: false,
},
},
};
const REMAIN_SILENT_TOOL: RealtimeToolDefinition = {
type: 'function',
function: {
name: REMAIN_SILENT_TOOL_NAME,
description:
'Call this when the best response is to say nothing. Use it instead ' +
'of speaking after silent context messages whenever acknowledging ' +
'aloud would be distracting. This tool has no user-visible effect.',
parameters: { type: 'object', properties: {}, additionalProperties: false },
},
};
export const LIVE_SESSION_TOOLS: readonly RealtimeToolDefinition[] = [
APPSHOT_TOOL,
SESSION_LIST_TOOL,
SESSION_CREATE_TOOL,
HANDOFF_TOOL,
SESSION_MONITOR_TOOL,
SESSION_STOP_TOOL,
RESPOND_PERMISSION_TOOL,
REMAIN_SILENT_TOOL,
];
@@ -0,0 +1,206 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { ToolDispatcher } from './dispatcher.js';
import type { ToolContext, ToolHandler } from './dispatcher.js';
function makeContext(
transcript: ToolContext['activeTranscript'] = [],
): ToolContext {
return { activeTranscript: transcript };
}
function makeDispatcher(
handlers: Record<string, ToolHandler>,
timeoutMs?: number,
): ToolDispatcher {
return new ToolDispatcher({
handlers: new Map(Object.entries(handlers)),
...(timeoutMs !== undefined ? { timeoutMs } : {}),
});
}
afterEach(() => {
vi.useRealTimers();
});
describe('ToolDispatcher', () => {
it('answers an unregistered tool with an error receipt instead of stalling', async () => {
const dispatcher = makeDispatcher({});
const result = await dispatcher.dispatch('mystery', '{}', makeContext());
expect(result.ok).toBe(false);
const receipt = JSON.parse(result.receipt) as {
status: string;
note: string;
};
expect(receipt.status).toBe('error');
expect(receipt.note).toContain('No handler');
expect(receipt.note).toContain('mystery');
});
it.each([
['invalid JSON', '{not json'],
['empty string', ''],
['whitespace only', ' '],
['a JSON array', '[1, 2, 3]'],
['a JSON scalar', '"hello"'],
])('degrades %s arguments to an empty object', async (_label, raw) => {
const seen: Array<Record<string, unknown>> = [];
const dispatcher = makeDispatcher({
echo: (args) => {
seen.push(args);
return { status: 'ok' };
},
});
const result = await dispatcher.dispatch('echo', raw, makeContext());
expect(result.ok).toBe(true);
expect(seen).toEqual([{}]);
});
it('passes well-formed JSON object arguments through to the handler', async () => {
const seen: Array<Record<string, unknown>> = [];
const dispatcher = makeDispatcher({
echo: (args) => {
seen.push(args);
return { status: 'ok' };
},
});
await dispatcher.dispatch(
'echo',
'{"sessionHandle":"session_1","count":2}',
makeContext(),
);
expect(seen).toEqual([{ sessionHandle: 'session_1', count: 2 }]);
});
it('serializes a successful handler result as the JSON receipt', async () => {
const outcome = { status: 'accepted', jobHandle: 'job_1', queued: false };
const dispatcher = makeDispatcher({
start_job: () => outcome,
});
const result = await dispatcher.dispatch('start_job', '{}', makeContext());
expect(result.ok).toBe(true);
expect(result.receipt).toBe(JSON.stringify(outcome));
});
it('awaits async handlers before building the receipt', async () => {
const dispatcher = makeDispatcher({
async_tool: async () => ({ status: 'done' }),
});
const result = await dispatcher.dispatch('async_tool', '{}', makeContext());
expect(result.ok).toBe(true);
expect(JSON.parse(result.receipt)).toEqual({ status: 'done' });
});
it('turns a thrown handler error into an error receipt with the message truncated to 300 chars', async () => {
const longMessage = 'x'.repeat(400);
const dispatcher = makeDispatcher({
boom: () => {
throw new Error(longMessage);
},
});
const result = await dispatcher.dispatch('boom', '{}', makeContext());
expect(result.ok).toBe(false);
const receipt = JSON.parse(result.receipt) as {
status: string;
note: string;
};
expect(receipt.status).toBe('error');
expect(receipt.note).toBe(longMessage.slice(0, 300));
expect(receipt.note).toHaveLength(300);
});
it('falls back to a generic note for non-Error and empty-message throws', async () => {
const dispatcher = makeDispatcher({
raw_throw: () => {
// A non-Error throw (object, not string — the lint bans literals).
throw { reason: 'not an error object' };
},
empty_message: () => {
throw new Error('');
},
});
for (const name of ['raw_throw', 'empty_message']) {
const result = await dispatcher.dispatch(name, '{}', makeContext());
expect(result.ok).toBe(false);
expect(JSON.parse(result.receipt)).toEqual({
status: 'error',
note: 'the tool failed',
});
}
});
it('times out a hung handler and returns the timeout receipt', async () => {
vi.useFakeTimers();
const dispatcher = makeDispatcher(
{
hang: () => new Promise<Record<string, unknown>>(() => {}),
},
50,
);
const pending = dispatcher.dispatch('hang', '{}', makeContext());
await vi.advanceTimersByTimeAsync(50);
const result = await pending;
expect(result.ok).toBe(false);
const receipt = JSON.parse(result.receipt) as {
status: string;
note: string;
};
// The handler keeps running (side effects are real): the receipt must
// read as "still in progress", never as a retryable failure.
expect(receipt.status).toBe('pending');
expect(receipt.note).toContain('Do not retry');
});
it('does not time out a handler that resolves before the deadline', async () => {
vi.useFakeTimers();
const dispatcher = makeDispatcher(
{
quick: () => ({ status: 'ok' }),
},
50,
);
const result = await dispatcher.dispatch('quick', '{}', makeContext());
expect(result.ok).toBe(true);
expect(JSON.parse(result.receipt)).toEqual({ status: 'ok' });
});
it('threads ctx.activeTranscript through to the handler untouched', async () => {
const transcript = [
{ role: 'user' as const, text: 'run the tests' },
{ role: 'assistant' as const, text: 'Starting them now.' },
];
let received: ToolContext | undefined;
const dispatcher = makeDispatcher({
capture: (_args, ctx) => {
received = ctx;
return { status: 'ok' };
},
});
await dispatcher.dispatch('capture', '{}', makeContext(transcript));
expect(received?.activeTranscript).toBe(transcript);
});
});
+124
View File
@@ -0,0 +1,124 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Routes realtime function calls to handlers and turns whatever happens —
* success, handler error, bad arguments, timeout — into a JSON receipt
* string. No call is ever left unanswered: an unanswered function call
* would stall the realtime response arbitration.
*/
/**
* Generous by design: the timeout is a last-resort answer for the model,
* not a cancellation — the handler keeps running and its side effects
* (a created job, a submitted prompt) stay real. The receipt wording must
* therefore steer the model away from retrying.
*/
const DEFAULT_HANDLER_TIMEOUT_MS = 30_000;
const TIMEOUT_NOTE =
'The action is still running in the background and may yet finish. Do ' +
'not retry the call; check on it with session_monitor.';
/** Per-call context threaded through from the realtime function-call event. */
export interface ToolContext {
/** Transcript tail captured at call time (capturesTranscript tools). */
activeTranscript: ReadonlyArray<{ role: 'user' | 'assistant'; text: string }>;
}
export type ToolHandler = (
args: Record<string, unknown>,
ctx: ToolContext,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
export interface ToolDispatchResult {
/** JSON receipt to submit as the function call output. */
receipt: string;
ok: boolean;
}
export interface ToolDispatcherOptions {
handlers: ReadonlyMap<string, ToolHandler>;
timeoutMs?: number;
}
function parseArguments(raw: string): Record<string, unknown> {
if (!raw.trim()) return {};
try {
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed))
return parsed as Record<string, unknown>;
} catch {
/* fall through to the lenient default */
}
return {};
}
class ToolTimeoutError extends Error {
constructor() {
super('tool handler timed out');
this.name = 'ToolTimeoutError';
}
}
export class ToolDispatcher {
private readonly handlers: ReadonlyMap<string, ToolHandler>;
private readonly timeoutMs: number;
constructor(options: ToolDispatcherOptions) {
this.handlers = options.handlers;
this.timeoutMs = options.timeoutMs ?? DEFAULT_HANDLER_TIMEOUT_MS;
}
async dispatch(
name: string,
rawArguments: string,
ctx: ToolContext,
): Promise<ToolDispatchResult> {
const handler = this.handlers.get(name);
if (!handler) {
return {
ok: false,
receipt: JSON.stringify({
status: 'error',
note: `No handler for tool ${name}.`,
}),
};
}
const args = parseArguments(rawArguments);
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const outcome = await Promise.race([
Promise.resolve(handler(args, ctx)),
new Promise<never>((_, reject) => {
timer = setTimeout(() => {
reject(new ToolTimeoutError());
}, this.timeoutMs);
timer.unref?.();
}),
]);
return { ok: true, receipt: JSON.stringify(outcome) };
} catch (error) {
if (error instanceof ToolTimeoutError) {
return {
ok: false,
receipt: JSON.stringify({ status: 'pending', note: TIMEOUT_NOTE }),
};
}
return {
ok: false,
receipt: JSON.stringify({
status: 'error',
note:
error instanceof Error && error.message
? error.message.slice(0, 300)
: 'the tool failed',
}),
};
} finally {
if (timer !== undefined) clearTimeout(timer);
}
}
}
@@ -0,0 +1,210 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { HandleRegistry } from './handles.js';
import type { JobRecord } from './handles.js';
import type { BackendHandle } from '../adaptor/types.js';
function backend(id: string, adaptor = 'qwen-code'): BackendHandle {
return { id, adaptor };
}
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(1_000_000);
});
afterEach(() => {
vi.useRealTimers();
});
describe('HandleRegistry sessions', () => {
it('returns the same handle for the same backend session (idempotent)', () => {
const registry = new HandleRegistry();
const first = registry.session(backend('abc'));
const again = registry.session(backend('abc'));
expect(first).toBe('session_1');
expect(again).toBe(first);
});
it('assigns incrementing handles to distinct backends', () => {
const registry = new HandleRegistry();
expect(registry.session(backend('abc'))).toBe('session_1');
expect(registry.session(backend('def'))).toBe('session_2');
// Same id under a different adaptor is a different backend.
expect(registry.session(backend('abc', 'acp'))).toBe('session_3');
});
it('resolves session handles with surrounding whitespace trimmed', () => {
const registry = new HandleRegistry();
const handle = registry.session(backend('abc'));
expect(registry.resolveSession(` ${handle} `)).toEqual(backend('abc'));
expect(registry.resolveSession('session_99')).toBeUndefined();
});
});
describe('HandleRegistry jobs', () => {
it('creates jobs with incrementing handles and accepted state', () => {
const registry = new HandleRegistry();
const sessionHandle = registry.session(backend('abc'));
const job = registry.createJob({
sessionHandle,
backend: backend('abc'),
jobRef: 'prompt-1',
task: 'run the tests',
});
expect(job).toEqual({
jobHandle: 'job_1',
sessionHandle,
backend: backend('abc'),
jobRef: 'prompt-1',
state: 'accepted',
task: 'run the tests',
createdAt: 1_000_000,
});
expect(
registry.createJob({
sessionHandle,
backend: backend('abc'),
task: 'another task',
}).jobHandle,
).toBe('job_2');
});
it('omits jobRef when none was supplied', () => {
const registry = new HandleRegistry();
const job = registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
task: 'no ref',
});
expect('jobRef' in job).toBe(false);
expect(registry.jobByRef('prompt-x')).toBeUndefined();
});
it('returns the existing record instead of minting a duplicate for a known jobRef', () => {
const registry = new HandleRegistry();
const first = registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
jobRef: 'prompt-1',
task: 'original task',
});
first.state = 'running';
// A steer that joined the running turn reports the SAME jobRef: the
// registry must not orphan the running job behind a second record.
const again = registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
jobRef: 'prompt-1',
task: 'joined instruction',
});
expect(again).toBe(first);
expect(again.jobHandle).toBe('job_1');
expect(registry.resolveJob('job_2')).toBeUndefined();
expect(registry.activeJobForSession('session_1')).toBe(first);
first.state = 'done';
expect(registry.activeJobForSession('session_1')).toBeUndefined();
});
it('resolves jobs by trimmed handle and by backend jobRef', () => {
const registry = new HandleRegistry();
const job = registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
jobRef: 'prompt-7',
task: 'build',
});
expect(registry.resolveJob(` ${job.jobHandle} `)).toBe(job);
expect(registry.resolveJob('job_99')).toBeUndefined();
expect(registry.jobByRef('prompt-7')).toBe(job);
expect(registry.jobByRef('prompt-8')).toBeUndefined();
});
it('picks the newest non-terminal job as the active job for a session', () => {
const registry = new HandleRegistry();
const create = (task: string): JobRecord => {
vi.advanceTimersByTime(1_000);
return registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
task,
});
};
const older = create('older');
const newer = create('newer');
expect(registry.activeJobForSession('session_1')?.task).toBe('newer');
newer.state = 'done';
expect(registry.activeJobForSession('session_1')?.task).toBe('older');
older.state = 'failed';
expect(registry.activeJobForSession('session_1')).toBeUndefined();
const cancelled = create('cancelled one');
cancelled.state = 'cancelled';
expect(registry.activeJobForSession('session_1')).toBeUndefined();
const running = create('running one');
running.state = 'running';
expect(registry.activeJobForSession('session_1')).toBe(running);
});
it('scopes the active job lookup to the requested session', () => {
const registry = new HandleRegistry();
registry.createJob({
sessionHandle: 'session_1',
backend: backend('abc'),
task: 'for session 1',
});
expect(registry.activeJobForSession('session_2')).toBeUndefined();
});
});
describe('HandleRegistry assets', () => {
it('registers assets and resolves trimmed handles', () => {
const registry = new HandleRegistry();
const asset = registry.registerAsset({
path: '/tmp/shot.png',
mimeType: 'image/png',
});
expect(asset.assetHandle).toBe('asset_1');
expect(registry.resolveAsset(' asset_1 ')).toBe(asset);
});
it('keeps at most 32 assets, evicting the oldest first', () => {
const registry = new HandleRegistry();
for (let index = 1; index <= 33; index++) {
registry.registerAsset({
path: `/tmp/shot-${index}.png`,
mimeType: 'image/png',
});
}
expect(registry.resolveAsset('asset_1')).toBeUndefined();
expect(registry.resolveAsset('asset_2')?.path).toBe('/tmp/shot-2.png');
expect(registry.resolveAsset('asset_33')?.path).toBe('/tmp/shot-33.png');
registry.registerAsset({ path: '/tmp/shot-34.png', mimeType: 'image/png' });
expect(registry.resolveAsset('asset_2')).toBeUndefined();
expect(registry.resolveAsset('asset_3')).toBeDefined();
});
});
+161
View File
@@ -0,0 +1,161 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Short-handle registries. The realtime model refers to sessions as
* `session_N`, jobs as `job_N`, and screen-capture assets as `asset_N` —
* short, speakable, and hard to mistranscribe. These maps own the
* bidirectional translation to the real identities.
*/
import type { BackendHandle } from '../adaptor/types.js';
export interface JobRecord {
jobHandle: string;
sessionHandle: string;
backend: BackendHandle;
/** Adaptor-side correlation id (qwen serve: promptId). */
jobRef?: string;
state: 'accepted' | 'running' | 'done' | 'failed' | 'cancelled';
task: string;
createdAt: number;
}
export interface AssetRecord {
assetHandle: string;
path: string;
mimeType: string;
createdAt: number;
}
const MAX_ASSETS = 32;
export class HandleRegistry {
private readonly sessions = new Map<string, BackendHandle>();
private readonly sessionsByBackendId = new Map<string, string>();
/** Sessions whose backend reported closure — handles no longer resolve. */
private readonly closedSessions = new Set<string>();
private readonly jobs = new Map<string, JobRecord>();
private readonly jobsByRef = new Map<string, string>();
private readonly assets = new Map<string, AssetRecord>();
private sessionSeq = 0;
private jobSeq = 0;
private assetSeq = 0;
/** Register (or return the existing handle for) a backend session. */
session(backend: BackendHandle): string {
const key = `${backend.adaptor}:${backend.id}`;
const existing = this.sessionsByBackendId.get(key);
if (existing) return existing;
const handle = `session_${++this.sessionSeq}`;
this.sessions.set(handle, backend);
this.sessionsByBackendId.set(key, handle);
return handle;
}
resolveSession(handle: string): BackendHandle | undefined {
const key = handle.trim();
if (this.closedSessions.has(key)) return undefined;
return this.sessions.get(key);
}
/** Mark a session closed: its handle stops resolving from now on. */
closeSession(handle: string): void {
this.closedSessions.add(handle.trim());
}
/**
* Register a new job. Idempotent by jobRef: a jobRef already registered
* means the backend attributed the prompt to that existing turn (e.g. a
* steer that joined it), so the existing record is returned instead of
* minting a second job that would orphan the first in 'running'.
*/
createJob(fields: {
sessionHandle: string;
backend: BackendHandle;
jobRef?: string;
task: string;
}): JobRecord {
if (fields.jobRef !== undefined) {
const existing = this.jobByRef(fields.jobRef);
if (existing) return existing;
}
const record: JobRecord = {
jobHandle: `job_${++this.jobSeq}`,
sessionHandle: fields.sessionHandle,
backend: fields.backend,
...(fields.jobRef !== undefined ? { jobRef: fields.jobRef } : {}),
state: 'accepted',
task: fields.task,
createdAt: Date.now(),
};
this.jobs.set(record.jobHandle, record);
if (record.jobRef !== undefined) {
this.jobsByRef.set(record.jobRef, record.jobHandle);
}
return record;
}
resolveJob(handle: string): JobRecord | undefined {
return this.jobs.get(handle.trim());
}
/** Find the job a backend event's jobRef belongs to. */
jobByRef(jobRef: string): JobRecord | undefined {
const handle = this.jobsByRef.get(jobRef);
return handle ? this.jobs.get(handle) : undefined;
}
/**
* Reconcile a session's non-terminal jobs against reality: the backend
* reports idle, so any 'accepted'/'running' record left over from a
* missed terminal event (emitted while no pump was subscribed — pumps
* are per-call and aborted at call end) transitions to 'done'. Gated on
* the caller's isBusy check so a genuinely busy backend is never
* touched.
*/
reconcileIdleSession(sessionHandle: string): void {
for (const job of this.jobs.values()) {
if (job.sessionHandle !== sessionHandle) continue;
if (job.state === 'done' || job.state === 'failed') continue;
if (job.state === 'cancelled') continue;
job.state = 'done';
}
}
/** The most recent non-terminal job for a session, if any. */
activeJobForSession(sessionHandle: string): JobRecord | undefined {
let candidate: JobRecord | undefined;
for (const job of this.jobs.values()) {
if (job.sessionHandle !== sessionHandle) continue;
if (job.state === 'done' || job.state === 'failed') continue;
if (job.state === 'cancelled') continue;
if (!candidate || job.createdAt > candidate.createdAt) candidate = job;
}
return candidate;
}
registerAsset(fields: { path: string; mimeType: string }): AssetRecord {
const record: AssetRecord = {
assetHandle: `asset_${++this.assetSeq}`,
path: fields.path,
mimeType: fields.mimeType,
createdAt: Date.now(),
};
this.assets.set(record.assetHandle, record);
// Bounded: assets reference temp files with short TTLs; keep the newest.
while (this.assets.size > MAX_ASSETS) {
const oldest = this.assets.keys().next().value;
if (oldest === undefined) break;
this.assets.delete(oldest);
}
return record;
}
resolveAsset(handle: string): AssetRecord | undefined {
return this.assets.get(handle.trim());
}
}
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"tsBuildInfoFile": "./tsconfig.build.tsbuildinfo",
"sourceMap": false,
"declarationMap": false
},
"exclude": ["node_modules", "dist", "src/**/*.test.ts", "src/testing/**"]
}
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "dist",
"rootDir": "src",
"composite": true,
"types": ["node", "vitest/globals"]
},
"include": ["src/**/*.ts"],
"exclude": ["node_modules", "dist"]
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @license
* Copyright 2026 Qwen
* SPDX-License-Identifier: Apache-2.0
*/
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { defineConfig } from 'vitest/config';
const here = path.dirname(fileURLToPath(import.meta.url));
export default defineConfig({
resolve: {
alias: {
// Read the SDK's TypeScript source directly so tests never depend on a
// stale dist build (same rationale as packages/cli/vitest.config.ts).
'@qwen-code/sdk': path.resolve(here, '../sdk-typescript/src/index.ts'),
},
},
test: {
environment: 'node',
include: ['src/**/*.test.ts'],
// Discovery tests exercise real file locking; orchestrator tests use fake
// timers but spawn no processes. Keep generous ceilings for slow CI hosts.
testTimeout: 60_000,
hookTimeout: 60_000,
},
});
@@ -103,10 +103,12 @@ export class DiffManager {
const rightUri = rightDocUri.toString();
for (const group of vscode.window.tabGroups.all) {
const containsDiff = group.tabs.some((tab) => {
const input = tab.input as {
original?: vscode.Uri;
modified?: vscode.Uri;
} | undefined;
const input = tab.input as
| {
original?: vscode.Uri;
modified?: vscode.Uri;
}
| undefined;
return (
input?.original?.toString() === leftUri &&
input?.modified?.toString() === rightUri
+1
View File
@@ -74,6 +74,7 @@ const buildOrder = [
...(cliOnly
? []
: [
'packages/qwen-live',
'packages/webui',
'packages/web-shell',
'packages/vscode-ide-companion',
@@ -145,6 +145,10 @@ describe('no-AK integration CI wiring', () => {
'./fake-openai-server.test.ts',
'./test-helper.test.ts',
'./chat-transcript-contract.test.ts',
'./qwen-live-m1-call.test.ts',
'./qwen-live-m2-inject.test.ts',
'./qwen-live-m2-permission.test.ts',
'./qwen-live-m2-steering.test.ts',
'./cli/daemon-invocation-context.test.ts',
'./cli/list_directory.test.ts',
'./cli/qwen-serve-routes.test.ts',
+19
View File
@@ -42,10 +42,29 @@ describe('package scripts', () => {
" '@qwen-code/sdk',\n" +
" '@qwen-code/mobile-mcp',\n" +
" '@qwen-code/node-repl-mcp',\n" +
" '@qwen-code/qwen-live',\n" +
'];',
);
});
it('builds the standalone qwen-live daemon in the root build order', () => {
const buildScript = readFileSync(
path.join(root, 'scripts/build.js'),
'utf8',
);
// The qwen-live e2e harness spawns packages/qwen-live/dist/index.js and
// the workspace unit tests run from src, so this pin is what catches the
// root build silently dropping the package.
const startIndex = buildScript.indexOf('const buildOrder = [');
expect(startIndex).toBeGreaterThan(-1);
const buildOrder = buildScript.slice(
startIndex,
buildScript.indexOf('];', startIndex),
);
expect(buildOrder).toContain("'packages/qwen-live',");
});
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
const packageJson = readPackageJson();
+3 -1
View File
@@ -43,11 +43,13 @@ if (!versionType) {
run(`npm version ${versionType} --no-git-tag-version --allow-same-version`);
// 3. Get all workspaces and filter out the one we don't want to version.
// We intend to maintain sdk, mobile-mcp, and node-repl versions independently.
// We intend to maintain sdk, mobile-mcp, node-repl, and qwen-live versions
// independently.
const workspacesToExclude = [
'@qwen-code/sdk',
'@qwen-code/mobile-mcp',
'@qwen-code/node-repl-mcp',
'@qwen-code/qwen-live',
];
const workspaceNames = JSON.parse(
execSync('npm pkg get name --workspaces --json').toString(),
+1
View File
@@ -8,6 +8,7 @@ export default defineConfig({
'packages/vscode-ide-companion',
'packages/sdk-typescript',
'packages/node-repl',
'packages/qwen-live',
'packages/channels/base',
'packages/channels/dingtalk',
'packages/channels/dws',