diff --git a/docs/providers.md b/docs/providers.md index 3a7a0cb44..ca949357a 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -24,7 +24,7 @@ Pi MCP support depends on the open-source `pi-mcp-adapter` extension being loade Pi import discovery reads Pi's persisted JSONL session files because Pi RPC does not expose a recent-session listing command. Resume and full history hydration still go through `pi --mode rpc` using the session file as `nativeHandle`. -Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. +Pi RPC extension UI dialog requests (`select`, `input`, `editor`, `confirm`) are bridged into Paseo question permissions and answered with `extension_ui_response`. Pi extensions such as `ask_user` may chain dialogs: for example, a `select` can be followed by an optional-comment `input`. When an `ask_user` tool call declares `allowComment: true`, Paseo presents the selection and optional comment as one question permission, answers Pi's initial `select` immediately, then auto-answers the follow-up optional `input` with the comment the user already supplied (or an empty string). Preserve placeholders and optional/skip semantics for standalone optional inputs so the app can still distinguish "skip this optional input" from "cancel the whole dialog." Fire-and-forget extension UI requests such as notifications are intentionally ignored by the provider adapter unless Paseo grows first-class UI for them. OpenCode MCP injection is dynamic and session-scoped. Call OpenCode's `mcp.add` endpoint with the MCP server config and do not follow it with `mcp.connect`; `connect` only toggles MCP servers already present in OpenCode's own config. New OpenCode versions return `McpServerNotFoundError`/404 for `connect` after a dynamic add because the server is not config-backed, while older versions silently swallowed the same missing-config path. diff --git a/packages/app/src/components/question-form-card-core.test.ts b/packages/app/src/components/question-form-card-core.test.ts new file mode 100644 index 000000000..de076516d --- /dev/null +++ b/packages/app/src/components/question-form-card-core.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, test } from "vitest"; +import { + areQuestionsAnswered, + buildQuestionFormAnswers, + parseQuestionFormQuestions, + questionShowsTextInput, + resolveDismissLabel, + shouldSubmitEmptyOnDismiss, +} from "./question-form-card-core"; + +describe("question form card core", () => { + test("treats optional input prompts as skippable empty answers", () => { + const questions = parseQuestionFormQuestions({ + questions: [ + { + question: "Optional comment?", + header: "Response", + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + dismissLabel: "Skip", + }, + ], + }); + + if (!questions) throw new Error("questions did not parse"); + expect(areQuestionsAnswered(questions, {}, {})).toBe(true); + expect(buildQuestionFormAnswers(questions, {}, {})).toEqual({ Response: "" }); + expect(shouldSubmitEmptyOnDismiss(questions)).toBe(true); + expect(resolveDismissLabel(questions)).toBe("Skip"); + }); + + test("requires a selection for option-only questions", () => { + const questions = parseQuestionFormQuestions({ + questions: [ + { + question: "Pick one", + header: "Response", + options: [{ label: "A" }, { label: "B" }], + multiSelect: false, + }, + ], + }); + + if (!questions) throw new Error("questions did not parse"); + const [question] = questions; + if (!question) throw new Error("question missing"); + expect(questionShowsTextInput(question)).toBe(false); + expect(areQuestionsAnswered(questions, {}, { 0: "freeform" })).toBe(false); + expect(areQuestionsAnswered(questions, { 0: new Set([1]) }, {})).toBe(true); + expect(buildQuestionFormAnswers(questions, { 0: new Set([1]) }, {})).toEqual({ + Response: "B", + }); + }); + + test("shows text input for explicit other questions", () => { + const questions = parseQuestionFormQuestions({ + questions: [ + { + question: "Pick or type", + header: "Response", + options: [{ label: "A" }], + isOther: true, + multiSelect: false, + }, + ], + }); + + if (!questions) throw new Error("questions did not parse"); + const [question] = questions; + if (!question) throw new Error("question missing"); + expect(questionShowsTextInput(question)).toBe(true); + expect(areQuestionsAnswered(questions, {}, { 0: "custom" })).toBe(true); + expect(buildQuestionFormAnswers(questions, {}, { 0: "custom" })).toEqual({ + Response: "custom", + }); + }); +}); diff --git a/packages/app/src/components/question-form-card-core.ts b/packages/app/src/components/question-form-card-core.ts new file mode 100644 index 000000000..f6f116983 --- /dev/null +++ b/packages/app/src/components/question-form-card-core.ts @@ -0,0 +1,143 @@ +export interface QuestionOption { + label: string; + description?: string; +} + +export interface QuestionFormQuestion { + question: string; + header: string; + options: QuestionOption[]; + multiSelect: boolean; + allowOther: boolean; + allowEmpty: boolean; + placeholder?: string; + dismissLabel?: string; +} + +export type QuestionSelections = Record>; +export type QuestionOtherTexts = Record; + +function readOptionalString(record: Record, key: string): string | undefined { + const value = record[key]; + return typeof value === "string" ? value : undefined; +} + +export function parseQuestionFormQuestions(input: unknown): QuestionFormQuestion[] | null { + if ( + typeof input !== "object" || + input === null || + !("questions" in input) || + !Array.isArray((input as Record).questions) + ) { + return null; + } + const raw = (input as Record).questions as unknown[]; + const questions: QuestionFormQuestion[] = []; + for (const item of raw) { + if (typeof item !== "object" || item === null) return null; + const q = item as Record; + if (typeof q.question !== "string" || typeof q.header !== "string") return null; + if (!Array.isArray(q.options)) return null; + const options: QuestionOption[] = []; + for (const opt of q.options as unknown[]) { + if (typeof opt !== "object" || opt === null) return null; + const o = opt as Record; + if (typeof o.label !== "string") return null; + options.push({ + label: o.label, + description: typeof o.description === "string" ? o.description : undefined, + }); + } + questions.push({ + question: q.question, + header: q.header, + options, + multiSelect: q.multiSelect === true, + allowOther: q.allowOther === true || q.isOther === true, + allowEmpty: q.allowEmpty === true, + placeholder: readOptionalString(q, "placeholder"), + dismissLabel: readOptionalString(q, "dismissLabel"), + }); + } + return questions.length > 0 ? questions : null; +} + +export function questionShowsTextInput(question: QuestionFormQuestion): boolean { + return question.options.length === 0 || question.allowOther; +} + +export function isQuestionAnswered( + question: QuestionFormQuestion, + qIndex: number, + selections: QuestionSelections, + otherTexts: QuestionOtherTexts, +): boolean { + const selected = selections[qIndex]; + if (selected && selected.size > 0) { + return true; + } + + if (!questionShowsTextInput(question)) { + return false; + } + + const otherText = otherTexts[qIndex]?.trim(); + if (otherText && otherText.length > 0) { + return true; + } + + return question.allowEmpty; +} + +export function areQuestionsAnswered( + questions: QuestionFormQuestion[] | null, + selections: QuestionSelections, + otherTexts: QuestionOtherTexts, +): boolean { + return ( + questions?.every((question, qIndex) => + isQuestionAnswered(question, qIndex, selections, otherTexts), + ) ?? false + ); +} + +export function buildQuestionFormAnswers( + questions: QuestionFormQuestion[], + selections: QuestionSelections, + otherTexts: QuestionOtherTexts, +): Record { + const answers: Record = {}; + for (let i = 0; i < questions.length; i++) { + const q = questions[i]; + const selected = selections[i]; + const otherText = otherTexts[i]?.trim(); + + if (questionShowsTextInput(q)) { + if (otherText && otherText.length > 0) { + answers[q.header] = otherText; + continue; + } + if (q.allowEmpty && q.options.length === 0) { + answers[q.header] = ""; + continue; + } + } + + if (selected && selected.size > 0) { + const labels = Array.from(selected).map((idx) => q.options[idx].label); + answers[q.header] = labels.join(", "); + } + } + return answers; +} + +export function shouldSubmitEmptyOnDismiss(questions: QuestionFormQuestion[]): boolean { + return ( + questions.length > 0 && + questions.every((question) => question.allowEmpty && question.options.length === 0) + ); +} + +export function resolveDismissLabel(questions: QuestionFormQuestion[]): string { + return questions.find((question) => question.dismissLabel)?.dismissLabel ?? "Dismiss"; +} diff --git a/packages/app/src/components/question-form-card.tsx b/packages/app/src/components/question-form-card.tsx index 150221c0a..527ee4b2e 100644 --- a/packages/app/src/components/question-form-card.tsx +++ b/packages/app/src/components/question-form-card.tsx @@ -13,54 +13,16 @@ import { Check, CircleHelp, X } from "lucide-react-native"; import type { PendingPermission } from "@/types/shared"; import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types"; import { isWeb } from "@/constants/platform"; - -interface QuestionOption { - label: string; - description?: string; -} - -interface Question { - question: string; - header: string; - options: QuestionOption[]; - multiSelect: boolean; -} - -function parseQuestions(input: unknown): Question[] | null { - if ( - typeof input !== "object" || - input === null || - !("questions" in input) || - !Array.isArray((input as Record).questions) - ) { - return null; - } - const raw = (input as Record).questions as unknown[]; - const questions: Question[] = []; - for (const item of raw) { - if (typeof item !== "object" || item === null) return null; - const q = item as Record; - if (typeof q.question !== "string" || typeof q.header !== "string") return null; - if (!Array.isArray(q.options)) return null; - const options: QuestionOption[] = []; - for (const opt of q.options as unknown[]) { - if (typeof opt !== "object" || opt === null) return null; - const o = opt as Record; - if (typeof o.label !== "string") return null; - options.push({ - label: o.label, - description: typeof o.description === "string" ? o.description : undefined, - }); - } - questions.push({ - question: q.question, - header: q.header, - options, - multiSelect: q.multiSelect === true, - }); - } - return questions.length > 0 ? questions : null; -} +import { + areQuestionsAnswered, + buildQuestionFormAnswers, + parseQuestionFormQuestions, + questionShowsTextInput, + resolveDismissLabel, + shouldSubmitEmptyOnDismiss, + type QuestionFormQuestion, + type QuestionOption, +} from "./question-form-card-core"; interface QuestionFormCardProps { permission: PendingPermission; @@ -70,6 +32,12 @@ interface QuestionFormCardProps { const IS_WEB = isWeb; +function getQuestionInputPlaceholder(question: QuestionFormQuestion): string { + return ( + question.placeholder ?? (question.options.length === 0 ? "Type your answer..." : "Other...") + ); +} + interface QuestionOptionRowProps { qIndex: number; optIndex: number; @@ -137,6 +105,7 @@ function QuestionOptionRow({ interface QuestionOtherInputProps { qIndex: number; value: string; + placeholder: string; isResponding: boolean; onChange: (qIndex: number, text: string) => void; onSubmit: () => void; @@ -145,6 +114,7 @@ interface QuestionOtherInputProps { function QuestionOtherInput({ qIndex, value, + placeholder, isResponding, onChange, onSubmit, @@ -179,7 +149,7 @@ function QuestionOtherInput({ >>({}); const [otherTexts, setOtherTexts] = useState>({}); @@ -237,33 +207,17 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi } }, []); - const allAnswered = - questions?.every((_, qIndex) => { - const selected = selections[qIndex]; - const otherText = otherTexts[qIndex]?.trim(); - return (selected && selected.size > 0) || (otherText && otherText.length > 0); - }) ?? false; + const allAnswered = areQuestionsAnswered(questions, selections, otherTexts); const handleSubmit = useCallback(() => { if (!questions || !allAnswered || isResponding) return; setRespondingAction("submit"); - const answers: Record = {}; - for (let i = 0; i < questions.length; i++) { - const q = questions[i]; - const selected = selections[i]; - const otherText = otherTexts[i]?.trim(); - - if (otherText && otherText.length > 0) { - answers[q.header] = otherText; - } else if (selected && selected.size > 0) { - const labels = Array.from(selected).map((idx) => q.options[idx].label); - answers[q.header] = labels.join(", "); - } - } - onRespond({ behavior: "allow", - updatedInput: { ...permission.request.input, answers }, + updatedInput: { + ...permission.request.input, + answers: buildQuestionFormAnswers(questions, selections, otherTexts), + }, }); }, [ questions, @@ -276,12 +230,23 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ]); const handleDeny = useCallback(() => { + if (!questions) return; setRespondingAction("dismiss"); + if (shouldSubmitEmptyOnDismiss(questions)) { + onRespond({ + behavior: "allow", + updatedInput: { + ...permission.request.input, + answers: buildQuestionFormAnswers(questions, selections, otherTexts), + }, + }); + return; + } onRespond({ behavior: "deny", message: "Dismissed by user", }); - }, [onRespond]); + }, [questions, onRespond, otherTexts, permission.request.input, selections]); const dismissButtonStyle = useCallback( ({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [ @@ -349,11 +314,14 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi return null; } + const dismissLabel = resolveDismissLabel(questions); + return ( {questions.map((q, qIndex) => { const selected = selections[qIndex] ?? new Set(); const otherText = otherTexts[qIndex] ?? ""; + const showTextInput = questionShowsTextInput(q); return ( @@ -361,27 +329,32 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi {q.question} - - {q.options.map((opt, optIndex) => ( - - ))} - - + {q.options.length > 0 ? ( + + {q.options.map((opt, optIndex) => ( + + ))} + + ) : null} + {showTextInput ? ( + + ) : null} ); })} @@ -393,7 +366,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi ) : ( - Dismiss + {dismissLabel} )} diff --git a/packages/server/src/server/agent/providers/pi/agent.test.ts b/packages/server/src/server/agent/providers/pi/agent.test.ts index 559ad41bc..76589743d 100644 --- a/packages/server/src/server/agent/providers/pi/agent.test.ts +++ b/packages/server/src/server/agent/providers/pi/agent.test.ts @@ -230,6 +230,125 @@ describe("PiRpcAgentSession", () => { ]); }); + test("marks optional Pi RPC input prompts as skippable", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ + type: "extension_ui_request", + id: "comment-1", + method: "input", + title: "Pick one\n\nSelected option:\n- A", + placeholder: "Optional comment (press Enter to skip)...", + }); + + const permission = await events.nextPermissionRequest(); + expect(permission.request).toMatchObject({ + title: "Optional comment", + input: { + questions: [ + { + question: "Optional comment", + header: "Response", + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + dismissLabel: "Skip", + }, + ], + }, + }); + + await session.respondToPermission("comment-1", { + behavior: "allow", + updatedInput: { answers: { Response: "" } }, + }); + + expect(fakeSession.extensionUiResponses).toEqual([ + { id: "comment-1", response: { value: "" } }, + ]); + }); + + test("combines Pi ask_user select and optional comment into one permission", async () => { + const { pi, session, events } = await createSession(); + const fakeSession = pi.latestSession(); + + fakeSession.emit({ + type: "tool_execution_start", + toolCallId: "tool-1", + toolName: "ask_user", + args: { + question: "Pick one", + options: ["A", "B"], + allowComment: true, + allowFreeform: false, + }, + }); + fakeSession.emit({ + type: "extension_ui_request", + id: "select-1", + method: "select", + title: "Pick one", + options: ["A", "B"], + }); + + const permission = await events.nextPermissionRequest(); + expect(permission.request).toMatchObject({ + id: "select-1", + name: "Pi ask_user", + kind: "question", + title: "Pick one", + input: { + questions: [ + { + question: "Pick one", + header: "Response", + options: [{ label: "A" }, { label: "B" }], + multiSelect: false, + }, + { + question: "Optional comment", + header: "Comment", + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + }, + ], + }, + metadata: { + combinedAskUser: "ask_user_select_optional_comment", + answerHeader: "Response", + commentHeader: "Comment", + }, + }); + + await session.respondToPermission("select-1", { + behavior: "allow", + updatedInput: { answers: { Response: "B", Comment: "Looks good" } }, + }); + + expect(fakeSession.extensionUiResponses).toEqual([ + { id: "select-1", response: { value: "B" } }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + + fakeSession.emit({ + type: "extension_ui_request", + id: "comment-1", + method: "input", + title: "Pick one\n\nSelected option:\n- B", + placeholder: "Optional comment (press Enter to skip)...", + }); + + expect(fakeSession.extensionUiResponses).toEqual([ + { id: "select-1", response: { value: "B" } }, + { id: "comment-1", response: { value: "Looks good" } }, + ]); + expect(session.getPendingPermissions()).toEqual([]); + }); + test("cancels Pi RPC extension UI dialogs when question permission is denied", async () => { const { pi, session, events } = await createSession(); const fakeSession = pi.latestSession(); diff --git a/packages/server/src/server/agent/providers/pi/agent.ts b/packages/server/src/server/agent/providers/pi/agent.ts index ac350f50c..eb0e1de5e 100644 --- a/packages/server/src/server/agent/providers/pi/agent.ts +++ b/packages/server/src/server/agent/providers/pi/agent.ts @@ -81,6 +81,10 @@ const PASEO_PI_CAPTURE_EXTENSION_COMMAND = "paseo_capture_entries"; const PASEO_PI_ENTRY_CAPTURE_MARKER = "PASEO_ENTRY_CAPTURE"; const PASEO_PI_COMMAND_RESULT_MARKER = "PASEO_COMMAND_RESULT"; const PASEO_PI_EXTENSION_RESULT_TIMEOUT_MS = 10_000; +const QUESTION_RESPONSE_HEADER = "Response"; +const QUESTION_COMMENT_HEADER = "Comment"; +const PI_ASK_USER_FREEFORM_SENTINEL = "✏️ Type custom response..."; +const COMBINED_ASK_USER_METADATA = "ask_user_select_optional_comment"; const PI_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, @@ -185,6 +189,22 @@ interface PendingExtensionResult { timer: NodeJS.Timeout; } +interface ActiveAskUserDialog { + allowComment: boolean; + allowFreeform: boolean; + allowMultiple: boolean; +} + +interface PendingCombinedAskUserResponse { + comment: string; + freeform: string | null; +} + +interface ExtensionUiMappingOptions { + combineOptionalComment?: boolean; + allowFreeform?: boolean; +} + function normalizePiModelLabel(label: string): string { return label.trim().replace(/[_\s]+/g, " "); } @@ -622,24 +642,77 @@ function parseCapturedEntries(value: unknown): PiCapturedEntry[] { }); } +function optionalBoolean(value: unknown): boolean | undefined { + return typeof value === "boolean" ? value : undefined; +} + +function readActiveAskUserDialog(toolName: string, args: unknown): ActiveAskUserDialog | null { + if (toolName !== "ask_user" || !isRecord(args)) { + return null; + } + return { + allowComment: optionalBoolean(args.allowComment) ?? false, + allowFreeform: optionalBoolean(args.allowFreeform) ?? true, + allowMultiple: optionalBoolean(args.allowMultiple) ?? false, + }; +} + +function isOptionalInputPlaceholder(placeholder: string | undefined): boolean { + return /\boptional\b|\bskip\b/i.test(placeholder ?? ""); +} + +function getInputQuestionTitle(title: string | undefined, placeholder: string | undefined): string { + if (!isOptionalInputPlaceholder(placeholder)) { + return title ?? "Enter a value"; + } + if (/\bcomment\b/i.test(`${title ?? ""}\n${placeholder ?? ""}`)) { + return "Optional comment"; + } + return "Optional response"; +} + +function readStringArray(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +function isPiAskUserFreeformOption(option: string): boolean { + return option === PI_ASK_USER_FREEFORM_SENTINEL; +} + function mapExtensionUiRequestToPermission( event: Extract, + options: ExtensionUiMappingOptions = {}, ): AgentPermissionRequest | null { switch (event.method) { - case "select": + case "select": { + const selectOptions = readStringArray(event.options); + if (options.combineOptionalComment) { + return buildCombinedAskUserQuestionPermission(event, { + question: optionalString(event.title) ?? "Select an option", + options: selectOptions, + allowFreeform: options.allowFreeform === true, + }); + } return buildExtensionUiQuestionPermission(event, { question: optionalString(event.title) ?? "Select an option", - options: Array.isArray(event.options) - ? event.options.filter((option): option is string => typeof option === "string") - : [], + options: selectOptions, multiSelect: false, }); - case "input": + } + case "input": { + const placeholder = optionalString(event.placeholder); + const title = optionalString(event.title); + const allowEmpty = isOptionalInputPlaceholder(placeholder); return buildExtensionUiQuestionPermission(event, { - question: optionalString(event.title) ?? "Enter a value", + question: getInputQuestionTitle(title, placeholder), options: [], multiSelect: false, + ...(placeholder ? { placeholder } : {}), + ...(allowEmpty ? { allowEmpty: true, dismissLabel: "Skip" } : {}), }); + } case "editor": return buildExtensionUiQuestionPermission(event, { question: optionalString(event.title) ?? "Edit text", @@ -661,9 +734,15 @@ function mapExtensionUiRequestToPermission( function buildExtensionUiQuestionPermission( event: Extract, - input: { question: string; options: string[]; multiSelect: boolean }, + input: { + question: string; + options: string[]; + multiSelect: boolean; + placeholder?: string; + allowEmpty?: boolean; + dismissLabel?: string; + }, ): AgentPermissionRequest { - const header = "Response"; return { id: event.id, provider: PI_PROVIDER, @@ -674,19 +753,77 @@ function buildExtensionUiQuestionPermission( questions: [ { question: input.question, - header, + header: QUESTION_RESPONSE_HEADER, options: input.options.map((label) => ({ label })), multiSelect: input.multiSelect, + ...(input.placeholder ? { placeholder: input.placeholder } : {}), + ...(input.allowEmpty ? { allowEmpty: true } : {}), + ...(input.dismissLabel ? { dismissLabel: input.dismissLabel } : {}), }, ], }, metadata: { extensionUiMethod: event.method, - answerHeader: header, + answerHeader: QUESTION_RESPONSE_HEADER, }, }; } +function buildCombinedAskUserQuestionPermission( + event: Extract, + input: { + question: string; + options: string[]; + allowFreeform: boolean; + }, +): AgentPermissionRequest { + const visibleOptions = input.options.filter((option) => !isPiAskUserFreeformOption(option)); + const allowOther = input.allowFreeform || visibleOptions.length !== input.options.length; + return { + id: event.id, + provider: PI_PROVIDER, + name: "Pi ask_user", + kind: "question", + title: input.question, + input: { + questions: [ + { + question: input.question, + header: QUESTION_RESPONSE_HEADER, + options: visibleOptions.map((label) => ({ label })), + multiSelect: false, + ...(allowOther ? { allowOther: true } : {}), + }, + { + question: "Optional comment", + header: QUESTION_COMMENT_HEADER, + options: [], + multiSelect: false, + placeholder: "Optional comment (press Enter to skip)...", + allowEmpty: true, + }, + ], + }, + metadata: { + extensionUiMethod: event.method, + answerHeader: QUESTION_RESPONSE_HEADER, + commentHeader: QUESTION_COMMENT_HEADER, + combinedAskUser: COMBINED_ASK_USER_METADATA, + selectOptions: visibleOptions, + ...(allowOther ? { freeformSentinel: PI_ASK_USER_FREEFORM_SENTINEL } : {}), + }, + }; +} + +function permissionAnswer(input: AgentMetadata | undefined, header: string): string | null { + const answers = isRecord(input?.answers) ? input.answers : null; + if (!answers) { + return null; + } + const answer = answers[header]; + return typeof answer === "string" ? answer : null; +} + function firstPermissionAnswer(input: AgentMetadata | undefined): string | null { const answers = isRecord(input?.answers) ? input.answers : null; if (!answers) { @@ -696,6 +833,39 @@ function firstPermissionAnswer(input: AgentMetadata | undefined): string | null return typeof first === "string" ? first : null; } +function isCombinedAskUserPermission(request: AgentPermissionRequest): boolean { + return request.metadata?.combinedAskUser === COMBINED_ASK_USER_METADATA; +} + +function buildCombinedAskUserSelectionResponse( + request: AgentPermissionRequest, + response: AgentPermissionResponse, +): { + uiResponse: { value?: string; cancelled?: boolean }; + pendingResponse: PendingCombinedAskUserResponse | null; +} { + if (response.behavior === "deny") { + return { uiResponse: { cancelled: true }, pendingResponse: null }; + } + + const answer = permissionAnswer(response.updatedInput, QUESTION_RESPONSE_HEADER); + if (answer === null) { + return { uiResponse: { cancelled: true }, pendingResponse: null }; + } + + const selectOptions = readStringArray(request.metadata?.selectOptions); + const freeformSentinel = optionalString(request.metadata?.freeformSentinel); + const isFreeform = Boolean(freeformSentinel) && !selectOptions.includes(answer); + const comment = permissionAnswer(response.updatedInput, QUESTION_COMMENT_HEADER) ?? ""; + return { + uiResponse: { value: isFreeform ? freeformSentinel : answer }, + pendingResponse: { + comment, + freeform: isFreeform ? answer : null, + }, + }; +} + function buildExtensionUiResponse( request: AgentPermissionRequest, response: AgentPermissionResponse, @@ -742,6 +912,8 @@ export class PiRpcAgentSession implements AgentSession { private readonly subscribers = new Set<(event: AgentStreamEvent) => void>(); private readonly activeToolCalls = new Map(); private readonly pendingExtensionUiRequests = new Map(); + private activeAskUserDialog: ActiveAskUserDialog | null = null; + private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null; private activeTurnId: string | null = null; private lastKnownThinkingOptionId: string | null; currentLeafOverrideId: string | null | undefined; @@ -875,10 +1047,16 @@ export class PiRpcAgentSession implements AgentSession { } this.pendingExtensionUiRequests.delete(requestId); - this.runtimeSession.respondToExtensionUiRequest( - requestId, - buildExtensionUiResponse(request, response), - ); + if (isCombinedAskUserPermission(request)) { + const combined = buildCombinedAskUserSelectionResponse(request, response); + this.pendingCombinedAskUserResponse = combined.pendingResponse; + this.runtimeSession.respondToExtensionUiRequest(requestId, combined.uiResponse); + } else { + this.runtimeSession.respondToExtensionUiRequest( + requestId, + buildExtensionUiResponse(request, response), + ); + } this.emit({ type: "permission_resolved", provider: PI_PROVIDER, @@ -1116,7 +1294,18 @@ export class PiRpcAgentSession implements AgentSession { } } - const request = mapExtensionUiRequestToPermission(event); + if (this.respondToCombinedAskUserFollowUp(event)) { + return; + } + + const shouldCombineOptionalComment = + event.method === "select" && + this.activeAskUserDialog?.allowComment === true && + this.activeAskUserDialog.allowMultiple === false; + const request = mapExtensionUiRequestToPermission(event, { + combineOptionalComment: shouldCombineOptionalComment, + allowFreeform: this.activeAskUserDialog?.allowFreeform, + }); if (!request) { return; } @@ -1130,6 +1319,33 @@ export class PiRpcAgentSession implements AgentSession { }); } + private respondToCombinedAskUserFollowUp( + event: Extract, + ): boolean { + const pending = this.pendingCombinedAskUserResponse; + if (!pending || event.method !== "input") { + return false; + } + + const placeholder = optionalString(event.placeholder); + if (pending.freeform !== null && !isOptionalInputPlaceholder(placeholder)) { + this.pendingCombinedAskUserResponse = { + ...pending, + freeform: null, + }; + this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.freeform }); + return true; + } + + if (isOptionalInputPlaceholder(placeholder)) { + this.pendingCombinedAskUserResponse = null; + this.runtimeSession.respondToExtensionUiRequest(event.id, { value: pending.comment }); + return true; + } + + return false; + } + private handleRuntimeEvent(event: PiRuntimeEvent): void { if (event.type === "extension_ui_request") { this.handleExtensionUiRequest(event); @@ -1186,6 +1402,7 @@ export class PiRpcAgentSession implements AgentSession { case "tool_execution_start": { const toolCall = parseToolArgs(event.toolName, event.args); this.activeToolCalls.set(event.toolCallId, toolCall); + this.activeAskUserDialog = readActiveAskUserDialog(event.toolName, event.args); this.emitToolCallEvent(event.toolCallId, toolCall, "running", null, null); return; } @@ -1204,6 +1421,11 @@ export class PiRpcAgentSession implements AgentSession { this.activeToolCalls.get(event.toolCallId) ?? parseToolArgs(event.toolName, null); this.activeToolCalls.delete(event.toolCallId); + if (event.toolName === "ask_user") { + this.activeAskUserDialog = null; + this.pendingCombinedAskUserResponse = null; + } + const result = parseToolResult(event.result); const error = event.isError ? event.result : null; const status = event.isError ? "failed" : "completed";