diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 4feb4e296f..a9ddc147af 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -49,6 +49,14 @@ export class UserQuestionError extends HarnessError { } } +function abortedQuestion(cause?: unknown): UserQuestionError { + return new UserQuestionError( + 'ask_user_question was aborted before the user answered', + 'ASK_ABORTED', + cause === undefined ? undefined : { cause }, + ) +} + /** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */ export class UserQuestionService extends Service { private provider: UserQuestionProvider | undefined @@ -87,13 +95,14 @@ export class UserQuestionService extends Service { * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. - * @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied - * agent is not the registry's exact live instance, or `DELEGATED_CALLER` - * when that live agent is owned by another agent. + * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal + * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent + * is not the registry's exact live instance, or `DELEGATED_CALLER` when + * that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { - throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') + throw abortedQuestion() } if (request.questions.length === 0) { throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') @@ -138,7 +147,14 @@ export class UserQuestionService extends Service { if (this.provider === undefined) { throw new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER') } - return this.provider.ask(request) + try { + return await this.provider.ask(request) + } catch (error) { + if (request.signal?.aborted && !(error instanceof UserQuestionError)) { + throw abortedQuestion(error) + } + throw error + } } } diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 4c6c153a4a..3ba062d23c 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -82,6 +82,45 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('normalizes an in-flight signal cancellation to ASK_ABORTED', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const pending = Promise.withResolvers() + ctx.userQuestions.registerProvider({ ask: () => pending.promise }) + const controller = new AbortController() + + const answer = ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + }) + controller.abort() + pending.reject(controller.signal.reason) + + await expect(answer).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_ABORTED', + cause: controller.signal.reason, + }) + }) + + it('preserves a domain rejection when its provider also aborts the signal', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const controller = new AbortController() + const cancelled = new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED') + ctx.userQuestions.registerProvider({ + ask: () => { + controller.abort() + return Promise.reject(cancelled) + }, + }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + })).rejects.toBe(cancelled) + }) + it('rejects empty question batches before reaching the provider', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService)