Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
3ba6f15
feat(web-shell): queue prompts while turns are running
Jun 29, 2026
9db3dc6
fix(web-shell): address pending prompt review feedback
Jun 29, 2026
b881d8a
fix(web-shell): tighten queued prompt event handling
Jun 29, 2026
54f6b8b
fix(web-shell): avoid showing active prompt as queued
Jun 29, 2026
5e79771
fix(web-shell): address queued prompt review follow-ups
Jun 29, 2026
dccbc16
fix(web-shell): address pending prompt review issues
Jun 30, 2026
807fb93
merge: resolve conflicts between feat/daemon-pending-prompt-queue and…
qwen-code-dev-bot Jun 30, 2026
3333382
fix(web-shell): reconcile queued prompt actions from server
Jun 30, 2026
c24cbbd
fix(daemon): address pending prompt critical review
Jun 30, 2026
c0d28ba
Merge branch 'main' into feat/daemon-pending-prompt-queue
ytahdn Jun 30, 2026
b99ea0f
test(webui): expect submit abort signal forwarding
Jun 30, 2026
a9c37a4
fix(web-shell): avoid duplicate queued prompt sync
Jun 30, 2026
b0da490
Merge branch 'main' into feat/daemon-pending-prompt-queue
ytahdn Jun 30, 2026
9683058
fix(web-shell): keep local slash commands out of queue
Jun 30, 2026
20d1f0d
fix(webui): avoid aborting prompt admission
Jun 30, 2026
56ec962
fix(daemon): avoid queued prompt cancel cascades
Jun 30, 2026
40ebe88
fix(webui): avoid stale client id for queue cleanup
Jun 30, 2026
81e10fc
test(webui): update stale session queue cleanup expectation
Jun 30, 2026
b922821
fix(web-shell): preserve queue reconciliation identity
Jun 30, 2026
e90f00a
fix(web-shell): guard queue clear session writes
Jun 30, 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
281 changes: 281 additions & 0 deletions packages/acp-bridge/src/bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3376,6 +3376,287 @@ describe('createAcpSessionBridge', () => {
});
});

describe('pendingPromptList', () => {
it('tracks a single prompt without publishing a pending_prompt_added event', async () => {
const events: BridgeEvent[] = [];
const handle = makeChannel({
promptImpl: () => ({ stopReason: 'end_turn' }),
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const ev of bridge.subscribeEvents(session.sessionId)) {
events.push(ev);
}
})();
await bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'first prompt' }],
});
const addedEvents = events.filter(
(e) => e.type === 'pending_prompt_added',
);
expect(addedEvents).toHaveLength(0);
expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(0);
sub.catch(() => {});
await bridge.shutdown();
});

it('publishes pending_prompt_added only for queued prompts', async () => {
Comment thread
ytahdn marked this conversation as resolved.
const events: BridgeEvent[] = [];
let resolveFirst: (() => void) | undefined;
const firstDone = new Promise<void>((r) => {
resolveFirst = r;
});
const handle = makeChannel({
promptImpl: async (req: PromptRequest) => {
if ((req.prompt[0] as { text?: string }).text === 'blocking') {
await firstDone;
}
return { stopReason: 'end_turn' } as PromptResponse;
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const ev of bridge.subscribeEvents(session.sessionId)) {
events.push(ev);
}
})();

const p1 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'blocking' }],
});
const p2 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'queued behind first' }],
});

await new Promise((r) => setTimeout(r, 20));
const addedEvents = events.filter(
(e) => e.type === 'pending_prompt_added',
);
expect(addedEvents).toHaveLength(1);
expect(
(addedEvents[0] as BridgeEvent & { data: { text: string } }).data.text,
).toBe('queued behind first');

const pending = bridge.getPendingPrompts(session.sessionId);
expect(pending).toHaveLength(2);
expect(pending[0]?.state).toBe('running');
expect(pending[1]?.state).toBe('queued');

resolveFirst!();
await p1;
await p2;
await vi.waitFor(() => {
expect(
events.filter((e) => e.type === 'pending_prompt_started'),
).toHaveLength(1);
expect(
events.filter(
(e) =>
e.type === 'pending_prompt_completed' &&
(e as BridgeEvent & { data: { state: string } }).data.state ===
'completed',
),
).toHaveLength(1);
});
const startedEvents = events.filter(
(e) => e.type === 'pending_prompt_started',
);
expect(startedEvents).toHaveLength(1);
expect(
(startedEvents[0] as BridgeEvent & { data: { text: string } }).data
.text,
).toBe('queued behind first');
expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(0);
sub.catch(() => {});
await bridge.shutdown();
});

