Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions packages/coding-agent/docs/rpc.md
Original file line number Diff line number Diff line change
Expand Up @@ -1204,6 +1204,10 @@ Some `ExtensionUIContext` methods are not supported or degraded in RPC mode beca

Note: `ctx.mode` is `"rpc"` and `ctx.hasUI` is `true` in RPC mode because the dialog and fire-and-forget methods are functional via the extension UI sub-protocol. Use `ctx.mode === "tui"` to guard TUI-specific features like `custom()` that require a real terminal.

The built-in `clarify_user` tool accepts question groups and emits one existing `select` or `input` request per question in RPC mode. Dialogs carry the question text and option labels; question ids, labels, reasons, and group structure remain in the tool result rather than the dialog payload. Structured option values, trimmed non-empty freeform answers, cancellation, and abort signals are preserved. Empty freeform answers and responses that do not match an offered option label return an explicit error result. The error result still sets `cancelled: true`; clients should display the error text rather than treating it as a user cancellation. The tool does not use `custom()` in RPC mode. The native TUI dialog is unchanged, and non-interactive `print` and `json` modes continue to reject clarification requests.

After an abort, the runtime settles the pending dialog and ignores late responses to that request id, but it does not emit a separate invalidation event. Clients must dismiss any pending clarification dialog when the turn is aborted or ends.

### Extension UI Requests (stdout)

All requests have `type: "extension_ui_request"`, a unique `id`, and a `method` field.
Expand Down
74 changes: 54 additions & 20 deletions packages/coding-agent/src/features/step-questionnaire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,41 +208,56 @@ function answerText(questions: readonly NormalizedQuestion[], answers: readonly
.join("\n\n");
}

async function executeFallback(
questions: NormalizedQuestion[],
ctx: ExtensionContext,
): Promise<StepQuestionnaireDetails> {
interface FallbackOutcome {
details: StepQuestionnaireDetails;
error?: string;
}

async function executeFallback(questions: NormalizedQuestion[], ctx: ExtensionContext): Promise<FallbackOutcome> {
const answers: StepQuestionnaireAnswer[] = [];
const finish = (cancelled: boolean, error?: string): FallbackOutcome => ({
details: { questions, answers, cancelled },
...(error === undefined ? {} : { error }),
});
const readFreeform = async (question: NormalizedQuestion): Promise<{ value?: string; error?: string }> => {
const answer = await ctx.ui.input(question.question, undefined, { signal: ctx.signal });
if (answer === undefined) return {};
const value = answer.trim();
return value ? { value } : { error: "Error: clarify_user requires a non-empty freeform answer" };
};
for (const question of questions) {
if (question.options.length === 0) {
const answer = await ctx.ui.input(question.question);
if (answer === undefined) return { questions, answers, cancelled: true };
const result = await readFreeform(question);
if (result.error) return finish(true, result.error);
if (result.value === undefined) return finish(true);
answers.push({
id: question.id,
value: answer,
label: answer,
value: result.value,
label: result.value,
wasCustom: true,
});
continue;
}
const labels = question.options.map((option) => option.label);
if (question.allowFreeform) labels.push("Type a custom answer");
const selected = await ctx.ui.select(question.question, labels);
if (selected === undefined) return { questions, answers, cancelled: true };
const selected = await ctx.ui.select(question.question, labels, { signal: ctx.signal });
if (selected === undefined) return finish(true);
const index = labels.indexOf(selected);
if (index === question.options.length) {
const answer = await ctx.ui.input(question.question);
if (answer === undefined) return { questions, answers, cancelled: true };
const result = await readFreeform(question);
if (result.error) return finish(true, result.error);
if (result.value === undefined) return finish(true);
answers.push({
id: question.id,
value: answer,
label: answer,
value: result.value,
label: result.value,
wasCustom: true,
});
continue;
}
if (index < 0) return finish(true, "Error: clarify_user received an option that was not offered");
const option = question.options[index];
if (!option) return { questions, answers, cancelled: true };
if (!option) return finish(true, "Error: clarify_user received an option that was not offered");
answers.push({
id: question.id,
value: option.value,
Expand All @@ -251,7 +266,7 @@ async function executeFallback(
index: index + 1,
});
}
return { questions, answers, cancelled: false };
return finish(false);
}

async function executeNativeDialog(
Expand Down Expand Up @@ -523,7 +538,8 @@ async function executeQuestionnaire(
telemetry?: StepTelemetryReporter,
): Promise<AgentToolResult<StepQuestionnaireDetails>> {
const questions = normalizeQuestions(params);
if (!ctx.hasUI || ctx.mode !== "tui") {
const supportsQuestionnaire = ctx.mode === "tui" || ctx.mode === "rpc";
if (!ctx.hasUI || !supportsQuestionnaire) {
return response({ questions, answers: [], cancelled: true }, "Error: clarify_user requires an interactive UI");
}
if (questions.length === 0 || questions.some((item) => !item.question)) {
Expand All @@ -535,12 +551,26 @@ async function executeQuestionnaire(
"Error: allow_freeform=false requires at least one option",
);
}
if (new Set(questions.map((item) => item.id)).size !== questions.length) {
return response({ questions, answers: [], cancelled: true }, "Error: question ids must be unique");
}
for (const question of questions) {
const labels = question.options.map((option) => option.label.trim());
if (new Set(labels).size !== labels.length) {
return response(
{ questions, answers: [], cancelled: true },
"Error: option labels must be unique within each question",
);
}
}

const askedAt = Date.now();
const details =
typeof ctx.ui.custom === "function"
? await executeNativeDialog(questions, ctx)
const execution =
ctx.mode === "tui" && typeof ctx.ui.custom === "function"
? { details: await executeNativeDialog(questions, ctx) }
: await executeFallback(questions, ctx);
const { details, error } = execution;
if (error) return response(details, error);
const outcome = details.cancelled
? "cancelled"
: details.answers.some((answer) => answer.wasCustom)
Expand Down Expand Up @@ -583,6 +613,10 @@ export function registerStepClarifyUserExtension(pi: ExtensionAPI, telemetry?: S
const block = result.content.find((item) => item.type === "text");
return new Text(block?.type === "text" ? block.text : "", 0, 0);
}
const errorText = result.content.find(
(item): item is { type: "text"; text: string } => item.type === "text" && item.text.startsWith("Error:"),
)?.text;
if (errorText) return new Text(theme.fg("warning", errorText), 0, 0);
if (details.cancelled) {
return new Text(theme.fg("warning", "Clarification cancelled"), 0, 0);
}
Expand Down
Loading
Loading