diff --git a/packages/ui/stdio-agent/src/stdio-chat.ts b/packages/ui/stdio-agent/src/stdio-chat.ts index 4745aefb47..8dc402df52 100644 --- a/packages/ui/stdio-agent/src/stdio-chat.ts +++ b/packages/ui/stdio-agent/src/stdio-chat.ts @@ -223,13 +223,10 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt if (activeQuestion !== undefined) return const pending = questionQueue.shift() if (pending === undefined) return - if (pending.request.signal?.aborted) { - pending.reject(new UserInteractionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED')) - startNextQuestion() - return - } + // The queue never contains an aborted pending ask: the seam rejects an + // already-aborted request synchronously, and queued asks attach their + // abort listener before enqueueing. activeQuestion = pending - pending.request.signal?.addEventListener('abort', pending.onAbort, { once: true }) renderQuestion(pending) } @@ -325,11 +322,19 @@ export function createStdioChat(ctx: Context, config: Config, runtime: StdioRunt resolve, reject, onAbort: () => { - activeQuestion = undefined + if (activeQuestion === pending) { + activeQuestion = undefined + disposeQuestion(pending) + startNextQuestion() + return + } + // If it is not active, this listener can only fire while the ask + // remains queued; settled asks remove the listener first. + questionQueue.splice(questionQueue.indexOf(pending), 1) disposeQuestion(pending) - startNextQuestion() }, } + request.signal?.addEventListener('abort', pending.onAbort, { once: true }) questionQueue.push(pending) startNextQuestion() }) diff --git a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts index 1f4b44e1cf..93683d0768 100644 --- a/packages/ui/stdio-agent/tests/stdio-chat.spec.ts +++ b/packages/ui/stdio-agent/tests/stdio-chat.spec.ts @@ -550,15 +550,44 @@ describe('createStdioChat input', () => { const controller = new AbortController() const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) - const secondRejected = expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' }) await new Promise(r => setImmediate(r)) controller.abort() + + await expect(Promise.race([ + second.then( + () => 'resolved', + (error: unknown) => (error as { code?: string }).code, + ), + new Promise((resolve) => { setImmediate(() => { resolve('pending') }) }), + ])).resolves.toBe('ASK_ABORTED') + expect(out.text()).not.toContain('\nSecond?\n') input.feed('first answer') + await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) + }) + + it('removes an aborted queued question without promoting later queued work early', async () => { + const { ctx, input, out } = await setup() + const controller = new AbortController() + const first = ctx.userInteraction.ask({ questions: [{ id: 'first', question: 'First?' }] }) + const second = ctx.userInteraction.ask({ questions: [{ id: 'second', question: 'Second?' }], signal: controller.signal }) + const third = ctx.userInteraction.ask({ questions: [{ id: 'third', question: 'Third?' }] }) + await new Promise(r => setImmediate(r)) + + controller.abort() + + await expect(second).rejects.toMatchObject({ code: 'ASK_ABORTED' }) + expect(out.text()).toContain('\nFirst?\n') + expect(out.text()).not.toContain('\nSecond?\n') + expect(out.text()).not.toContain('\nThird?\n') + input.feed('first answer') + await new Promise(r => setImmediate(r)) + + expect(out.text()).toContain('\nThird?\n') + input.feed('third answer') await expect(first).resolves.toEqual({ answers: [{ id: 'first', selected: [], custom: 'first answer' }] }) - await secondRejected - expect(out.text()).not.toContain('\nSecond?\n') + await expect(third).resolves.toEqual({ answers: [{ id: 'third', selected: [], custom: 'third answer' }] }) }) it('rejects active and queued questions when the UI is disposed', async () => {