feat: support ask flow in RPC clients
CI / validate (push) Failing after 13s

This commit is contained in:
jb
2026-08-22 21:55:08 -04:00
parent 49482b7e5d
commit 80da307828
5 changed files with 346 additions and 14 deletions
+2 -1
View File
@@ -14,7 +14,8 @@ The codebase is split so the implementation reads through file boundaries and na
### Tool surface
- `src/index.ts` — extension entrypoint
- `src/ask-tool.ts` — tool registration, non-interactive fallback, transcript rendering, ask payload capture
- `src/ask-tool.ts` — tool registration, mode routing, transcript rendering, ask payload capture
- `src/rpc-flow.ts` — standard-dialog fallback for RPC clients
- `src/answer-commands.ts` — `/answer`, `/answer:again`, and `/ask:replay` command wiring
- `src/answer-extraction.ts` — configured, session-scope-aware extraction model selection, synthetic `ask_user` tool-call extraction, and tolerant text fallback
- `src/ask-payload-store.ts` — branch-aware persisted ask payload lookup
+4 -2
View File
@@ -268,7 +268,9 @@ Dirty dismiss:
## Non-TUI and non-interactive modes
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. In print, JSON, RPC, or any other non-TUI mode, the tool returns a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening custom UI.
The rich ask flow uses `ctx.ui.custom()` and opens only in TUI mode. RPC mode falls back to Pi's standard `select` and `input` dialogs so RPC clients can bridge questions into their own interface. Questions are presented sequentially; multi-select questions use repeated toggle dialogs followed by `Done`. The RPC fallback collects option selections and custom text, but does not expose preview panes, notes, or elaboration actions.
Print, JSON, and other non-interactive modes return a `Needs user input: ask_user requires interactive TUI mode.` message in `content` and a cancelled result in `details` instead of opening UI.
The public tool schema requires question `id` and `prompt` plus option `value` and `label`, and it restricts question `type` to `single`, `multi`, or `preview`, so malformed structural fields fail before execution. The tool still validates trimmed text, uniqueness, option counts, and preview requirements during execution and returns structured issues for those failures. Result rendering falls back to Pi's raw tool-error text when schema validation prevents execution.
@@ -280,7 +282,7 @@ When enabled, pi-ask emits one best-effort external notification per ask session
## Remote inter-extension events
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. RPC or headless integrations should use a trusted in-process bridge extension that consumes these events rather than expecting the TUI-only custom surface to open.
pi-ask exposes a local `pi.events` contract for trusted Pi extensions. It does not expose a network API and does not automate terminal keystrokes. Integrations that need the complete rich-flow semantics should use a trusted in-process bridge extension that consumes these events; the built-in RPC fallback intentionally exposes only standard dialog semantics.
Channels:
+4
View File
@@ -15,6 +15,7 @@ import {
} from "./ask-tool-helpers.ts";
import { getAskConfigStore } from "./config/store.ts";
import type { RemoteAskRuntime } from "./remote-ask.ts";
import { runRpcAskFlow } from "./rpc-flow.ts";
import { AskParamsSchema } from "./schema.ts";
import { prepareAskParams } from "./state/normalize.ts";
import type { AskParams } from "./types.ts";
@@ -69,6 +70,9 @@ async function executeAskTool(
source: "tool",
sourceEntryId: toolCallId,
});
if (ctx.mode === "rpc") {
return successfulResponse(await runRpcAskFlow(ctx.ui, validation.state));
}
if (ctx.mode !== "tui") {
return nonInteractiveResponse(validation.state);
}
+227
View File
@@ -0,0 +1,227 @@
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
import type {
AskQuestion,
AskResult,
AskResultAnswer,
AskState,
} from "./types.ts";
type DialogUi = Pick<ExtensionContext["ui"], "input" | "select">;
const CUSTOM_ANSWER_LABEL = "Type your own";
const DONE_LABEL = "Done";
export async function runRpcAskFlow(
ui: DialogUi,
state: AskState
): Promise<AskResult> {
const answers: AskResult["answers"] = {};
for (const question of state.questions) {
const answer = await askRpcQuestion(ui, question);
if (!answer) {
return createRpcResult(state, answers, true);
}
answers[question.id] = answer;
}
return createRpcResult(state, answers, false);
}
async function askRpcQuestion(
ui: DialogUi,
question: AskQuestion
): Promise<AskResultAnswer | undefined> {
if (question.type === "multi") {
return askRpcMultiQuestion(ui, question);
}
const optionLabels = question.options.map((option, index) =>
formatOptionLabel(index, option.label)
);
const customLabel = formatOptionLabel(
question.options.length,
CUSTOM_ANSWER_LABEL
);
const selectedLabel = await ui.select(questionTitle(question), [
...optionLabels,
customLabel,
]);
if (selectedLabel === undefined) {
return;
}
if (selectedLabel === customLabel) {
return askRpcCustomAnswer(ui, question, []);
}
const selectedIndex = optionLabels.indexOf(selectedLabel);
const option = question.options[selectedIndex];
if (!option) {
return;
}
return serializeSelections(question, [selectedIndex]);
}
async function askRpcMultiQuestion(
ui: DialogUi,
question: AskQuestion
): Promise<AskResultAnswer | undefined> {
const selectedIndices = new Set<number>();
let customText: string | undefined;
while (true) {
const step = await askRpcMultiStep(
ui,
question,
selectedIndices,
customText
);
if (step.kind === "cancel") {
return;
}
if (step.kind === "done") {
return step.answer;
}
customText = step.customText;
}
}
type MultiStep =
| { kind: "cancel" }
| { kind: "continue"; customText?: string }
| { kind: "done"; answer: AskResultAnswer };
async function askRpcMultiStep(
ui: DialogUi,
question: AskQuestion,
selectedIndices: Set<number>,
customText: string | undefined
): Promise<MultiStep> {
const optionLabels = question.options.map((option, index) =>
formatOptionLabel(
index,
`[${selectedIndices.has(index) ? "x" : " "}] ${option.label}`
)
);
const doneLabel = formatOptionLabel(question.options.length, DONE_LABEL);
const customLabel = formatOptionLabel(
question.options.length + 1,
CUSTOM_ANSWER_LABEL
);
const selectedLabel = await ui.select(questionTitle(question), [
...optionLabels,
doneLabel,
customLabel,
]);
if (selectedLabel === undefined) {
return { kind: "cancel" };
}
if (selectedLabel === doneLabel) {
return {
kind: "done",
answer: serializeSelections(
question,
[...selectedIndices].sort((left, right) => left - right),
customText
),
};
}
if (selectedLabel === customLabel) {
const customAnswer = await ui.input(
questionTitle(question),
"Type your answer"
);
return customAnswer === undefined
? { kind: "cancel" }
: { kind: "continue", customText: customAnswer };
}
const selectedIndex = optionLabels.indexOf(selectedLabel);
if (selectedIndex < 0) {
return { kind: "cancel" };
}
toggleSelectedIndex(selectedIndices, selectedIndex);
return { kind: "continue", customText };
}
function toggleSelectedIndex(
selectedIndices: Set<number>,
selectedIndex: number
): void {
if (selectedIndices.has(selectedIndex)) {
selectedIndices.delete(selectedIndex);
return;
}
selectedIndices.add(selectedIndex);
}
async function askRpcCustomAnswer(
ui: DialogUi,
question: AskQuestion,
selectedIndices: number[]
): Promise<AskResultAnswer | undefined> {
const customText = await ui.input(
questionTitle(question),
"Type your answer"
);
if (customText === undefined) {
return;
}
return serializeSelections(question, selectedIndices, customText);
}
function serializeSelections(
question: AskQuestion,
selectedIndices: number[],
customText?: string
): AskResultAnswer {
const selectedOptions = selectedIndices.map(
(index) => question.options[index]
);
const answer: AskResultAnswer = {
indices: selectedIndices,
labels: selectedOptions.map((option) => option.label),
values: selectedOptions.map((option) => option.value),
};
if (customText !== undefined) {
answer.customText = customText;
}
return answer;
}
function createRpcResult(
state: AskState,
answers: AskResult["answers"],
cancelled: boolean
): AskResult {
return {
title: state.title,
cancelled,
mode: "submit",
questions: state.questions.map((question) => {
const summary: AskResult["questions"][number] = {
id: question.id,
label: question.label,
prompt: question.prompt,
type: question.requestedType ?? question.type,
};
if (
question.presentedType &&
question.presentedType !== question.requestedType
) {
summary.presentedType = question.presentedType;
}
return summary;
}),
answers,
};
}
function questionTitle(question: AskQuestion): string {
return `${question.label}: ${question.prompt}`;
}
function formatOptionLabel(index: number, label: string): string {
return `${index + 1}. ${label}`;
}
+109 -11
View File
@@ -56,6 +56,31 @@ function makeCtx(hasUi: boolean, mode = hasUi ? "tui" : "print"): unknown {
return { [HAS_UI]: hasUi, mode };
}
function makeRpcCtx(responses: Array<string | undefined>) {
const requests: Array<{
title: string;
options?: string[];
placeholder?: string;
}> = [];
return {
ctx: {
[HAS_UI]: true,
mode: "rpc",
ui: {
select(title: string, options: string[]) {
requests.push({ title, options });
return Promise.resolve(responses.shift());
},
input(title: string, placeholder?: string) {
requests.push({ title, placeholder });
return Promise.resolve(responses.shift());
},
},
},
requests,
};
}
function sampleParams(): AskParams {
return {
title: "Clarify next step",
@@ -155,23 +180,96 @@ test("ask tool returns pending questions in non-interactive mode", async () => {
assert.match(result.content[0].text, CUSTOM_OPTION_RE);
});
test("ask tool does not open custom UI outside TUI mode", async () => {
test("ask tool uses standard dialogs in RPC mode", async () => {
const { tool } = registerMockTool();
let customOpened = false;
const { ctx, requests } = makeRpcCtx(["1. Speed"]);
const result = await tool.execute("call-1", sampleParams(), undefined, noop, {
[HAS_UI]: true,
mode: "rpc",
ui: {
custom() {
customOpened = true;
},
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.deepEqual(requests, [
{
title: "Goal: What should I optimize for?",
options: ["1. Speed", "2. Safety", "3. Type your own"],
},
]);
assert.equal(result.details.cancelled, false);
assert.deepEqual(result.details.answers.goal, {
indices: [0],
labels: ["Speed"],
values: ["speed"],
});
});
test("ask tool collects a custom answer through an RPC input dialog", async () => {
const { tool } = registerMockTool();
const { ctx, requests } = makeRpcCtx(["3. Type your own", "Balance both"]);
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.deepEqual(requests[1], {
title: "Goal: What should I optimize for?",
placeholder: "Type your answer",
});
assert.deepEqual(result.details.answers.goal, {
indices: [],
labels: [],
values: [],
customText: "Balance both",
});
});
test("ask tool collects multiple selections through RPC dialogs", async () => {
const { tool } = registerMockTool();
const params = sampleParams();
params.questions[0].type = "multi";
const { ctx, requests } = makeRpcCtx([
"1. [ ] Speed",
"2. [ ] Safety",
"3. Done",
]);
const result = await tool.execute("call-1", params, undefined, noop, ctx);
assert.equal(requests.length, 3);
assert.deepEqual(requests[1]?.options, [
"1. [x] Speed",
"2. [ ] Safety",
"3. Done",
"4. Type your own",
]);
assert.deepEqual(result.details.answers.goal, {
indices: [0, 1],
labels: ["Speed", "Safety"],
values: ["speed", "safety"],
});
});
test("ask tool returns a cancelled RPC result when a dialog is dismissed", async () => {
const { tool } = registerMockTool();
const { ctx } = makeRpcCtx([undefined]);
const result = await tool.execute(
"call-1",
sampleParams(),
undefined,
noop,
ctx
);
assert.equal(customOpened, false);
assert.equal(result.details.cancelled, true);
assert.match(result.content[0].text, NON_INTERACTIVE_MESSAGE_RE);
assert.deepEqual(result.details.answers, {});
});
test("ask tool includes custom answer fallback for preview questions", async () => {