Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
cb26fb6
feat(web-shell): add native Live Voice
LaZzyMan Jul 27, 2026
c9d8712
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Jul 27, 2026
c941066
Merge branch 'main' into lazzy/web-shell-live-voice
wenshao Jul 27, 2026
105dba9
Merge branch 'main' into lazzy/web-shell-live-voice
wenshao Jul 27, 2026
e083779
fix(web-shell): address review feedback for Live Voice PR (#7859)
Jul 27, 2026
6b6b171
fix(cli): register realtime_voice in docs contract and env guard (#7859)
Jul 27, 2026
9199fa6
fix(web-shell): address review feedback for Live Voice PR (#7859)
Jul 27, 2026
85165f1
fix(cli): discard orphaned isolated dir when parent restore fails (#7…
Jul 27, 2026
85c4801
Merge branch 'main' into lazzy/web-shell-live-voice
qwen-code-ci-bot Jul 28, 2026
7110bec
fix(web-shell): address review feedback for Live Voice PR (#7859)
qwen-code-ci-bot Jul 28, 2026
67cb233
fix(serve): harden live turn recovery
LaZzyMan Jul 28, 2026
57f282a
fix(desktop): restore Live Host native build
LaZzyMan Jul 28, 2026
d49e314
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Jul 28, 2026
e653a6f
fix(live): align native host and session isolation
LaZzyMan Jul 28, 2026
7a4df25
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Jul 28, 2026
3d7b7dc
fix(acp): preserve live worker continuation lineage
LaZzyMan Jul 28, 2026
bc72f81
fix(live): classify provider close reasons
LaZzyMan Jul 28, 2026
86803ea
fix(serve): discard unused recovered conversation dirs
LaZzyMan Jul 28, 2026
4185f3e
fix(live): isolate authorized realtime responses
LaZzyMan Jul 28, 2026
ac2b3db
fix(live): preserve realtime response authority
LaZzyMan Jul 28, 2026
56c1127
feat(web-shell): complete Live Voice onboarding
LaZzyMan Aug 1, 2026
1bdd2e8
fix(live): persist realtime-owned dialogue
LaZzyMan Aug 1, 2026
078f252
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Aug 1, 2026
96a2c22
fix(live): preserve final speech while stopping
LaZzyMan Aug 1, 2026
411e9a2
Revert "fix(web-shell): address review feedback for Live Voice PR (#7…
LaZzyMan Aug 2, 2026
ea3c083
Revert "fix(cli): discard orphaned isolated dir when parent restore f…
LaZzyMan Aug 2, 2026
d440763
Revert "fix(web-shell): address review feedback for Live Voice PR (#7…
LaZzyMan Aug 2, 2026
0a3548c
Revert "fix(cli): register realtime_voice in docs contract and env gu…
LaZzyMan Aug 2, 2026
b27d5d5
Revert "fix(web-shell): address review feedback for Live Voice PR (#7…
LaZzyMan Aug 2, 2026
522eb29
revert(live): remove remaining takeover behavior
LaZzyMan Aug 2, 2026
7d85e9c
revert(live): restore pre-rollback implementation
LaZzyMan Aug 2, 2026
aae449f
Merge origin/main into lazzy/web-shell-live-voice
LaZzyMan Aug 2, 2026
f3335bb
test(cli): align Live diagnostics env guard
LaZzyMan Aug 2, 2026
22e6d09
test(release): cover Live Host publication
LaZzyMan Aug 2, 2026
2185661
fix(ci): re-sign Live Host package before verification
LaZzyMan Aug 2, 2026
5f63a39
fix(serve): scope sent completion notifications to Live
LaZzyMan Aug 2, 2026
de64483
Merge branch 'main' into lazzy/web-shell-live-voice
LaZzyMan Aug 3, 2026
83cc16a
fix(web-shell): preserve live setup errors
LaZzyMan Aug 3, 2026
765f95f
fix(live): align realtime backend speech lifecycle
LaZzyMan Aug 4, 2026
cf1939d
ci(live): publish Live Host independently
LaZzyMan Aug 4, 2026
30b5ed0
Merge remote-tracking branch 'lazzy/lazzy/web-shell-live-voice' into …
LaZzyMan Aug 4, 2026
a8f94a2
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Aug 4, 2026
b8d8965
test(cli): mock Live speech bridge handler
LaZzyMan Aug 4, 2026
28dfc42
test(release): align Live Host workflow contract
LaZzyMan Aug 4, 2026
5a1046d
Merge origin/main into lazzy/web-shell-live-voice
LaZzyMan Aug 4, 2026
e84799c
fix(live): address release and lifecycle review findings
LaZzyMan Aug 5, 2026
4e7761c
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Aug 5, 2026
dfd0b3e
fix(live): release completed call tracking
LaZzyMan Aug 5, 2026
4c0811d
Merge remote-tracking branch 'origin/main' into lazzy/web-shell-live-…
LaZzyMan Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
509 changes: 509 additions & 0 deletions docs/design/web-shell-live-voice.md

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions docs/developers/qwen-serve-protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,7 @@ operator diagnostic snapshot documented below.
| `cdp_tunnel_over_ws` | the daemon exposes the reverse `/cdp` WebSocket tunnel, either by explicit opt-in or because a Chrome extension origin is allowed. This only means the tunnel exists; it does not mean Chrome DevTools MCP tools are registered. |
| `browser_automation_mcp` | ACP HTTP is enabled, `cdp_tunnel_over_ws` is active, no bearer token blocks `/cdp`, and `QWEN_CDP_MCP_COMMAND` names an external stdio MCP adapter. The main CLI package does not bundle a browser automation adapter; without this tag, Chrome extension side-panel chat may still work, but console/network/screenshot/click tools are not registered by default. |
| `voice_transcribe` | the Voice WebSocket endpoint is mounted; a configured Voice model is still required for a successful transcription. |
| `realtime_voice` | ACP HTTP is enabled; the Live Voice Host WebSocket and `/live/status` readiness endpoint are mounted. |

<!-- conditional-serve-features:end -->

Expand Down
160 changes: 158 additions & 2 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import { TurnBoundaryCompactionEngine } from './compactionEngine.js';
import {
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
DAEMON_MODEL_PROMPT_META_KEY,
} from './bridgeTypes.js';
import {
ApprovalMode,
Expand Down Expand Up @@ -647,6 +648,44 @@ describe('createAcpSessionBridge', () => {
await bridge.shutdown();
});

it('forwards the Live managed relocation capability to the ACP child', async () => {
const target = path.join(WS_A, 'conversation-managed');
const sessionCdCalls: unknown[] = [];
const handle = makeChannel({
extMethodImpl: async (method, params) => {
if (method === SERVE_CONTROL_EXT_METHODS.sessionCd) {
sessionCdCalls.push(params);
return {
previousCwd: WS_A,
newCwd: target,
warnings: [],
};
}
return {};
},
});
const bridge = makeBridge({
channelFactory: vi.fn().mockResolvedValue(handle.channel),
});

const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
await bridge.changeSessionCwd(session.sessionId, {
path: target,
allowedRoots: [WS_A],
managedRelocation: 'live-conversation',
});

expect(sessionCdCalls).toEqual([
{
sessionId: session.sessionId,
path: target,
allowedRoots: [WS_A],
managedRelocation: 'live-conversation',
},
]);
await bridge.shutdown();
});

it('does not emit another registration for attach-only spawnOrAttach calls', async () => {
const events: Array<{ type: string; sessionId: string }> = [];
const bridge = makeBridge({
Expand Down Expand Up @@ -5213,6 +5252,8 @@ describe('createAcpSessionBridge', () => {
promptId: 'forged-prompt',
originatorClientId: 'forged-client',
};
const forgedModelPrompt = 'forged model-only prompt';
const trustedModelPrompt = 'trusted model-only prompt';

expect(() =>
bridge.sendPrompt(
Expand All @@ -5228,6 +5269,19 @@ describe('createAcpSessionBridge', () => {
).toThrow(InvalidClientIdError);
expect(handle.agent.promptCalls).toHaveLength(0);

expect(() =>
bridge.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'invalid internal prompt' }],
},
undefined,
{ clientId: session.clientId, modelPrompt: ' ' },
),
).toThrow('Bridge modelPrompt must be a non-empty bounded string');
expect(handle.agent.promptCalls).toHaveLength(0);

await bridge.sendPrompt(
session.sessionId,
{
Expand All @@ -5237,10 +5291,15 @@ describe('createAcpSessionBridge', () => {
keep: true,
'qwen-code/invocation': forgedInvocation,
'qwen-code/private-parent-capability': 'forged-capability',
[DAEMON_MODEL_PROMPT_META_KEY]: forgedModelPrompt,
},
},
undefined,
{ clientId: session.clientId, promptId: 'server-prompt' },
{
clientId: session.clientId,
promptId: 'server-prompt',
modelPrompt: trustedModelPrompt,
},
);

expect(handle.agent.promptCalls[0]?._meta).toMatchObject({
Expand All @@ -5251,13 +5310,31 @@ describe('createAcpSessionBridge', () => {
promptId: 'server-prompt',
originatorClientId: session.clientId,
},
[DAEMON_MODEL_PROMPT_META_KEY]: trustedModelPrompt,
});
expect(handle.agent.promptCalls[0]?.prompt).toEqual([
{ type: 'text', text: 'accepted' },
]);
expect(
handle.agent.promptCalls[0]?._meta?.[
'qwen-code/private-parent-capability'
],
).toBeUndefined();

await bridge.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'spoof-only' }],
_meta: { [DAEMON_MODEL_PROMPT_META_KEY]: forgedModelPrompt },
},
undefined,
{ clientId: session.clientId, promptId: 'spoof-only-prompt' },
);
expect(
handle.agent.promptCalls[1]?._meta?.[DAEMON_MODEL_PROMPT_META_KEY],
).toBeUndefined();

await bridge.shutdown();
});

Expand Down Expand Up @@ -5740,7 +5817,11 @@ describe('createAcpSessionBridge', () => {
_meta: { inputAnnotations, privateRequestId: 'hidden' },
},
undefined,
{ clientId: session.clientId, promptId: 'prompt-echo' },
{
clientId: session.clientId,
promptId: 'prompt-echo',
modelPrompt: 'hidden internal prompt',
},
);

const [aChunk, bChunk] = await Promise.all([aPromise, bPromise]);
Expand Down Expand Up @@ -17790,6 +17871,81 @@ describe('activePromptCount and lastActivityAt', () => {
});
});

describe('createAcpSessionBridge — background notifications', () => {
it('forwards a daemon-owned worker completion to the live parent session', async () => {
const handle = makeChannel({
extMethodImpl: async (method, params) =>
method === SERVE_CONTROL_EXT_METHODS.sessionBackgroundNotification
? { sessionId: params['sessionId'], accepted: true }
: {},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

await expect(
bridge.enqueueBackgroundNotification(session.sessionId, {
displayText: 'Worker completed.',
modelText: '<task-notification />',
taskId: 'worker-1',
status: 'completed',
kind: 'agent',
}),
).resolves.toEqual({ sessionId: session.sessionId, accepted: true });

expect(handle.agent.extMethodCalls).toContainEqual({
method: SERVE_CONTROL_EXT_METHODS.sessionBackgroundNotification,
params: {
sessionId: session.sessionId,
displayText: 'Worker completed.',
modelText: '<task-notification />',
taskId: 'worker-1',
status: 'completed',
kind: 'agent',
},
});
await bridge.shutdown();
});

it('propagates a parent that did not accept the completion', async () => {
const handle = makeChannel({
extMethodImpl: async (method, params) =>
method === SERVE_CONTROL_EXT_METHODS.sessionBackgroundNotification
? { sessionId: params['sessionId'], accepted: false }
: {},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

await expect(
bridge.enqueueBackgroundNotification(session.sessionId, {
displayText: 'Worker completed.',
modelText: '<task-notification />',
taskId: 'worker-1',
status: 'completed',
kind: 'agent',
}),
).resolves.toEqual({ sessionId: session.sessionId, accepted: false });

await bridge.shutdown();
});

it('rejects a worker completion for an unknown parent session', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
await expect(
bridge.enqueueBackgroundNotification('missing', {
displayText: 'Worker completed.',
modelText: '<task-notification />',
taskId: 'worker-1',
status: 'completed',
kind: 'agent',
}),
).rejects.toBeInstanceOf(SessionNotFoundError);
await bridge.shutdown();
});
});

/**
* `enqueueMidTurnMessage` backs the web-shell mid-turn drain: the browser
* pushes a message typed during a turn, the ACP child drains it via
Expand Down
37 changes: 37 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,13 +99,15 @@ import {
CHANNEL_STARTUP_PROFILE_META_KEY,
CHANNEL_STARTUP_PROFILE_VERSION,
DAEMON_CHANNEL_DELIVERY_META_KEY,
DAEMON_MODEL_PROMPT_META_KEY,
LOAD_REPLAY_BULK_MODE,
LOAD_REPLAY_META_KEY,
LOAD_REPLAY_MODE_META_KEY,
LOAD_REPLAY_PAGE_SIZE_META_KEY,
LOAD_REPLAY_VERSION,
PROMPT_CANCEL_METHOD,
TODO_STOP_GUARD_QUEUE_RELEASE_METHOD,
isValidTrustedModelPrompt,
} from './bridgeTypes.js';
import { getChannelStartupProfileAttributes } from './channel-startup-profile.js';
import type {
Expand Down Expand Up @@ -5204,6 +5206,15 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
entry,
context?.clientId,
);
const modelPrompt = context?.modelPrompt;
if (
modelPrompt !== undefined &&
!isValidTrustedModelPrompt(modelPrompt)
) {
throw new TypeError(
'Bridge modelPrompt must be a non-empty bounded string.',
);
}
// Pre-aborted: skip the queue entirely. Without this the prompt
// chains onto promptQueue, waits its turn, and the FIFO worker
// checks `signal.aborted` only AFTER reaching the head — wasted
Expand Down Expand Up @@ -5442,6 +5453,7 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
// below) re-arms it after this strip.
delete meta[DAEMON_CONTINUE_META_KEY];
delete meta[DAEMON_CHANNEL_DELIVERY_META_KEY];
delete meta[DAEMON_MODEL_PROMPT_META_KEY];
if (isRetry) {
meta[DAEMON_RETRY_META_KEY] = true;
}
Expand All @@ -5452,6 +5464,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
meta[DAEMON_CHANNEL_DELIVERY_META_KEY] =
context.channelDelivery;
}
if (modelPrompt !== undefined) {
meta[DAEMON_MODEL_PROMPT_META_KEY] = modelPrompt;
}
meta[INVOCATION_CONTEXT_META_KEY] = invocationContext;
if (Object.keys(meta).length > 0) {
copy._meta = meta;
Expand Down Expand Up @@ -6196,6 +6211,9 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
sessionId,
path: req.path,
...(req.allowedRoots ? { allowedRoots: req.allowedRoots } : {}),
...(req.managedRelocation
? { managedRelocation: req.managedRelocation }
: {}),
},
);
const extResult = raw as {
Expand Down Expand Up @@ -7449,6 +7467,25 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
return { accepted: true };
},

async enqueueBackgroundNotification(sessionId, notification) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
const info = channelInfoForEntry(entry);
if (!info || info.isDying) throw new SessionNotFoundError(sessionId);
const response = await Promise.race([
withTimeout(
entry.connection.extMethod(
SERVE_CONTROL_EXT_METHODS.sessionBackgroundNotification,
{ sessionId, ...notification },
),
initTimeoutMs,
SERVE_CONTROL_EXT_METHODS.sessionBackgroundNotification,
),
getTransportClosedReject(entry),
]);
return { sessionId, accepted: response['accepted'] === true };
},

async generateSessionBtw(sessionId, question, signal, _context) {
const entry = byId.get(sessionId);
if (!entry) throw new SessionNotFoundError(sessionId);
Expand Down
62 changes: 62 additions & 0 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,68 @@ function makeClient(fileSystem?: BridgeFileSystem): BridgeClient {
);
}

describe('BridgeClient — background notification turn boundary', () => {
it('publishes the child end-turn signal for the owned live session', async () => {
const sessionId = 'session-background';
const publish = vi.fn().mockReturnValue(true);
const entry = { sessionId, events: { publish } };
const noFlow = () => {
throw new Error('test: permission flow should not run');
};
const client = new BridgeClient(
((id: string) => (id === sessionId ? entry : undefined)) as never,
noFlow as never,
{ request: noFlow } as never,
0,
Infinity,
);

await client.extNotification('_qwencode/end_turn', {
sessionId,
reason: 'end_turn',
source: 'background_notification',
});

expect(publish).toHaveBeenCalledWith({
type: 'background_notification_turn_complete',
data: { sessionId, reason: 'end_turn' },
});
});

it('drops malformed or foreign end-turn signals', async () => {
const publish = vi.fn();
const entry = { sessionId: 'owned', events: { publish } };
const noFlow = () => {
throw new Error('test: permission flow should not run');
};
const client = new BridgeClient(
((id: string) => (id === 'owned' ? entry : undefined)) as never,
noFlow as never,
{ request: noFlow } as never,
0,
Infinity,
undefined,
undefined,
undefined,
undefined,
(id) => id === 'owned',
);

await client.extNotification('_qwencode/end_turn', {
sessionId: 'owned',
reason: 'end_turn',
source: 'forged',
});
await client.extNotification('_qwencode/end_turn', {
sessionId: 'foreign',
reason: 'end_turn',
source: 'background_notification',
});

expect(publish).not.toHaveBeenCalled();
});
});

describe('BridgeClient — recording degradation ownership', () => {
it('keeps session-level recording degradation prompt-neutral', async () => {
const sessionId = 'session-with-active-prompt';
Expand Down
Loading
Loading