Skip to content
4 changes: 4 additions & 0 deletions packages/acp-bridge/src/bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1714,6 +1714,10 @@ export function createAcpSessionBridge(opts: BridgeOptions): AcpSessionBridge {
outputTokens,
durationMs,
),
// `create_sub_session` tool: forward the request/response hook so a child
// tool can ask the daemon to spawn a sub-session and (for 'first-turn')
// return its result. Omitted → the method reports daemon-only.
opts.onCreateSubSession,
);
const connection = new ClientSideConnection(() => client, channel.stream);

Expand Down
186 changes: 186 additions & 0 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@ import {
} from '@qwen-code/qwen-code-core';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { BridgeClient } from './bridgeClient.js';
import {
MAX_SUB_SESSION_NAME_CHARS,
MAX_SUB_SESSION_PROMPT_CHARS,
} from './bridgeOptions.js';
import type { BridgeFileSystem } from './bridgeFileSystem.js';
import type { MidTurnQueueEntry } from './bridgeTypes.js';
import type { ClientMcpMessageSender } from './bridgeOptions.js';
Expand Down Expand Up @@ -717,6 +721,188 @@ describe('BridgeClient — token usage accounting', () => {
});
});

describe('BridgeClient — create-sub-session extMethod dispatch', () => {
const noFlow = () => {
throw new Error('test: should not run');
};

function makeClientWithCreateSubSession(
onCreateSubSession:
| ((info: {
prompt: string;
completion: 'sent' | 'first-turn';
model?: string;
name?: string;
callerSessionId?: string;
}) => Promise<{
sessionId: string;
result?: string;
stopReason?: string;
}>)
| undefined,
ownsSession?: (sessionId: string) => boolean,
) {
return new BridgeClient(
(() => undefined) as never, // resolveEntry
noFlow as never,
{ request: noFlow } as never,
0,
Infinity,
undefined, // fileSystem
undefined, // onModelPromoted
undefined, // onModePromoted
undefined, // clientMcpSender
ownsSession as never, // ownsSession (undefined → defaults to () => true)
undefined, // onTokenUsage
onCreateSubSession,
);
}

const METHOD = 'qwen/control/create-sub-session';

it('forwards a valid request to the host handler and returns its result', async () => {
const onCreate = vi.fn(async () => ({
sessionId: 'sub-9',
result: 'done',
stopReason: 'end_turn',
}));
const client = makeClientWithCreateSubSession(onCreate);

const res = await client.extMethod(METHOD, {
prompt: 'summarize',
completion: 'first-turn',
model: 'm1',
name: 'digest',
callerSessionId: 'caller-1',
});

expect(onCreate).toHaveBeenCalledWith({
prompt: 'summarize',
completion: 'first-turn',
model: 'm1',
name: 'digest',
callerSessionId: 'caller-1',
});
expect(res).toEqual({
sessionId: 'sub-9',
result: 'done',
stopReason: 'end_turn',
});
});

it('omits result/stopReason when the handler does not return them (sent mode)', async () => {
const client = makeClientWithCreateSubSession(async () => ({
sessionId: 'sub-10',
}));
const res = await client.extMethod(METHOD, {
prompt: 'go',
completion: 'sent',
callerSessionId: 'caller-1',
});
expect(res).toEqual({ sessionId: 'sub-10' });
});

it('rejects methodNotFound when no host handler is wired (non-daemon)', async () => {
const client = makeClientWithCreateSubSession(undefined);
await expect(
client.extMethod(METHOD, {
prompt: 'x',
completion: 'sent',
callerSessionId: 'caller-1',
}),
).rejects.toThrow();
});

it('rejects invalid params (missing prompt, bad completion)', async () => {
const onCreate = vi.fn();
const client = makeClientWithCreateSubSession(
onCreate as unknown as Parameters<
typeof makeClientWithCreateSubSession
>[0],
);
await expect(
client.extMethod(METHOD, { completion: 'sent' }),
).rejects.toThrow();
await expect(
client.extMethod(METHOD, { prompt: 'x', completion: 'weird' }),
).rejects.toThrow();
expect(onCreate).not.toHaveBeenCalled();
});

it('rejects a callerSessionId this connection does not own', async () => {
// The key names the launcher's per-caller concurrency bucket. A child that
// can name any session evades its own cap (a fabricated id starts a fresh
// bucket at zero) and can burn a victim session's slots.
const onCreate = vi.fn(async () => ({ sessionId: 'sub-11' }));
const client = makeClientWithCreateSubSession(
onCreate,
(id) => id === 'mine',
);

await expect(
client.extMethod(METHOD, {
prompt: 'x',
completion: 'sent',
callerSessionId: 'victim',
}),
).rejects.toThrow(/callerSessionId/i);
expect(onCreate).not.toHaveBeenCalled();

// An owned id passes through untouched.
await client.extMethod(METHOD, {
prompt: 'x',
completion: 'sent',
callerSessionId: 'mine',
});
expect(onCreate).toHaveBeenCalledWith({
prompt: 'x',
completion: 'sent',
callerSessionId: 'mine',
});

// Omitting it is NOT legal: an absent id would give the launcher an
// anonymous per-call bucket (no cap) and skip its depth-1 nesting gate.
onCreate.mockClear();
await expect(
client.extMethod(METHOD, { prompt: 'x', completion: 'sent' }),
).rejects.toThrow(/callerSessionId/i);
expect(onCreate).not.toHaveBeenCalled();
});

it('rejects an over-long prompt and an over-long name', async () => {
const onCreate = vi.fn(async () => ({ sessionId: 'sub-12' }));
const client = makeClientWithCreateSubSession(onCreate);

await expect(
client.extMethod(METHOD, {
prompt: 'x'.repeat(MAX_SUB_SESSION_PROMPT_CHARS + 1),
completion: 'sent',
callerSessionId: 'caller-1',
}),
).rejects.toThrow(new RegExp(`${MAX_SUB_SESSION_PROMPT_CHARS}`));

await expect(
client.extMethod(METHOD, {
prompt: 'x',
completion: 'sent',
name: 'n'.repeat(MAX_SUB_SESSION_NAME_CHARS + 1),
callerSessionId: 'caller-1',
}),
).rejects.toThrow(new RegExp(`${MAX_SUB_SESSION_NAME_CHARS}`));

expect(onCreate).not.toHaveBeenCalled();

// Both boundaries are accepted.
await client.extMethod(METHOD, {
prompt: 'x'.repeat(MAX_SUB_SESSION_PROMPT_CHARS),
completion: 'sent',
name: 'n'.repeat(MAX_SUB_SESSION_NAME_CHARS),
callerSessionId: 'caller-1',
});
expect(onCreate).toHaveBeenCalledTimes(1);
});
});

