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
103 changes: 103 additions & 0 deletions packages/acp-bridge/src/bridgeClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -717,6 +717,109 @@ 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,
) {
return new BridgeClient(
(() => undefined) as never, // resolveEntry
noFlow as never,
{ request: noFlow } as never,
0,
Infinity,
undefined, // fileSystem
undefined, // onModelPromoted
undefined, // onModePromoted
undefined, // clientMcpSender
undefined, // ownsSession
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',
});
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' }),
).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();
});
});

describe('BridgeClient — artifact ingress', () => {
const noPermissionFlow = () => {
throw new Error('test: permission flow should not run');
Expand Down
82 changes: 76 additions & 6 deletions packages/acp-bridge/src/bridgeClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,10 @@ 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 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 +505,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 +830,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 +857,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 +960,58 @@ 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',
);
}
const completion = params['completion'];
if (completion !== 'sent' && completion !== 'first-turn') {
throw RequestError.invalidParams(
undefined,
"`completion` must be 'sent' or 'first-turn'",
);
}
const model = params['model'];
const name = params['name'];
Comment thread
wenshao marked this conversation as resolved.
const callerSessionId = params['callerSessionId'];
Comment thread
wenshao marked this conversation as resolved.
const result = await this.onCreateSubSession({
prompt,
completion,
...(typeof model === 'string' && model.length > 0 && model.length <= 128
? { model }
: {}),
...(typeof name === 'string' && name.length > 0 ? { name } : {}),
...(typeof callerSessionId === 'string' && callerSessionId.length > 0
? { 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 +1021,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
43 changes: 43 additions & 0 deletions packages/acp-bridge/src/bridgeOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,16 @@ export interface BridgeOptions {
* receives an SDK MCP runtime server, so the method is never called.
*/
clientMcpSender?: ClientMcpMessageSender;
/**
* Daemon-host seam for the `create_sub_session` tool. When a tool running
* inside a child's agent turn asks (over `extMethod`) to spawn a fresh
* top-level sub-session and run a prompt in it, the bridge's `extMethod`
* dispatch forwards it here. It RETURNS A PROMISE so the `'first-turn'` completion
* mode can wait for the sub-session's first turn and return its result to the
* caller. Omitted by tests / Mode A / non-daemon embeds — the tool then
* reports itself unavailable (daemon-only).
*/
onCreateSubSession?: CreateSubSessionHandler;
}

/**
Expand All @@ -428,3 +438,36 @@ export interface BridgeOptions {
export type ClientMcpMessageSender = (
serverName: string,
) => ((payload: unknown) => Promise<unknown>) | undefined;

/**
* Payload the bridge forwards to {@link BridgeOptions.onCreateSubSession} when
* the `create_sub_session` tool requests a sub-session.
*/
export interface CreateSubSessionInfo {
/** Prompt to run in the freshly-spawned sub-session. */
prompt: string;
/** When the request resolves: `'sent'` = as soon as the prompt is dispatched;
* `'first-turn'` = after the sub-session's first turn completes (result
* returned). Extensible — more criteria may be added later. */
completion: 'sent' | 'first-turn';
/** Optional model service id for the sub-session (falls back to default). */
model?: string;
/** Optional display name for the sub-session in the session list. */
name?: string;
/** The calling session's id — used for per-caller concurrency accounting. */
callerSessionId?: string;
}

/** Result the daemon host returns for a create-sub-session request. `result`
* (the sub-session's first-turn output) is present only for `'first-turn'`. */
export interface CreateSubSessionResult {
sessionId: string;
result?: string;
stopReason?: string;
}

/** Daemon-host callback that spawns a sub-session and (for `'first-turn'`) waits
* for its first turn. Returns a Promise so the tool can block on the result. */
export type CreateSubSessionHandler = (
info: CreateSubSessionInfo,
) => Promise<CreateSubSessionResult>;
9 changes: 9 additions & 0 deletions packages/acp-bridge/src/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,15 @@ export const SERVE_CONTROL_EXT_METHODS = {
*/
clientMcpMessage: 'qwen/control/client_mcp/message',
sessionCd: 'qwen/control/session/cd',
/**
* Also called by the CHILD UP into the parent (like `clientMcpMessage`): the
* `create_sub_session` tool, running inside a child's agent turn, asks the
* daemon to spawn a fresh top-level sub-session and run a prompt in it. Params:
* `{ prompt, completion:'sent'|'first-turn', model?, name?, callerSessionId? }`;
* result: `{ sessionId, result?, stopReason? }` (result present only for the
* `first-turn` mode, which waits for the sub-session's first turn to finish).
*/
createSubSession: 'qwen/control/create-sub-session',
} as const;

export type ServeStatus =
Expand Down
Loading
Loading