it('removePendingPrompt aborts a queued prompt and removes it from the list', async () => {
let resolveFirst: (() => void) | undefined;
const firstDone = new Promise<void>((r) => {
resolveFirst = r;
});
const handle = makeChannel({
promptImpl: async (req: PromptRequest) => {
if ((req.prompt[0] as { text?: string }).text === 'blocker') {
await firstDone;
}
return { stopReason: 'end_turn' } as PromptResponse;
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const p1 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'blocker' }],
});
const p2 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'to be removed' }],
});

await new Promise((r) => setTimeout(r, 20));
const pending = bridge.getPendingPrompts(session.sessionId);
expect(pending).toHaveLength(2);
const queuedId = pending[1]?.promptId;
expect(queuedId).toBeDefined();

const result = bridge.removePendingPrompt(session.sessionId, queuedId!);
expect(result.removed).toBe(true);
expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(1);

const again = bridge.removePendingPrompt(
session.sessionId,
'nonexistent',
);
expect(again.removed).toBe(false);

resolveFirst!();
await p1;
await expect(p2).rejects.toBeDefined();
await bridge.shutdown();
});

it('removePendingPrompt aborts a running prompt and triggers cancel', async () => {
const events: BridgeEvent[] = [];
let rejectPrompt: ((err: Error) => void) | undefined;
const promptPromise = new Promise<PromptResponse>((_resolve, reject) => {
rejectPrompt = reject;
});
const handle = makeChannel({
promptImpl: () => promptPromise,
cancelImpl: () => {
rejectPrompt?.(new Error('cancelled'));
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const sub = (async () => {
for await (const ev of bridge.subscribeEvents(session.sessionId)) {
events.push(ev);
}
})();

const p1 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'running prompt' }],
});

await new Promise((r) => setTimeout(r, 20));
const pending = bridge.getPendingPrompts(session.sessionId);
expect(pending).toHaveLength(1);
expect(pending[0]?.state).toBe('running');
const runningId = pending[0]?.promptId;
expect(runningId).toBeDefined();

const result = bridge.removePendingPrompt(session.sessionId, runningId!);
expect(result.removed).toBe(true);

// The running prompt is removed from the list immediately.
expect(bridge.getPendingPrompts(session.sessionId)).toHaveLength(0);

// The prompt should reject after the cancel path fires.
await expect(p1).rejects.toBeDefined();

// Cancel was forwarded to the agent.
expect(handle.agent.cancelCalls.length).toBeGreaterThanOrEqual(1);

// A pending_prompt_completed event with state 'removed' was published.
const completedEvents = events.filter(
(e) => e.type === 'pending_prompt_completed',
);
const removedEvent = completedEvents.find(
(e) =>
(e as BridgeEvent & { data: { promptId: string } }).data.promptId ===
runningId &&
(e as BridgeEvent & { data: { state: string } }).data.state ===
'removed',
);
expect(removedEvent).toBeDefined();

sub.catch(() => {});
await bridge.shutdown();
});

it('keeps later prompts queued while a removed running prompt is still settling', async () => {
let resolveFirst: (() => void) | undefined;
const firstDone = new Promise<void>((r) => {
resolveFirst = r;
});
const handle = makeChannel({
promptImpl: async (req: PromptRequest) => {
if (
(req.prompt[0] as { text?: string }).text === 'slow running prompt'
) {
await firstDone;
}
return { stopReason: 'end_turn' } as PromptResponse;
},
});
const bridge = makeBridge({
channelFactory: async () => handle.channel,
});
const session = await bridge.spawnOrAttach({ workspaceCwd: WS_A });

const p1 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'slow running prompt' }],
});
await new Promise((r) => setTimeout(r, 20));
const runningId = bridge.getPendingPrompts(session.sessionId)[0]
?.promptId;
expect(runningId).toBeDefined();

expect(bridge.removePendingPrompt(session.sessionId, runningId!)).toEqual(
{ removed: true },
);

const p2 = bridge.sendPrompt(session.sessionId, {
sessionId: session.sessionId,
prompt: [{ type: 'text', text: 'next prompt' }],
});
await new Promise((r) => setTimeout(r, 20));

const pending = bridge.getPendingPrompts(session.sessionId);
expect(pending).toHaveLength(1);
expect(pending[0]?.text).toBe('next prompt');
expect(pending[0]?.state).toBe('queued');

resolveFirst!();
await p1;
await p2;
await bridge.shutdown();
});

it('removePendingPrompt throws SessionNotFoundError for unknown sessions', () => {
const bridge = makeBridge();
expect(() => bridge.removePendingPrompt('unknown', 'some-id')).toThrow(
SessionNotFoundError,
);
});

it('getPendingPrompts throws SessionNotFoundError for unknown sessions', () => {
const bridge = makeBridge();
expect(() => bridge.getPendingPrompts('unknown')).toThrow(
SessionNotFoundError,
);
});
});

describe('cancelSession', () => {
it('forwards a cancel notification with the routing id', async () => {
const handles: ChannelHandle[] = [];
Expand Down
Loading