fix: handle undefined result from custom() in RPC mode for ask_user_questions (#156, #165, #171) (#199)
In RPC mode, `ctx.ui.custom()` returns `undefined as never`, causing `showInterviewRound` to return undefined and `Object.keys(result.answers)` to throw TypeError. When `showInterviewRound` returns undefined (RPC mode), fall back to sequential `ctx.ui.select()` calls for each question, forwarding the abort signal (#171) and supporting `allowMultiple` (#165). - Add `allowMultiple` to `ExtensionUIDialogOptions` - Widen `select()` return type to `string | string[] | undefined` - Add `allowMultiple` to RPC select request and `values` array to response - Update RPC `select()` to forward `allowMultiple` and parse array responses - Guard existing `ctx.ui.select()` callers against the widened return type Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
af27c5dd3c
commit
3084366d87
5 changed files with 49 additions and 6 deletions
|
|
@ -86,6 +86,8 @@ export interface ExtensionUIDialogOptions {
|
|||
signal?: AbortSignal;
|
||||
/** Timeout in milliseconds. Dialog auto-dismisses with live countdown display. */
|
||||
timeout?: number;
|
||||
/** When true, the user can select multiple options. The return type becomes `string[]`. */
|
||||
allowMultiple?: boolean;
|
||||
}
|
||||
|
||||
/** Placement for extension widgets. */
|
||||
|
|
@ -105,8 +107,8 @@ export type TerminalInputHandler = (data: string) => { consume?: boolean; data?:
|
|||
* Each mode (interactive, RPC, print) provides its own implementation.
|
||||
*/
|
||||
export interface ExtensionUIContext {
|
||||
/** Show a selector and return the user's choice. */
|
||||
select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | undefined>;
|
||||
/** Show a selector and return the user's choice. When `opts.allowMultiple` is true, returns an array. */
|
||||
select(title: string, options: string[], opts?: ExtensionUIDialogOptions): Promise<string | string[] | undefined>;
|
||||
|
||||
/** Show a confirmation dialog. */
|
||||
confirm(title: string, message: string, opts?: ExtensionUIDialogOptions): Promise<boolean>;
|
||||
|
|
|
|||
|
|
@ -119,8 +119,8 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
|
|||
*/
|
||||
const createExtensionUIContext = (): ExtensionUIContext => ({
|
||||
select: (title, options, opts) =>
|
||||
createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "value" in r ? r.value : undefined,
|
||||
createDialogPromise(opts, undefined, { method: "select", title, options, timeout: opts?.timeout, allowMultiple: opts?.allowMultiple }, (r) =>
|
||||
"cancelled" in r && r.cancelled ? undefined : "values" in r ? r.values : "value" in r ? r.value : undefined,
|
||||
),
|
||||
|
||||
confirm: (title, message, opts) =>
|
||||
|
|
|
|||
|
|
@ -210,7 +210,7 @@ export type RpcResponse =
|
|||
|
||||
/** Emitted when an extension needs user input */
|
||||
export type RpcExtensionUIRequest =
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number }
|
||||
| { type: "extension_ui_request"; id: string; method: "select"; title: string; options: string[]; timeout?: number; allowMultiple?: boolean }
|
||||
| { type: "extension_ui_request"; id: string; method: "confirm"; title: string; message: string; timeout?: number }
|
||||
| {
|
||||
type: "extension_ui_request";
|
||||
|
|
@ -253,6 +253,7 @@ export type RpcExtensionUIRequest =
|
|||
/** Response to an extension UI request */
|
||||
export type RpcExtensionUIResponse =
|
||||
| { type: "extension_ui_response"; id: string; value: string }
|
||||
| { type: "extension_ui_response"; id: string; values: string[] }
|
||||
| { type: "extension_ui_response"; id: string; confirmed: boolean }
|
||||
| { type: "extension_ui_response"; id: string; cancelled: true };
|
||||
|
||||
|
|
|
|||
|
|
@ -144,6 +144,46 @@ export default function AskUserQuestions(pi: ExtensionAPI) {
|
|||
// Delegate to shared interview UI
|
||||
const result = await showInterviewRound(params.questions, {}, ctx);
|
||||
|
||||
// RPC mode fallback: custom() returns undefined, so showInterviewRound
|
||||
// may return undefined. Fall back to sequential ctx.ui.select() calls.
|
||||
if (!result) {
|
||||
const answers: Record<string, { answers: string[] }> = {};
|
||||
for (const q of params.questions) {
|
||||
const options = q.options.map((o) => o.label);
|
||||
if (!q.allowMultiple) {
|
||||
options.push(OTHER_OPTION_LABEL);
|
||||
}
|
||||
const selected = await ctx.ui.select(
|
||||
`${q.header}: ${q.question}`,
|
||||
options,
|
||||
{ signal, ...(q.allowMultiple ? { allowMultiple: true } : {}) },
|
||||
);
|
||||
if (selected === undefined) {
|
||||
return errorResult("ask_user_questions was cancelled", params.questions);
|
||||
}
|
||||
answers[q.id] = {
|
||||
answers: Array.isArray(selected) ? selected : [selected],
|
||||
};
|
||||
}
|
||||
const roundResult: RoundResult = {
|
||||
endInterview: false,
|
||||
answers: Object.fromEntries(
|
||||
Object.entries(answers).map(([id, a]) => [
|
||||
id,
|
||||
{ selected: a.answers.length === 1 ? a.answers[0] : a.answers, notes: "" },
|
||||
]),
|
||||
),
|
||||
};
|
||||
return {
|
||||
content: [{ type: "text" as const, text: JSON.stringify({ answers }) }],
|
||||
details: {
|
||||
questions: params.questions,
|
||||
response: roundResult,
|
||||
cancelled: false,
|
||||
} satisfies LocalResultDetails,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if cancelled (empty answers = user exited)
|
||||
const hasAnswers = Object.keys(result.answers).length > 0;
|
||||
if (!hasAnswers) {
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ export function registerSearchProviderCommand(pi: ExtensionAPI): void {
|
|||
return
|
||||
}
|
||||
|
||||
chosen = parseSelectChoice(result)
|
||||
chosen = parseSelectChoice(Array.isArray(result) ? result[0] : result)
|
||||
}
|
||||
|
||||
setSearchProviderPreference(chosen)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue