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]>
250 lines
8.7 KiB
TypeScript
250 lines
8.7 KiB
TypeScript
/**
|
|
* @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);
|
|
});
|
|
});
|