mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* feat(live): add visual input, proactive memory and desktop controls * fix(live): preserve upstream checks and finalize migration validation * feat(live): open active configuration from host settings * fix(live): address lifecycle and proactive review findings * feat(live): add subagent controls and diagnostics with review fixes --------- Co-authored-by: 菲兹 <[email protected]> Co-authored-by: Shaojin Wen <[email protected]>
235 lines
7.5 KiB
TypeScript
235 lines
7.5 KiB
TypeScript
/**
|
|
* @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,
|
|
waitForLiveResponseAfter,
|
|
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>> => {
|
|
const fromIndex = stack.fakeDash.inbox.length;
|
|
conn.queueFunctionCall({
|
|
name,
|
|
argumentsJson: JSON.stringify(args),
|
|
callId,
|
|
});
|
|
conn.speakTranscript(`Please ${name}: ${JSON.stringify(args)}`);
|
|
const receiptMessage = await stack.fakeDash.waitForMessage(
|
|
(message) => functionCallOutputOf(message)?.callId === callId,
|
|
{
|
|
fromIndex,
|
|
timeoutMs: 30_000,
|
|
description: `${name} receipt ${callId}`,
|
|
},
|
|
);
|
|
if (name !== 'handoff')
|
|
await waitForLiveResponseAfter(
|
|
stack,
|
|
receiptMessage,
|
|
'tool_continuation',
|
|
);
|
|
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');
|
|
const spoken = await stack.fakeDash.waitForMessage(
|
|
(message) => {
|
|
const text = contextTextOf(message);
|
|
return (
|
|
text?.startsWith('[SPEAK_TO_USER] ') === true &&
|
|
text.includes('slow task finished')
|
|
);
|
|
},
|
|
{ fromIndex: stack.fakeDash.inbox.indexOf(complete) + 1 },
|
|
);
|
|
await waitForLiveResponseAfter(stack, spoken, 'backend_speech');
|
|
});
|
|
|
|
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 inboxIndex = stack.fakeDash.inbox.length;
|
|
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,
|
|
{
|
|
fromIndex: inboxIndex,
|
|
timeoutMs: 30_000,
|
|
description: `[COMPLETE ${job}] for the idle handoff`,
|
|
},
|
|
);
|
|
});
|
|
});
|