fix(user-questions): normalize in-flight aborts
This commit is contained in:
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)
|
||||
|
||||
Reference in new issue
Block a user