Skip to content
Merged
318 changes: 318 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10301,3 +10301,321 @@ describe('close on last client detach', () => {
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
* `craft/drainMidTurnQueue` (answered by BridgeClient.extMethod). The accept
* gate is the exactly-once linchpin — it must reject when the session is idle
* so the browser's own next-turn queue stays the single delivery path in the
* settle-window race.
*/
describe('createAcpSessionBridge — mid-turn message queue (enqueueMidTurnMessage)', () => {
function hangingPromptFactory(): {
factory: ChannelFactory;
release: () => void;
} {
let release: (() => void) | undefined;
const factory: ChannelFactory = async () =>
makeChannel({
promptImpl: async () => {
await new Promise<void>((res) => {
release = res;
});
return { stopReason: 'end_turn' };
},
}).channel;
return { factory, release: () => release?.() };
}

it('rejects (accepted:false) when the session is idle', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(bridge.enqueueMidTurnMessage(session.sessionId, 'later')).toEqual({
accepted: false,
});
await bridge.shutdown();
});

it('accepts while a turn is in flight, then rejects again once it settles', async () => {
const { factory, release } = hangingPromptFactory();
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptPromise = bridge
.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'run tools' }],
},
undefined,
{ clientId: session.clientId },
)
.catch(() => {});

// Let the FIFO worker pick up the (hanging) prompt before asserting.
await new Promise((r) => setTimeout(r, 10));
expect(
bridge.enqueueMidTurnMessage(session.sessionId, 'also check tests'),
).toEqual({ accepted: true });

// Settle the turn → the queue flips back to idle and the undrained copy is
// dropped server-side (the browser resends it as the next turn).
release();
await promptPromise;
expect(
bridge.enqueueMidTurnMessage(session.sessionId, 'next time'),
).toEqual({ accepted: false });
await bridge.shutdown();
});

it('rejects a whitespace-only message even while busy', async () => {
const { factory, release } = hangingPromptFactory();
const bridge = makeBridge({ channelFactory: factory });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const promptPromise = bridge
.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'x' }],
},
undefined,
{ clientId: session.clientId },
)
.catch(() => {});
await new Promise((r) => setTimeout(r, 10));
expect(bridge.enqueueMidTurnMessage(session.sessionId, ' ')).toEqual({
accepted: false,
});
release();
await promptPromise;
await bridge.shutdown();
});

it('throws SessionNotFoundError for an unknown session', async () => {
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
expect(() => bridge.enqueueMidTurnMessage('nope', 'hi')).toThrow(
SessionNotFoundError,
);
await bridge.shutdown();
});

it('drains the queue through the child connection; a second drain is empty', async () => {
let release: (() => void) | undefined;
const handle = makeChannel({
promptImpl: async () => {
await new Promise<void>((r) => {
release = r;
});
return { stopReason: 'end_turn' };
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const prompt = bridge
.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'go' }],
},
undefined,
{ clientId: session.clientId },
)
.catch(() => {});
await new Promise((r) => setTimeout(r, 10));

bridge.enqueueMidTurnMessage(session.sessionId, 'm1');
bridge.enqueueMidTurnMessage(session.sessionId, 'm2');

// The child pulls them via the ext-method the real Session calls between
// tool batches — exercising bridge queue ⇄ BridgeClient.extMethod end to end.
const drained = await handle.agentConnection.extMethod(
'craft/drainMidTurnQueue',
{ sessionId: session.sessionId },
);
expect(drained).toEqual({ messages: ['m1', 'm2'] });
// Spliced out, so the next batch's drain is empty.
expect(
await handle.agentConnection.extMethod('craft/drainMidTurnQueue', {
sessionId: session.sessionId,
}),
).toEqual({ messages: [] });

release?.();
await prompt;
await bridge.shutdown();
});

