mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Fix/pi ask user submit (#1188)
* Fix Pi ask_user optional input handling * Clarify Pi ask_user optional comment prompt * Combine Pi ask_user optional comment UI
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
79
packages/app/src/components/question-form-card-core.test.ts
Normal file
79
packages/app/src/components/question-form-card-core.test.ts
Normal file
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
143
packages/app/src/components/question-form-card-core.ts
Normal file
143
packages/app/src/components/question-form-card-core.ts
Normal file
@@ -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<number, ReadonlySet<number>>;
|
||||
export type QuestionOtherTexts = Record<number, string>;
|
||||
|
||||
function readOptionalString(record: Record<string, unknown>, 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<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: QuestionFormQuestion[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const q = item as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, string> {
|
||||
const answers: Record<string, string> = {};
|
||||
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";
|
||||
}
|
||||
@@ -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<string, unknown>).questions)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const raw = (input as Record<string, unknown>).questions as unknown[];
|
||||
const questions: Question[] = [];
|
||||
for (const item of raw) {
|
||||
if (typeof item !== "object" || item === null) return null;
|
||||
const q = item as Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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({
|
||||
<TextInput
|
||||
// @ts-expect-error - outlineStyle is web-only
|
||||
style={otherInputStyle}
|
||||
placeholder="Other..."
|
||||
placeholder={placeholder}
|
||||
placeholderTextColor={theme.colors.foregroundMuted}
|
||||
value={value}
|
||||
onChangeText={handleChange}
|
||||
@@ -193,7 +163,7 @@ function QuestionOtherInput({
|
||||
export function QuestionFormCard({ permission, onRespond, isResponding }: QuestionFormCardProps) {
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile = useIsCompactFormFactor();
|
||||
const questions = parseQuestions(permission.request.input);
|
||||
const questions = parseQuestionFormQuestions(permission.request.input);
|
||||
|
||||
const [selections, setSelections] = useState<Record<number, Set<number>>>({});
|
||||
const [otherTexts, setOtherTexts] = useState<Record<number, string>>({});
|
||||
@@ -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<string, string> = {};
|
||||
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 (
|
||||
<View style={containerStyle}>
|
||||
{questions.map((q, qIndex) => {
|
||||
const selected = selections[qIndex] ?? new Set<number>();
|
||||
const otherText = otherTexts[qIndex] ?? "";
|
||||
const showTextInput = questionShowsTextInput(q);
|
||||
|
||||
return (
|
||||
<View key={q.question} style={styles.questionBlock}>
|
||||
@@ -361,27 +329,32 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
<Text style={questionTextStyle}>{q.question}</Text>
|
||||
<CircleHelp size={14} color={theme.colors.foregroundMuted} />
|
||||
</View>
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
{q.options.length > 0 ? (
|
||||
<View style={styles.optionsWrap}>
|
||||
{q.options.map((opt, optIndex) => (
|
||||
<QuestionOptionRow
|
||||
key={opt.label}
|
||||
qIndex={qIndex}
|
||||
optIndex={optIndex}
|
||||
option={opt}
|
||||
isSelected={selected.has(optIndex)}
|
||||
multiSelect={q.multiSelect}
|
||||
isResponding={isResponding}
|
||||
onToggle={toggleOption}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : null}
|
||||
{showTextInput ? (
|
||||
<QuestionOtherInput
|
||||
qIndex={qIndex}
|
||||
value={otherText}
|
||||
placeholder={getQuestionInputPlaceholder(q)}
|
||||
isResponding={isResponding}
|
||||
onChange={setOtherText}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
@@ -393,7 +366,7 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
|
||||
) : (
|
||||
<View style={styles.actionContent}>
|
||||
<X size={14} color={theme.colors.foregroundMuted} />
|
||||
<Text style={dismissActionTextStyle}>Dismiss</Text>
|
||||
<Text style={dismissActionTextStyle}>{dismissLabel}</Text>
|
||||
</View>
|
||||
)}
|
||||
</Pressable>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
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<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
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<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
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<string, PiTrackedToolCall>();
|
||||
private readonly pendingExtensionUiRequests = new Map<string, AgentPermissionRequest>();
|
||||
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<PiRuntimeEvent, { type: "extension_ui_request" }>,
|
||||
): 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";
|
||||
|
||||
Reference in New Issue
Block a user