From 80da3078281f839f075bd63bf221ddd0de2cbd86 Mon Sep 17 00:00:00 2001 From: JB Date: Sat, 22 Aug 2026 21:55:08 -0400 Subject: [PATCH] feat: support ask flow in RPC clients --- docs/architecture.md | 3 +- docs/contract.md | 6 +- src/ask-tool.ts | 4 + src/rpc-flow.ts | 227 +++++++++++++++++++++++++++++++++++++++++ tests/ask-tool.test.ts | 120 ++++++++++++++++++++-- 5 files changed, 346 insertions(+), 14 deletions(-) create mode 100644 src/rpc-flow.ts diff --git a/docs/architecture.md b/docs/architecture.md index 2a34fbe..54a5ce7 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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 diff --git a/docs/contract.md b/docs/contract.md index 1c46f07..496e972 100644 --- a/docs/contract.md +++ b/docs/contract.md @@ -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: diff --git a/src/ask-tool.ts b/src/ask-tool.ts index 5ca4d97..b83c5b0 100644 --- a/src/ask-tool.ts +++ b/src/ask-tool.ts @@ -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); } diff --git a/src/rpc-flow.ts b/src/rpc-flow.ts new file mode 100644 index 0000000..e87d8f1 --- /dev/null +++ b/src/rpc-flow.ts @@ -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; + +const CUSTOM_ANSWER_LABEL = "Type your own"; +const DONE_LABEL = "Done"; + +export async function runRpcAskFlow( + ui: DialogUi, + state: AskState +): Promise { + 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 { + 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 { + const selectedIndices = new Set(); + 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, + customText: string | undefined +): Promise { + 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, + selectedIndex: number +): void { + if (selectedIndices.has(selectedIndex)) { + selectedIndices.delete(selectedIndex); + return; + } + selectedIndices.add(selectedIndex); +} + +async function askRpcCustomAnswer( + ui: DialogUi, + question: AskQuestion, + selectedIndices: number[] +): Promise { + 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}`; +} diff --git a/tests/ask-tool.test.ts b/tests/ask-tool.test.ts index c865918..ef58e07 100644 --- a/tests/ask-tool.test.ts +++ b/tests/ask-tool.test.ts @@ -56,6 +56,31 @@ function makeCtx(hasUi: boolean, mode = hasUi ? "tui" : "print"): unknown { return { [HAS_UI]: hasUi, mode }; } +function makeRpcCtx(responses: Array) { + 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 () => {