it('clears undrained messages at settle — not re-drained on the next turn', async () => {
const releases: Array<() => void> = [];
const handle = makeChannel({
promptImpl: async () => {
await new Promise<void>((r) => {
releases.push(r);
});
return { stopReason: 'end_turn' };
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const send = (text: string) =>
bridge
.sendPrompt(
session.sessionId,
{ sessionId: session.sessionId, prompt: [{ type: 'text', text }] },
undefined,
{ clientId: session.clientId },
)
.catch(() => {});
const drain = () =>
handle.agentConnection.extMethod('craft/drainMidTurnQueue', {
sessionId: session.sessionId,
});

// Turn 1: enqueue, do NOT drain, then settle.
const t1 = send('t1');
await new Promise((r) => setTimeout(r, 10));
expect(bridge.enqueueMidTurnMessage(session.sessionId, 'leftover')).toEqual(
{
accepted: true,
},
);
releases[0]!();
await t1;

// Turn 2: the drain must be empty — the leftover was dropped at turn-1
// settle, NOT carried into turn 2's first batch. (Deleting the settle-clear
// line makes this return ['leftover'].)
const t2 = send('t2');
await new Promise((r) => setTimeout(r, 10));
expect(await drain()).toEqual({ messages: [] });

releases[1]!();
await t2;
await bridge.shutdown();
});

it('keeps the queue across a back-to-back prompt FIFO, clearing only at true idle', async () => {
const releases: Array<() => void> = [];
const handle = makeChannel({
promptImpl: async () => {
await new Promise<void>((r) => {
releases.push(r);
});
return { stopReason: 'end_turn' };
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const send = (text: string) =>
bridge
.sendPrompt(
session.sessionId,
{ sessionId: session.sessionId, prompt: [{ type: 'text', text }] },
undefined,
{ clientId: session.clientId },
)
.catch(() => {});

const p1 = send('p1');
await new Promise((r) => setTimeout(r, 10));
const p2 = send('p2'); // queued behind p1 ⇒ pendingPromptCount = 2
expect(bridge.enqueueMidTurnMessage(session.sessionId, 'x')).toEqual({
accepted: true,
});

releases[0]!(); // settle p1 — session still busy (p2 pending), so NOT idle
await new Promise((r) => setTimeout(r, 10));
// Survived the p1→p2 boundary: the clear only fires at true idle.
expect(
await handle.agentConnection.extMethod('craft/drainMidTurnQueue', {
sessionId: session.sessionId,
}),
).toEqual({ messages: ['x'] });

releases[1]!();
await Promise.all([p1, p2]);
await bridge.shutdown();
});

it('rejects a non-member client id (mirrors /prompt and /btw authorization)', async () => {
// The route forwards the client-declared id; the bridge must authorize it
// against THIS session before queuing — a token-holding client bound to
// another session must not push into this turn. The check runs before the
// idle/empty gates, so it throws even on an idle session.
const bridge = makeBridge({
channelFactory: async () => makeChannel().channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
expect(() =>
bridge.enqueueMidTurnMessage(session.sessionId, 'sneaky', {
clientId: 'client-not-issued',
}),
).toThrow(InvalidClientIdError);
await bridge.shutdown();
});

it('stamps the drained injection frame with the originator client id', async () => {
// End-to-end: the trusted client id passed to enqueue is recorded on the
// queue entry and surfaces as the published frame's `originatorClientId`, so
// only that client dedupes its own pending queue (a peer must keep its copy).
let release: (() => void) | undefined;
const handle = makeChannel({
promptImpl: async () => {
await new Promise<void>((r) => {
release = r;
});
return { stopReason: 'end_turn' };
},
});
const bridge = makeBridge({ channelFactory: async () => handle.channel });
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const prompt = bridge
.sendPrompt(
session.sessionId,
{
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'go' }],
},
undefined,
{ clientId: session.clientId },
)
.catch(() => {});
await new Promise((r) => setTimeout(r, 10));

expect(
bridge.enqueueMidTurnMessage(session.sessionId, 'hi', {
clientId: session.clientId,
}),
).toEqual({ accepted: true });

// Subscribe before the drain so the live injection frame is captured. The
// hanging prompt publishes nothing in between, so it is the first frame.
const abort = new AbortController();
const iter = bridge.subscribeEvents(session.sessionId, {
signal: abort.signal,
});
const drained = await handle.agentConnection.extMethod(
'craft/drainMidTurnQueue',
{ sessionId: session.sessionId },
);
expect(drained).toEqual({ messages: ['hi'] });

const it = iter[Symbol.asyncIterator]();
const next = await it.next();
expect(next.value?.type).toBe('mid_turn_message_injected');
expect(next.value?.originatorClientId).toBe(session.clientId);
expect(next.value?.data).toMatchObject({ messages: ['hi'] });

abort.abort();
release?.();
await prompt;
await bridge.shutdown();
});
});
Loading
Loading