describe('BridgeClient — artifact ingress', () => {
const noPermissionFlow = () => {
throw new Error('test: permission flow should not run');
Expand Down
117 changes: 111 additions & 6 deletions packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,14 @@ import { MID_TURN_MESSAGE_INJECTED_EVENT } from './daemonEventTypes.js';
import { MID_TURN_QUEUE_DRAIN_METHOD } from './bridgeTypes.js';
import type { MidTurnQueueEntry } from './bridgeTypes.js';
import { SERVE_CONTROL_EXT_METHODS } from './status.js';
import type { ClientMcpMessageSender } from './bridgeOptions.js';
import type {
ClientMcpMessageSender,
CreateSubSessionHandler,
} from './bridgeOptions.js';
import {
MAX_SUB_SESSION_NAME_CHARS,
MAX_SUB_SESSION_PROMPT_CHARS,
} from './bridgeOptions.js';
import type { BridgeFileSystem } from './bridgeFileSystem.js';
import { CANCEL_VOTE_SENTINEL } from './permissionMediator.js';
// Narrowed from the concrete `MultiClientPermissionMediator` to the
Expand Down Expand Up @@ -502,6 +509,15 @@ export class BridgeClient implements Client {
outputTokens: number,
durationMs?: number,
) => void,
/**
* Daemon-host seam for the `create_sub_session` tool. Invoked from the
* `extMethod` dispatch (a child→daemon REQUEST, so it returns a Promise the
* child awaits) with the prompt, completion mode, and optional model/name;
* the host spawns a sub-session and, for `'first-turn'`, returns its result.
* Omitted by tests / Mode A / non-daemon — the method then reports
* `methodNotFound` and the tool surfaces itself as daemon-only.
*/
private readonly onCreateSubSession?: CreateSubSessionHandler,
) {}

async requestPermission(
Expand Down Expand Up @@ -818,9 +834,12 @@ export class BridgeClient implements Client {

/**
* Handle child->bridge ACP `extMethod` requests (calls that expect a
* response, unlike `extNotification`). The only method served today is
* `craft/drainMidTurnQueue`: the ACP child calls it between tool batches to
* pull any messages the browser queued mid-turn. We splice the per-session
* response, unlike `extNotification`). Served methods:
* `qwen/control/client_mcp/message` (reverse tool channel),
* `qwen/control/create-sub-session` (the `create_sub_session` tool → daemon
* spawns a sub-session and, for `'first-turn'`, returns its first-turn
* result), and `craft/drainMidTurnQueue`: the ACP child calls the last one
* between tool batches to pull any messages the browser queued mid-turn. We splice the per-session
* queue, return them to the child as the response, and — when non-empty —
* publish a `mid_turn_message_injected` SSE frame so the browser can move
* those messages out of its pending queue (a dedupe signal, not a transcript
Expand All @@ -842,6 +861,9 @@ export class BridgeClient implements Client {
if (method === SERVE_CONTROL_EXT_METHODS.clientMcpMessage) {
return this.handleClientMcpMessage(params);
}
if (method === SERVE_CONTROL_EXT_METHODS.createSubSession) {
return this.handleCreateSubSession(params);
}
if (method !== MID_TURN_QUEUE_DRAIN_METHOD) {
throw RequestError.methodNotFound(method);
}
Expand Down Expand Up @@ -942,6 +964,89 @@ export class BridgeClient implements Client {
return { payload: response as Record<string, unknown> };
}

/**
* Handle the `create_sub_session` tool's request: validate, then forward to
* the daemon-host `onCreateSubSession` callback (which spawns a fresh
* top-level sub-session and, for `'first-turn'`, waits for its first turn and
* returns the result). No host wired → `methodNotFound`, which the tool
* surfaces as "daemon-only".
*/
private async handleCreateSubSession(
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
if (!this.onCreateSubSession) {
throw RequestError.methodNotFound(
SERVE_CONTROL_EXT_METHODS.createSubSession,
);
}
const prompt = params['prompt'];
if (typeof prompt !== 'string' || prompt.length === 0) {
Comment thread
wenshao marked this conversation as resolved.
throw RequestError.invalidParams(
undefined,
'`prompt` must be a non-empty string',
);
}
// The child is a separate process; this is a trust boundary. Without a cap
// it can hand the daemon a multi-MB string to deserialize, copy for the
// display name, and dispatch into a new session. Same ceiling the
// scheduled-task REST route applies to the prompts it accepts.
if (prompt.length > MAX_SUB_SESSION_PROMPT_CHARS) {
throw RequestError.invalidParams(
undefined,
`\`prompt\` exceeds the ${MAX_SUB_SESSION_PROMPT_CHARS}-character limit`,
);
}
const completion = params['completion'];
if (completion !== 'sent' && completion !== 'first-turn') {
throw RequestError.invalidParams(
undefined,
"`completion` must be 'sent' or 'first-turn'",
);
}
const name = params['name'];
Comment thread
wenshao marked this conversation as resolved.
if (typeof name === 'string' && name.length > MAX_SUB_SESSION_NAME_CHARS) {
throw RequestError.invalidParams(
undefined,
`\`name\` exceeds the ${MAX_SUB_SESSION_NAME_CHARS}-character limit`,
);
}
// `callerSessionId` keys the launcher's per-caller concurrency bucket AND
// its depth-1 nesting gate. A child that names a session it does not own
// could evade its own cap (a fabricated id starts every bucket at zero) or
// exhaust a victim session's; a child that OMITS the field would get an
// anonymous per-call bucket and skip the nesting gate entirely. Neither is
// acceptable, so it is required and authenticated. Every real caller has
// one — the tool runs inside a session's turn.
const callerSessionId = params['callerSessionId'];
Comment thread
wenshao marked this conversation as resolved.
if (
typeof callerSessionId !== 'string' ||
callerSessionId.length === 0 ||
!this.ownsSession(callerSessionId)
Comment thread
wenshao marked this conversation as resolved.
) {
throw RequestError.invalidParams(
undefined,
'`callerSessionId` is required and must name a session owned by this connection',
);
}
const model = params['model'];
const result = await this.onCreateSubSession({
prompt,
completion,
...(typeof model === 'string' && model.length > 0 && model.length <= 128
? { model }
: {}),
...(typeof name === 'string' && name.length > 0 ? { name } : {}),
callerSessionId,
});
return {
sessionId: result.sessionId,
...(result.result !== undefined ? { result: result.result } : {}),
...(result.stopReason !== undefined
? { stopReason: result.stopReason }
: {}),
};
}

/**
* Handle child->bridge ACP `extNotification` calls. Recognized methods are
* `qwen/notify/session/model-update`,
Expand All @@ -951,8 +1056,8 @@ export class BridgeClient implements Client {
* `qwen/notify/session/artifact-event` (hook artifacts),
* `qwen/notify/session/terminal-sequence`, and
* `qwen/notify/session/mcp-budget-event` — each translated into a
* session-scoped SSE frame. Unknown methods are dropped silently
* for forward-compat.
* session-scoped SSE frame. Unknown methods are dropped silently for
* forward-compat.
*/
async extNotification(
method: string,
Expand Down
Loading
Loading