mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* 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]>
1141 lines
39 KiB
TypeScript
1141 lines
39 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
/**
|
|
* One live call, end to end: owns the realtime connection, routes audio
|
|
* between the Host and the provider, dispatches the seven orchestration
|
|
* tools, pumps backend events into the injector, and drains gracefully on
|
|
* stop.
|
|
*
|
|
* The state-machine disciplines are ported from qwen-code's
|
|
* live-session-coordinator (epoch + generation fences, bounded stop drain);
|
|
* the backend seam is the BackendAdaptor port instead of the in-process
|
|
* bridge.
|
|
*/
|
|
|
|
import { readFile } from 'node:fs/promises';
|
|
import type {
|
|
BackendAdaptor,
|
|
BackendEvent,
|
|
BackendHandle,
|
|
ContentBlock,
|
|
} from '../adaptor/types.js';
|
|
import type { LiveScreenContextCapture } from '../host/live-host-coordinator.js';
|
|
import type { LiveState } from '../host/types.js';
|
|
import { buildLiveInstructions } from '../realtime/instructions.js';
|
|
import {
|
|
openQwenRealtimeSession,
|
|
QWEN_REALTIME_LIMITS,
|
|
type QwenRealtimeSession,
|
|
type RealtimeFunctionCall,
|
|
type RealtimeTranscriptEntry,
|
|
} from '../realtime/realtime-session.js';
|
|
import type { SessionLog } from '../log/session-log.js';
|
|
import { PermissionBroker } from '../permissions/permission-broker.js';
|
|
import {
|
|
APPSHOT_TOOL_NAME,
|
|
HANDOFF_TOOL_NAME,
|
|
LIVE_SESSION_TOOLS,
|
|
RESPOND_PERMISSION_TOOL_NAME,
|
|
SESSION_CREATE_TOOL_NAME,
|
|
SESSION_LIST_TOOL_NAME,
|
|
SESSION_MONITOR_TOOL_NAME,
|
|
SESSION_STOP_TOOL_NAME,
|
|
} from '../tools/definitions.js';
|
|
import {
|
|
ToolDispatcher,
|
|
type ToolContext,
|
|
type ToolHandler,
|
|
} from '../tools/dispatcher.js';
|
|
import { HandleRegistry, type JobRecord } from '../tools/handles.js';
|
|
import { Injector } from './injector.js';
|
|
|
|
const DEFAULT_GRACEFUL_STOP_DRAIN_MS = 30_000;
|
|
const MAX_ACCESSIBILITY_CHARS = 8_000;
|
|
const MAX_VOICE_CONTEXT_ENTRIES = 12;
|
|
const MAX_VOICE_CONTEXT_CHARS = 4_000;
|
|
const MAX_SPOKEN_SUMMARY_CHARS = 200;
|
|
|
|
/**
|
|
* The Host surface LiveSession drives. Structurally satisfied by the ported
|
|
* LiveHostCoordinator.
|
|
*/
|
|
export interface LiveHostControl {
|
|
setCallState(
|
|
epoch: number,
|
|
state: Exclude<LiveState, 'unavailable' | 'idle'>,
|
|
): boolean;
|
|
/** Registers the live session as the appshot-authorized caller. */
|
|
setCoordinator(
|
|
epoch: number,
|
|
locator: { workspaceCwd: string; sessionId: string },
|
|
): boolean;
|
|
sendOutputAudio(epoch: number, pcm16: Uint8Array): boolean;
|
|
clearOutput(epoch: number): void;
|
|
setCaption(epoch: number, caption: string): boolean;
|
|
setStatusText(epoch: number, statusText?: string): boolean;
|
|
setTranscript?(epoch: number, transcript: string): boolean;
|
|
failCall(epoch: number, message?: string): boolean;
|
|
captureScreenContext(
|
|
callerSessionId: string,
|
|
): Promise<LiveScreenContextCapture>;
|
|
}
|
|
|
|
export interface LiveRealtimeConfig {
|
|
endpoint: string;
|
|
apiKey?: string;
|
|
model: string;
|
|
voice?: string;
|
|
}
|
|
|
|
export interface LiveSessionOptions {
|
|
host: LiveHostControl;
|
|
adaptor: BackendAdaptor;
|
|
realtime: LiveRealtimeConfig;
|
|
log: SessionLog;
|
|
openRealtime?: typeof openQwenRealtimeSession;
|
|
gracefulStopDrainMs?: number;
|
|
}
|
|
|
|
interface CallContext {
|
|
epoch: number;
|
|
callId: string;
|
|
realtime?: QwenRealtimeSession;
|
|
stopping: boolean;
|
|
speechInProgress: boolean;
|
|
responseInFlight: boolean;
|
|
caption: string;
|
|
defaultSessionHandle?: string;
|
|
/** Per backend-session event pump cancellation. */
|
|
pumps: Map<string, AbortController>;
|
|
injector: Injector;
|
|
broker: PermissionBroker;
|
|
stopResolve?: (outcome: void | { error: string }) => void;
|
|
}
|
|
|
|
/**
|
|
* Sentence boundaries for spoken clamps. ASCII terminators count only when
|
|
* followed by whitespace or end-of-string (a period inside a file path, IP,
|
|
* or version must not end a "sentence"); CJK terminators (。!?) count
|
|
* unconditionally — standard CJK typography puts no space after them.
|
|
*/
|
|
const SENTENCE_BOUNDARY = /(?<=[.!?])\s+|(?<=[。!?])\s*/;
|
|
|
|
function splitSentences(text: string): string[] {
|
|
return text
|
|
.split(SENTENCE_BOUNDARY)
|
|
.map((part) => part.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function firstSentence(text: string, max: number): string {
|
|
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
if (!trimmed) return '';
|
|
const sentence = splitSentences(trimmed)[0] ?? trimmed;
|
|
return sentence.length > max ? `${sentence.slice(0, max)}…` : sentence;
|
|
}
|
|
|
|
/**
|
|
* The spoken take-away from a long result: its closing sentence. Backend
|
|
* summaries are tail-clamped, so the head may start mid-sentence — the
|
|
* final sentence is the model's own conclusion and always complete.
|
|
*/
|
|
function lastSentence(text: string, max: number): string {
|
|
const trimmed = text.trim().replace(/\s+/g, ' ');
|
|
if (!trimmed) return '';
|
|
const parts = splitSentences(trimmed);
|
|
const sentence = parts[parts.length - 1] ?? trimmed;
|
|
return sentence.length > max ? `${sentence.slice(0, max)}…` : sentence;
|
|
}
|
|
|
|
function formatVoiceContext(
|
|
entries: readonly RealtimeTranscriptEntry[],
|
|
): string {
|
|
const recent = entries.slice(-MAX_VOICE_CONTEXT_ENTRIES);
|
|
let block = recent
|
|
.map(
|
|
(entry) =>
|
|
`${entry.role === 'user' ? 'User' : 'Assistant'}: ${entry.text}`,
|
|
)
|
|
.join('\n');
|
|
if (block.length > MAX_VOICE_CONTEXT_CHARS) {
|
|
block = `…${block.slice(block.length - MAX_VOICE_CONTEXT_CHARS)}`;
|
|
}
|
|
return block;
|
|
}
|
|
|
|
export class LiveSession {
|
|
private readonly host: LiveHostControl;
|
|
private readonly adaptor: BackendAdaptor;
|
|
private readonly log: SessionLog;
|
|
private readonly openRealtime: typeof openQwenRealtimeSession;
|
|
private readonly gracefulStopDrainMs: number;
|
|
private readonly handles = new HandleRegistry();
|
|
private active?: CallContext;
|
|
|
|
constructor(private readonly options: LiveSessionOptions) {
|
|
this.host = options.host;
|
|
this.adaptor = options.adaptor;
|
|
this.log = options.log;
|
|
this.openRealtime = options.openRealtime ?? openQwenRealtimeSession;
|
|
this.gracefulStopDrainMs =
|
|
options.gracefulStopDrainMs ?? DEFAULT_GRACEFUL_STOP_DRAIN_MS;
|
|
}
|
|
|
|
/** LiveCallHandlers.onStart */
|
|
async start(call: {
|
|
epoch: number;
|
|
callId: string;
|
|
mode: 'resume' | 'new';
|
|
}): Promise<void> {
|
|
this.closeActive();
|
|
const context: CallContext = {
|
|
epoch: call.epoch,
|
|
callId: call.callId,
|
|
stopping: false,
|
|
speechInProgress: false,
|
|
responseInFlight: false,
|
|
caption: '',
|
|
pumps: new Map(),
|
|
injector: new Injector({
|
|
sink: {
|
|
injectContext: (text) => this.injectContext(context, text),
|
|
injectSpeech: (text) => this.injectSpeech(context, text),
|
|
onInjected: (item, spoken) => {
|
|
this.log.write(spoken ? 'inject.speech' : 'inject.context', {
|
|
kind: item.kind,
|
|
job: item.jobHandle,
|
|
chars: item.context.length,
|
|
});
|
|
},
|
|
},
|
|
}),
|
|
broker: new PermissionBroker({
|
|
adaptor: this.adaptor,
|
|
log: (type, payload) => this.log.write(type, payload),
|
|
}),
|
|
};
|
|
this.active = context;
|
|
this.log.write('session.start', {
|
|
callId: call.callId,
|
|
epoch: call.epoch,
|
|
mode: call.mode,
|
|
adaptor: this.adaptor.name,
|
|
model: this.options.realtime.model,
|
|
voice: this.options.realtime.voice,
|
|
});
|
|
this.host.setCallState(call.epoch, 'starting');
|
|
// Register the live call itself as the appshot-authorized caller; the
|
|
// ported host coordinator gates screen capture on this locator.
|
|
this.host.setCoordinator(call.epoch, {
|
|
workspaceCwd: '/',
|
|
sessionId: call.callId,
|
|
});
|
|
|
|
try {
|
|
const realtime = await this.openRealtime(
|
|
{
|
|
endpoint: this.options.realtime.endpoint,
|
|
...(this.options.realtime.apiKey
|
|
? { apiKey: this.options.realtime.apiKey }
|
|
: {}),
|
|
model: this.options.realtime.model,
|
|
callEpoch: call.epoch,
|
|
...(this.options.realtime.voice
|
|
? { voice: this.options.realtime.voice }
|
|
: {}),
|
|
instructions: buildLiveInstructions(),
|
|
tools: LIVE_SESSION_TOOLS,
|
|
},
|
|
this.callbacksFor(context),
|
|
);
|
|
if (this.active !== context || context.stopping) {
|
|
realtime.close({ discardPendingInput: true });
|
|
return;
|
|
}
|
|
context.realtime = realtime;
|
|
this.host.setCallState(call.epoch, 'listening');
|
|
} catch (error) {
|
|
this.log.write('error', {
|
|
source: 'realtime',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
if (this.active === context) {
|
|
this.host.failCall(call.epoch, 'Live Voice could not connect.');
|
|
this.active = undefined;
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/** LiveCallHandlers.onStop */
|
|
stop(call: {
|
|
epoch: number;
|
|
callId: string;
|
|
}): Promise<void | { error: string }> {
|
|
const context = this.active;
|
|
if (!context || context.epoch !== call.epoch) return Promise.resolve();
|
|
if (context.stopping) {
|
|
return new Promise((resolve) => {
|
|
const previous = context.stopResolve;
|
|
context.stopResolve = (outcome) => {
|
|
previous?.(outcome);
|
|
resolve(outcome);
|
|
};
|
|
});
|
|
}
|
|
context.stopping = true;
|
|
this.host.clearOutput(context.epoch);
|
|
this.host.setCallState(context.epoch, 'stopping');
|
|
|
|
return new Promise((resolve) => {
|
|
context.stopResolve = resolve;
|
|
const finish = (outcome: void | { error: string }) => {
|
|
if (this.active === context) this.finishStop(context, outcome);
|
|
};
|
|
// Commit any trailing speech so the provider transcribes it, then wait
|
|
// for the in-flight response to settle — bounded by the drain budget.
|
|
// The commit ack (onInputCommitted) clears speechInProgress, so the
|
|
// drain settles deterministically instead of burning the full budget.
|
|
if (context.speechInProgress) {
|
|
let committed = false;
|
|
try {
|
|
committed = context.realtime?.commitInputAudio() ?? false;
|
|
} catch {
|
|
committed = false;
|
|
}
|
|
if (!committed) {
|
|
finish({
|
|
error: 'Live Voice could not commit the final spoken input.',
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
if (!context.responseInFlight && !context.speechInProgress) {
|
|
finish(undefined);
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => {
|
|
finish({
|
|
error:
|
|
'Live Voice could not confirm the final spoken input before the stop deadline.',
|
|
});
|
|
}, this.gracefulStopDrainMs);
|
|
timer.unref?.();
|
|
context.injector.dispose();
|
|
const poll = setInterval(() => {
|
|
if (this.active !== context) {
|
|
clearInterval(poll);
|
|
clearTimeout(timer);
|
|
return;
|
|
}
|
|
if (!context.responseInFlight && !context.speechInProgress) {
|
|
clearInterval(poll);
|
|
clearTimeout(timer);
|
|
finish(undefined);
|
|
}
|
|
}, 100);
|
|
poll.unref?.();
|
|
});
|
|
}
|
|
|
|
/** LiveCallHandlers.onInputAudio */
|
|
pushAudio(call: { epoch: number; callId: string; pcm16: Buffer }): boolean {
|
|
const context = this.active;
|
|
if (!context || context.epoch !== call.epoch || context.stopping) {
|
|
return true; // stale frames are dropped, not fatal
|
|
}
|
|
if (!context.realtime) return true; // still connecting
|
|
try {
|
|
// Propagate the provider's backpressure signal: a false return
|
|
// means the socket buffer is over its cap and frames are being
|
|
// dropped — the port source fails the call rather than letting VAD
|
|
// and transcription run on a gappy utterance.
|
|
return context.realtime.pushAudio(call.pcm16);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
dispose(): void {
|
|
this.closeActive();
|
|
}
|
|
|
|
// -- realtime callbacks ---------------------------------------------------
|
|
|
|
private callbacksFor(context: CallContext) {
|
|
const current = (session?: QwenRealtimeSession): boolean =>
|
|
this.active === context &&
|
|
(context.realtime === undefined || session === undefined
|
|
? true
|
|
: context.realtime === session);
|
|
|
|
return {
|
|
onReady: () => {
|
|
if (!current()) return;
|
|
this.log.write('session.start', { phase: 'realtime_ready' });
|
|
},
|
|
onSpeechStarted: () => {
|
|
if (!current()) return;
|
|
context.speechInProgress = true;
|
|
context.injector.noteSpeechStarted();
|
|
this.log.write('vad.speech_started', {});
|
|
},
|
|
onSpeechStopped: () => {
|
|
if (!current()) return;
|
|
context.speechInProgress = false;
|
|
context.injector.noteSpeechStopped();
|
|
this.log.write('vad.speech_stopped', {});
|
|
},
|
|
// The provider's input-commit ack: the utterance is out of the buffer,
|
|
// so speech is no longer "in progress" for the stop drain / injector.
|
|
// Once stopping, pushAudio drops frames, so this ack (or the transcript
|
|
// final below) is the only remaining clearer.
|
|
onInputCommitted: () => {
|
|
if (!current()) return;
|
|
context.speechInProgress = false;
|
|
this.log.write('vad.speech_stopped', { phase: 'input_committed' });
|
|
},
|
|
onInputTranscriptDone: (event: { text: string }) => {
|
|
if (!current()) return;
|
|
context.speechInProgress = false;
|
|
this.host.setTranscript?.(context.epoch, event.text);
|
|
this.log.write('transcript.user', { text: event.text });
|
|
},
|
|
onOutputTextDelta: (event: { text: string; source: string }) => {
|
|
if (!current()) return;
|
|
context.caption = `${context.caption}${event.text}`;
|
|
this.host.setCaption(context.epoch, context.caption);
|
|
},
|
|
onOutputTextDone: (event: { text: string }) => {
|
|
if (!current()) return;
|
|
context.caption = '';
|
|
this.log.write('transcript.assistant', { text: event.text });
|
|
},
|
|
onOutputAudioDelta: (event: { audio: Uint8Array }) => {
|
|
if (!current()) return;
|
|
this.host.sendOutputAudio(context.epoch, event.audio);
|
|
context.injector.noteOutputAudio(event.audio.byteLength);
|
|
},
|
|
onResponseCreated: (event: { responseId: string; authority: string }) => {
|
|
if (!current()) return;
|
|
context.responseInFlight = true;
|
|
context.injector.noteResponseCreated();
|
|
// During the stop drain the call state must stay 'stopping' — a
|
|
// 'speaking' flip here would strand the coordinator (its finish/fail
|
|
// paths early-return unless the call is still 'stopping').
|
|
if (!context.stopping) {
|
|
this.host.setCallState(context.epoch, 'speaking');
|
|
}
|
|
this.log.write('response.created', {
|
|
responseId: event.responseId,
|
|
authority: event.authority,
|
|
});
|
|
},
|
|
onResponseDone: (event: { responseId: string }) => {
|
|
if (!current()) return;
|
|
context.responseInFlight = false;
|
|
context.caption = '';
|
|
context.injector.noteResponseDone();
|
|
if (!context.stopping) {
|
|
this.host.setCallState(context.epoch, 'listening');
|
|
}
|
|
this.log.write('response.done', { responseId: event.responseId });
|
|
},
|
|
onBargeIn: (event: { responseId: string }) => {
|
|
if (!current()) return;
|
|
this.host.clearOutput(context.epoch);
|
|
this.host.setCaption(context.epoch, '');
|
|
this.host.setStatusText(context.epoch);
|
|
context.injector.noteOutputCleared();
|
|
this.log.write('playback.cleared', {
|
|
reason: 'barge_in',
|
|
responseId: event.responseId,
|
|
});
|
|
},
|
|
onFunctionCall: (event: RealtimeFunctionCall) => {
|
|
if (!current()) return;
|
|
void this.dispatchTool(context, event);
|
|
},
|
|
onDirectTranscript: (event: {
|
|
entries: readonly RealtimeTranscriptEntry[];
|
|
}) => {
|
|
if (!current()) return;
|
|
for (const entry of event.entries) {
|
|
this.log.write(
|
|
entry.role === 'user' ? 'transcript.user' : 'transcript.assistant',
|
|
{ text: entry.text, direct: true },
|
|
);
|
|
}
|
|
},
|
|
onAudioDropped: () => {
|
|
// The provider is dropping mic frames (socket buffer over its
|
|
// cap): speech would run on a gappy utterance with no error
|
|
// surfaced — fail the call instead, mirroring the port source.
|
|
if (this.active !== context) return;
|
|
this.log.write('error', {
|
|
source: 'realtime',
|
|
message: 'audio frames were dropped: provider socket backpressured',
|
|
});
|
|
this.host.failCall(context.epoch, 'audio frames were dropped');
|
|
},
|
|
onError: (error: { message: string; fatal: boolean; code?: string }) => {
|
|
if (!current()) return;
|
|
this.log.write('error', {
|
|
source: 'realtime',
|
|
code: error.code,
|
|
message: error.message,
|
|
fatal: error.fatal,
|
|
});
|
|
if (error.fatal && context.stopping) {
|
|
// The socket is done for; the stop drain would otherwise wait the
|
|
// full budget for response/speech flags that can never settle.
|
|
context.responseInFlight = false;
|
|
context.speechInProgress = false;
|
|
}
|
|
if (error.fatal && !context.stopping) {
|
|
this.host.failCall(context.epoch, 'Live Voice failed.');
|
|
this.cleanupContext(context);
|
|
}
|
|
},
|
|
onClose: (info: { reason: string }) => {
|
|
if (this.active !== context) return;
|
|
this.log.write('session.end', { reason: info.reason });
|
|
if (context.stopping) {
|
|
context.responseInFlight = false;
|
|
context.speechInProgress = false;
|
|
return;
|
|
}
|
|
if (info.reason !== 'client') {
|
|
this.host.failCall(context.epoch, 'Live Voice disconnected.');
|
|
this.cleanupContext(context);
|
|
}
|
|
},
|
|
};
|
|
}
|
|
|
|
// -- tools ----------------------------------------------------------------
|
|
|
|
private async dispatchTool(
|
|
context: CallContext,
|
|
event: RealtimeFunctionCall,
|
|
): Promise<void> {
|
|
const dispatcher = new ToolDispatcher({
|
|
handlers: this.toolHandlers(context),
|
|
});
|
|
if (!context.stopping) {
|
|
this.host.setCallState(context.epoch, 'thinking');
|
|
}
|
|
this.log.write('tool.call', {
|
|
name: event.name,
|
|
callId: event.callId,
|
|
args: event.arguments.slice(0, 2_000),
|
|
});
|
|
const ctx: ToolContext = { activeTranscript: event.activeTranscript };
|
|
const result = await dispatcher.dispatch(event.name, event.arguments, ctx);
|
|
// The realtime session rejects empty or oversized outputs; a stranded
|
|
// call would hang that response's arbitration. Clamp defensively.
|
|
let receipt = result.receipt;
|
|
if (receipt.length > QWEN_REALTIME_LIMITS.maxFunctionOutputChars) {
|
|
receipt = JSON.stringify({
|
|
status: 'error',
|
|
note: 'The result was too large to return; check the session on screen.',
|
|
});
|
|
}
|
|
if (!receipt.trim()) receipt = '{}';
|
|
this.log.write('tool.result', {
|
|
name: event.name,
|
|
callId: event.callId,
|
|
ok: result.ok,
|
|
receipt: receipt.slice(0, 2_000),
|
|
});
|
|
if (this.active !== context || !context.realtime) return;
|
|
try {
|
|
context.realtime.submitFunctionOutput(
|
|
{ callEpoch: context.epoch, callId: event.callId },
|
|
receipt,
|
|
);
|
|
} catch (error) {
|
|
this.log.write('error', {
|
|
source: 'tool_output',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
}
|
|
|
|
private toolHandlers(context: CallContext): ReadonlyMap<string, ToolHandler> {
|
|
const handlers = new Map<string, ToolHandler>();
|
|
|
|
handlers.set(APPSHOT_TOOL_NAME, async () => {
|
|
const capture = await this.host.captureScreenContext(context.callId);
|
|
const asset = this.handles.registerAsset({
|
|
path: capture.screenshotPath,
|
|
mimeType: 'image/png',
|
|
});
|
|
return {
|
|
status: 'ok',
|
|
app: capture.appName,
|
|
...(capture.windowTitle ? { window: capture.windowTitle } : {}),
|
|
accessibility_text: capture.accessibilityText.slice(
|
|
0,
|
|
MAX_ACCESSIBILITY_CHARS,
|
|
),
|
|
asset: asset.assetHandle,
|
|
};
|
|
});
|
|
|
|
handlers.set(SESSION_LIST_TOOL_NAME, async () => {
|
|
const sessions = await this.adaptor.listSessions();
|
|
return {
|
|
status: 'ok',
|
|
sessions: sessions.map((summary) => {
|
|
const handle = this.handles.session(summary.handle);
|
|
// Reconcile stale non-terminal jobs: a turn_complete emitted
|
|
// while no pump was subscribed (pumps are per-call and aborted
|
|
// at call end) would otherwise keep session_list reporting a
|
|
// running active_job forever. Gated on the backend's own idle
|
|
// report so a genuinely busy session is never touched.
|
|
if (
|
|
summary.state !== 'busy' &&
|
|
!this.adaptor.isBusy(summary.handle)
|
|
) {
|
|
this.handles.reconcileIdleSession(handle);
|
|
}
|
|
const activeJob = this.handles.activeJobForSession(handle);
|
|
return {
|
|
handle,
|
|
...(summary.label ? { label: summary.label } : {}),
|
|
...(summary.cwd ? { cwd: summary.cwd } : {}),
|
|
state: this.adaptor.isBusy(summary.handle) ? 'busy' : summary.state,
|
|
...(activeJob ? { active_job: activeJob.jobHandle } : {}),
|
|
};
|
|
}),
|
|
};
|
|
});
|
|
|
|
handlers.set(SESSION_CREATE_TOOL_NAME, async (args) => {
|
|
const backend = await this.adaptor.createSession({
|
|
...(typeof args['cwd'] === 'string' ? { cwd: args['cwd'] } : {}),
|
|
...(typeof args['label'] === 'string' ? { label: args['label'] } : {}),
|
|
});
|
|
const handle = this.handles.session(backend);
|
|
this.ensurePump(context, handle, backend);
|
|
return { status: 'ok', handle };
|
|
});
|
|
|
|
handlers.set(HANDOFF_TOOL_NAME, async (args, ctx) => {
|
|
const task = typeof args['task'] === 'string' ? args['task'].trim() : '';
|
|
if (!task) {
|
|
return { status: 'error', note: 'handoff needs a task.' };
|
|
}
|
|
const target = await this.resolveHandoffTarget(context, args['session']);
|
|
if ('error' in target) return { status: 'error', note: target.error };
|
|
const { handle, backend } = target;
|
|
|
|
const blocks = await this.buildHandoffBlocks(
|
|
task,
|
|
ctx.activeTranscript,
|
|
args['input_refs'],
|
|
);
|
|
const busy = this.adaptor.isBusy(backend);
|
|
const receipt = await this.adaptor.prompt(backend, blocks, {
|
|
steer: busy,
|
|
});
|
|
if (receipt.status === 'rejected') {
|
|
return {
|
|
status: 'rejected',
|
|
session: handle,
|
|
note: receipt.note ?? 'the session refused the task',
|
|
};
|
|
}
|
|
// A steer that joined the running turn comes back with that turn's
|
|
// jobRef: the instruction became part of the EXISTING job. Creating a
|
|
// second record would orphan the first in 'running' forever (nothing
|
|
// would ever transition it out).
|
|
const existing =
|
|
receipt.jobRef !== undefined
|
|
? this.handles.jobByRef(receipt.jobRef)
|
|
: undefined;
|
|
const job =
|
|
existing ??
|
|
this.handles.createJob({
|
|
sessionHandle: handle,
|
|
backend,
|
|
...(receipt.jobRef !== undefined ? { jobRef: receipt.jobRef } : {}),
|
|
task,
|
|
});
|
|
this.ensurePump(context, handle, backend);
|
|
return {
|
|
status: receipt.status,
|
|
job: job.jobHandle,
|
|
session: handle,
|
|
...(receipt.note ? { note: receipt.note } : {}),
|
|
};
|
|
});
|
|
|
|
handlers.set(SESSION_MONITOR_TOOL_NAME, (args) => {
|
|
const job =
|
|
typeof args['job'] === 'string'
|
|
? this.handles.resolveJob(args['job'])
|
|
: undefined;
|
|
const sessionHandle =
|
|
job?.sessionHandle ??
|
|
(typeof args['session'] === 'string' ? args['session'].trim() : '');
|
|
const backend = this.handles.resolveSession(sessionHandle);
|
|
if (!backend) {
|
|
return {
|
|
status: 'error',
|
|
note: 'unknown session; call session_list first.',
|
|
};
|
|
}
|
|
if (!this.adaptor.isBusy(backend)) {
|
|
this.handles.reconcileIdleSession(sessionHandle);
|
|
}
|
|
const activeJob = job ?? this.handles.activeJobForSession(sessionHandle);
|
|
return {
|
|
status: 'ok',
|
|
session: sessionHandle,
|
|
state: this.adaptor.isBusy(backend) ? 'busy' : 'idle',
|
|
...(activeJob
|
|
? {
|
|
job: activeJob.jobHandle,
|
|
job_state: activeJob.state,
|
|
task: activeJob.task.slice(0, 200),
|
|
}
|
|
: {}),
|
|
};
|
|
});
|
|
|
|
handlers.set(SESSION_STOP_TOOL_NAME, async (args) => {
|
|
const job =
|
|
typeof args['job'] === 'string'
|
|
? this.handles.resolveJob(args['job'])
|
|
: undefined;
|
|
const sessionHandle =
|
|
job?.sessionHandle ??
|
|
(typeof args['session'] === 'string' ? args['session'].trim() : '');
|
|
const backend =
|
|
job?.backend ?? this.handles.resolveSession(sessionHandle);
|
|
if (!backend) {
|
|
return {
|
|
status: 'error',
|
|
note: 'unknown session or job; call session_list first.',
|
|
};
|
|
}
|
|
await this.adaptor.cancel(backend);
|
|
if (job) job.state = 'cancelled';
|
|
return { status: 'cancelling', session: sessionHandle };
|
|
});
|
|
|
|
handlers.set(RESPOND_PERMISSION_TOOL_NAME, async (args) => {
|
|
const requestHandle =
|
|
typeof args['request_id'] === 'string' ? args['request_id'] : '';
|
|
const decision = args['decision'];
|
|
if (
|
|
decision !== 'allow' &&
|
|
decision !== 'allow_always' &&
|
|
decision !== 'deny'
|
|
) {
|
|
return {
|
|
status: 'error',
|
|
note: 'decision must be allow, allow_always, or deny.',
|
|
};
|
|
}
|
|
const note = typeof args['note'] === 'string' ? args['note'].trim() : '';
|
|
// Resolve before respond(): a delivered vote clears the pending entry,
|
|
// and the backend handle is needed to relay the user's constraint.
|
|
const pending = context.broker.resolveHandle(requestHandle);
|
|
const outcome = await context.broker.respond(
|
|
requestHandle,
|
|
decision,
|
|
note || undefined,
|
|
);
|
|
if (outcome === 'not_found') {
|
|
return {
|
|
status: 'error',
|
|
note: `no pending request ${requestHandle}.`,
|
|
};
|
|
}
|
|
// The vote channel carries no free text; a user constraint ("only this
|
|
// file") would otherwise be silently discarded — the grant would be
|
|
// broader than the user believes. Relay it as a user instruction to
|
|
// the same backend session through the existing prompt/steer path.
|
|
if (note && pending && outcome === 'delivered') {
|
|
try {
|
|
await this.adaptor.prompt(
|
|
pending.backend,
|
|
[
|
|
{
|
|
type: 'text',
|
|
text:
|
|
`The user answered the permission request "${pending.title}" ` +
|
|
`with "${decision}" and added this constraint, which you must ` +
|
|
`follow: ${note}`,
|
|
},
|
|
],
|
|
{ steer: this.adaptor.isBusy(pending.backend) },
|
|
);
|
|
} catch (error) {
|
|
this.log.write('error', {
|
|
source: 'permission',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
return {
|
|
status: outcome,
|
|
note:
|
|
'The vote was delivered, but the added constraint could not ' +
|
|
'be relayed to the session; tell the user to check it on screen.',
|
|
};
|
|
}
|
|
}
|
|
return { status: outcome };
|
|
});
|
|
|
|
return handlers;
|
|
}
|
|
|
|
private async resolveHandoffTarget(
|
|
context: CallContext,
|
|
sessionArg: unknown,
|
|
): Promise<{ handle: string; backend: BackendHandle } | { error: string }> {
|
|
if (typeof sessionArg === 'string' && sessionArg.trim()) {
|
|
const backend = this.handles.resolveSession(sessionArg);
|
|
if (!backend) {
|
|
return { error: `unknown session ${sessionArg}; call session_list.` };
|
|
}
|
|
return { handle: sessionArg.trim(), backend };
|
|
}
|
|
if (context.defaultSessionHandle) {
|
|
const backend = this.handles.resolveSession(context.defaultSessionHandle);
|
|
if (backend) {
|
|
return { handle: context.defaultSessionHandle, backend };
|
|
}
|
|
}
|
|
const backend = await this.adaptor.createSession({ label: 'Voice chat' });
|
|
const handle = this.handles.session(backend);
|
|
context.defaultSessionHandle = handle;
|
|
return { handle, backend };
|
|
}
|
|
|
|
private async buildHandoffBlocks(
|
|
task: string,
|
|
activeTranscript: readonly RealtimeTranscriptEntry[],
|
|
inputRefs: unknown,
|
|
): Promise<ContentBlock[]> {
|
|
const parts = [task];
|
|
const voiceContext = formatVoiceContext(activeTranscript);
|
|
if (voiceContext) {
|
|
parts.push(
|
|
`<recent_voice_context>\nRelayed from the user's live voice conversation; use it only to resolve references in the task.\n${voiceContext}\n</recent_voice_context>`,
|
|
);
|
|
}
|
|
const blocks: ContentBlock[] = [{ type: 'text', text: parts.join('\n\n') }];
|
|
if (Array.isArray(inputRefs)) {
|
|
for (const ref of inputRefs) {
|
|
if (typeof ref !== 'string') continue;
|
|
const asset = this.handles.resolveAsset(ref);
|
|
if (!asset) continue;
|
|
try {
|
|
const data = await readFile(asset.path);
|
|
blocks.push({
|
|
type: 'image',
|
|
mimeType: asset.mimeType,
|
|
data: new Uint8Array(data),
|
|
name: `${asset.assetHandle}.png`,
|
|
});
|
|
} catch {
|
|
/* the capture expired; the text task still stands */
|
|
}
|
|
}
|
|
}
|
|
return blocks;
|
|
}
|
|
|
|
// -- backend event pump ---------------------------------------------------
|
|
|
|
private ensurePump(
|
|
context: CallContext,
|
|
sessionHandle: string,
|
|
backend: BackendHandle,
|
|
): void {
|
|
if (context.pumps.has(sessionHandle)) return;
|
|
const abort = new AbortController();
|
|
context.pumps.set(sessionHandle, abort);
|
|
void this.pump(context, sessionHandle, backend, abort.signal).catch(
|
|
(error) => {
|
|
this.log.write('error', {
|
|
source: 'pump',
|
|
session: sessionHandle,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
},
|
|
);
|
|
}
|
|
|
|
private async pump(
|
|
context: CallContext,
|
|
sessionHandle: string,
|
|
backend: BackendHandle,
|
|
signal: AbortSignal,
|
|
): Promise<void> {
|
|
// The SSE stream can end without a session_closed (daemon restart,
|
|
// dropped connection). Resubscribe with backoff instead of leaving the
|
|
// session permanently unobserved — completion events would be lost.
|
|
let backoffMs = 1_000;
|
|
while (this.active === context && !signal.aborted) {
|
|
let sawEvent = false;
|
|
try {
|
|
for await (const event of this.adaptor.events(backend, { signal })) {
|
|
if (this.active !== context) return;
|
|
sawEvent = true;
|
|
backoffMs = 1_000;
|
|
this.log.write('backend.event', {
|
|
session: sessionHandle,
|
|
type: event.type,
|
|
...('jobRef' in event && event.jobRef !== undefined
|
|
? { jobRef: event.jobRef }
|
|
: {}),
|
|
});
|
|
this.onBackendEvent(context, sessionHandle, backend, event);
|
|
if (event.type === 'session_closed') {
|
|
context.pumps.delete(sessionHandle);
|
|
return;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
if (signal.aborted || this.active !== context) break;
|
|
this.log.write('error', {
|
|
source: 'pump',
|
|
session: sessionHandle,
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
if (signal.aborted || this.active !== context) break;
|
|
this.log.write('backend.event', {
|
|
session: sessionHandle,
|
|
type: 'stream_ended',
|
|
resubscribeInMs: backoffMs,
|
|
sawEvent,
|
|
});
|
|
await new Promise<void>((resolve) => {
|
|
const timer = setTimeout(resolve, backoffMs);
|
|
timer.unref?.();
|
|
});
|
|
backoffMs = Math.min(backoffMs * 2, 10_000);
|
|
}
|
|
context.pumps.delete(sessionHandle);
|
|
}
|
|
|
|
private onBackendEvent(
|
|
context: CallContext,
|
|
sessionHandle: string,
|
|
backend: BackendHandle,
|
|
event: BackendEvent,
|
|
): void {
|
|
switch (event.type) {
|
|
case 'turn_started': {
|
|
const job = event.jobRef
|
|
? this.handles.jobByRef(event.jobRef)
|
|
: undefined;
|
|
if (job) job.state = 'running';
|
|
return;
|
|
}
|
|
case 'progress': {
|
|
const job = this.jobFor(sessionHandle, event.jobRef);
|
|
context.injector.enqueue({
|
|
kind: 'progress',
|
|
context: `[PROGRESS ${job?.jobHandle ?? sessionHandle}] ${event.summary}`,
|
|
...(job ? { jobHandle: job.jobHandle } : {}),
|
|
});
|
|
return;
|
|
}
|
|
case 'speak': {
|
|
context.injector.enqueue({
|
|
kind: 'speak',
|
|
context: `[BACKEND ${sessionHandle}] ${event.text}`,
|
|
spoken: event.text.slice(0, MAX_SPOKEN_SUMMARY_CHARS),
|
|
});
|
|
return;
|
|
}
|
|
case 'turn_complete': {
|
|
const job = this.jobFor(sessionHandle, event.jobRef);
|
|
if (job) job.state = 'done';
|
|
const label = job?.jobHandle ?? sessionHandle;
|
|
const spokenSummary = lastSentence(
|
|
event.summary,
|
|
MAX_SPOKEN_SUMMARY_CHARS,
|
|
);
|
|
context.injector.enqueue({
|
|
kind: 'complete',
|
|
context: `[COMPLETE ${label}] ${event.detail ?? event.summary}`,
|
|
spoken: spokenSummary
|
|
? `Task ${label} finished. ${spokenSummary}`
|
|
: `Task ${label} finished.`,
|
|
...(job ? { jobHandle: job.jobHandle } : {}),
|
|
});
|
|
return;
|
|
}
|
|
case 'turn_error': {
|
|
const job = this.jobFor(sessionHandle, event.jobRef);
|
|
if (job)
|
|
job.state = event.error === 'cancelled' ? 'cancelled' : 'failed';
|
|
const label = job?.jobHandle ?? sessionHandle;
|
|
if (event.error === 'cancelled') {
|
|
context.injector.enqueue({
|
|
kind: 'complete',
|
|
context: `[COMPLETE ${label}] cancelled at the user's request.`,
|
|
});
|
|
return;
|
|
}
|
|
context.injector.enqueue({
|
|
kind: 'error',
|
|
context: `[ERROR ${label}] ${event.error}`,
|
|
spoken: `Task ${label} hit a problem. ${firstSentence(event.error, 120)}`,
|
|
...(job ? { jobHandle: job.jobHandle } : {}),
|
|
});
|
|
return;
|
|
}
|
|
case 'permission_request': {
|
|
void context.broker
|
|
.onRequest({
|
|
requestId: event.requestId,
|
|
backend,
|
|
sessionHandle,
|
|
title: event.title,
|
|
options: event.options,
|
|
})
|
|
.then((ask) => {
|
|
if (ask.autoAnswered || this.active !== context) return;
|
|
context.injector.enqueue({
|
|
kind: 'permission',
|
|
requestId: event.requestId,
|
|
context: `[PERMISSION ${ask.pending.requestHandle}] Session ${sessionHandle} wants to run: ${event.title}. Ask the user and relay their answer with respond_permission.`,
|
|
spoken: `The task wants to ${event.title}. Should I allow it?`,
|
|
});
|
|
})
|
|
.catch((error: unknown) => {
|
|
// A rejected broker chain must never become an unhandled
|
|
// rejection (it would take the whole daemon down mid-call).
|
|
this.log.write('error', {
|
|
source: 'permission',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
});
|
|
if (this.active === context) {
|
|
context.injector.enqueue({
|
|
kind: 'error',
|
|
context: `[ERROR ${sessionHandle}] A permission request could not be processed; the task may be stuck waiting for approval.`,
|
|
spoken:
|
|
'A task is waiting for an approval I could not process. You may need to check it on screen.',
|
|
});
|
|
}
|
|
});
|
|
return;
|
|
}
|
|
case 'permission_resolved': {
|
|
const pending = context.broker.onResolved(event.requestId);
|
|
if (!event.byUs) {
|
|
const retracted = context.injector.retractPermission(event.requestId);
|
|
if (!retracted && pending) {
|
|
context.injector.enqueue({
|
|
kind: 'progress',
|
|
context: `[BACKEND ${sessionHandle}] The permission request (${pending.requestHandle}) was already handled elsewhere; no answer needed.`,
|
|
});
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
case 'session_closed': {
|
|
// A closed session must not keep resolving every session-less
|
|
// handoff to a dead target for the rest of the call (WebShell
|
|
// deletion, daemon restart, idle reaper): stop resolving its
|
|
// handle entirely and clear the default so
|
|
// resolveHandoffTarget's createSession fall-through rebuilds.
|
|
this.handles.closeSession(sessionHandle);
|
|
if (context.defaultSessionHandle === sessionHandle) {
|
|
context.defaultSessionHandle = undefined;
|
|
}
|
|
return;
|
|
}
|
|
default:
|
|
return;
|
|
}
|
|
}
|
|
|
|
private jobFor(
|
|
sessionHandle: string,
|
|
jobRef: string | undefined,
|
|
): JobRecord | undefined {
|
|
if (jobRef) {
|
|
const byRef = this.handles.jobByRef(jobRef);
|
|
if (byRef) return byRef;
|
|
}
|
|
return this.handles.activeJobForSession(sessionHandle);
|
|
}
|
|
|
|
// -- injection sinks -------------------------------------------------------
|
|
|
|
private injectContext(context: CallContext, text: string): boolean {
|
|
if (this.active !== context || !context.realtime || context.stopping) {
|
|
return false;
|
|
}
|
|
try {
|
|
return context.realtime.sendBackendContext(text);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
private injectSpeech(context: CallContext, text: string): boolean {
|
|
if (this.active !== context || !context.realtime || context.stopping) {
|
|
return false;
|
|
}
|
|
try {
|
|
return context.realtime.speakToUser(text);
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// -- teardown ---------------------------------------------------------------
|
|
|
|
private finishStop(
|
|
context: CallContext,
|
|
outcome: void | { error: string },
|
|
): void {
|
|
const resolve = context.stopResolve;
|
|
context.stopResolve = undefined;
|
|
this.cleanupContext(context);
|
|
this.log.write('session.end', {
|
|
callId: context.callId,
|
|
...(outcome && 'error' in outcome ? { error: outcome.error } : {}),
|
|
});
|
|
resolve?.(outcome);
|
|
}
|
|
|
|
private cleanupContext(context: CallContext): void {
|
|
if (this.active === context) this.active = undefined;
|
|
context.injector.dispose();
|
|
for (const abort of context.pumps.values()) abort.abort();
|
|
context.pumps.clear();
|
|
try {
|
|
context.realtime?.close({ discardPendingInput: true });
|
|
} catch {
|
|
/* already closed */
|
|
}
|
|
// A stop() waiter must never be left hanging when the context is torn
|
|
// down through another path (daemon shutdown, fatal error).
|
|
const resolve = context.stopResolve;
|
|
context.stopResolve = undefined;
|
|
resolve?.(undefined);
|
|
}
|
|
|
|
private closeActive(): void {
|
|
const context = this.active;
|
|
if (!context) return;
|
|
this.cleanupContext(context);
|
|
}
|
|
}
|