fix(user-questions): normalize in-flight aborts

This commit is contained in:
imccyu committed 2026-08-23 16:16:03 +08:00
1 parent 639dcef5d8
commit 7f908c1bb4
2 files changed
+60 -5

No files matched your search

@@ -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<AskUserQuestionAnswer> {
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
}
}
}
@@ -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<never>()
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)