Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d3aee8c2df fix(server): hide Pi custom messages marked display: false
pi-goal-x sends its `<pi_goal_continuation …/>` checkpoint trigger as a custom
message with `display: false`: the model reads it, and Pi's TUI never renders
it. Paseo ignored the flag and emitted every custom message as assistant text,
which the timeline projection then glued onto the previous answer. The live
and history paths now read custom text through one function that respects the
flag. Extension adapters still see hidden messages, so structured mappings are
unchanged; pi-subagents' hidden `subagent-notify` text is dropped the same way.

Also removes the unused `mapCustomMessage` history hook left over from the
Oh My Pi removal.
2026-09-26 19:56:48 +02:00
Mohamed Boudra 6639847425 fix: answer questions with structured selections
The question form sent each answer as comma-joined text, so Codex, OpenCode,
and the rpiv-ask-user-question adapter split labels that contain ", " into
separate answers. OpenCode v2 forms rejected multi-select answers outright
and published `multiple` instead of `multiSelect`.

The allow response now carries optional `questionAnswers`, one
`{ selected, text? }` per question. AgentManager resolves both forms before
the provider sees the response: text-only callers (CLI, MCP, older apps) are
parsed by option label, and the header-keyed text answers Claude and plugin
providers read are derived when absent. The app still sends the text answers
for older daemons (COMPAT(questionAnswers)).
2026-09-26 19:56:48 +02:00
24 changed files with 550 additions and 160 deletions
@@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest";
import { import {
areQuestionsAnswered, areQuestionsAnswered,
buildQuestionFormAnswers, buildQuestionFormAnswers,
buildQuestionFormResponse,
parseQuestionFormQuestions, parseQuestionFormQuestions,
questionShowsTextInput, questionShowsTextInput,
resolveDismissLabel, resolveDismissLabel,
@@ -147,4 +148,44 @@ describe("question form card core", () => {
Response: "custom", Response: "custom",
}); });
}); });
test("submits each question's answer as structured labels so commas stay unambiguous", () => {
const input = {
questions: [
{
question: "Which colors?",
header: "Colors",
options: [{ label: "Red, bright" }, { label: "Blue" }],
multiSelect: true,
allowOther: true,
},
{
question: "Which city?",
header: "City",
options: [{ label: "Paris, France" }, { label: "Rome" }],
allowOther: true,
},
],
};
const questions = parseQuestionFormQuestions(input);
if (!questions) throw new Error("questions did not parse");
expect(
buildQuestionFormResponse(
input,
questions,
{ 0: new Set([0, 1]), 1: new Set([1]) },
{ 0: " Green, dark ", 1: "Lyon, France" },
),
).toEqual({
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: [], text: "Lyon, France" },
],
updatedInput: {
...input,
answers: { Colors: "Red, bright, Blue, Green, dark", City: "Lyon, France" },
},
});
});
}); });
@@ -1,3 +1,5 @@
import type { AgentPermissionResponse, AgentQuestionAnswer } from "@getpaseo/protocol/agent-types";
export interface QuestionOption { export interface QuestionOption {
label: string; label: string;
description?: string; description?: string;
@@ -101,35 +103,60 @@ export function areQuestionsAnswered(
); );
} }
export function buildQuestionFormAnswers( export function buildQuestionFormResponse(
input: Record<string, unknown> | undefined,
questions: QuestionFormQuestion[], questions: QuestionFormQuestion[],
selections: QuestionSelections, selections: QuestionSelections,
otherTexts: QuestionOtherTexts, otherTexts: QuestionOtherTexts,
): Record<string, string> { ): AgentPermissionResponse {
const answers: Record<string, string> = {}; return {
for (let i = 0; i < questions.length; i++) { behavior: "allow",
const q = questions[i]; questionAnswers: buildQuestionAnswers(questions, selections, otherTexts),
updatedInput: {
...input,
// COMPAT(questionAnswers): added in v0.9.3, remove after 2027-03-26 once daemon floor >= v0.9.3.
// Older daemons read only these text answers; newer daemons derive them from questionAnswers.
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
};
}
function buildQuestionAnswers(
questions: QuestionFormQuestion[],
selections: QuestionSelections,
otherTexts: QuestionOtherTexts,
): AgentQuestionAnswer[] {
return questions.map((q, i) => {
const selected = selections[i]; const selected = selections[i];
const otherText = otherTexts[i]?.trim(); const otherText = otherTexts[i]?.trim();
const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : []; const labels = selected ? Array.from(selected).map((idx) => q.options[idx].label) : [];
if (questionShowsTextInput(q)) { if (questionShowsTextInput(q)) {
if (otherText && otherText.length > 0) { if (otherText && otherText.length > 0) {
// Multi-select keeps the checked options and appends the custom answer, the way // Multi-select keeps the checked options alongside the custom answer, the way
// Claude Code's own AskUserQuestion UI does. Single-select replaces the option. // Claude Code's own AskUserQuestion UI does. Single-select replaces the option.
answers[q.header] = q.multiSelect ? [...labels, otherText].join(", ") : otherText; return { selected: q.multiSelect ? labels : [], text: otherText };
continue;
} }
if (q.allowEmpty && q.options.length === 0) { if (q.allowEmpty && q.options.length === 0) {
answers[q.header] = ""; return { selected: [], text: "" };
continue;
} }
} }
return { selected: labels };
});
}
if (labels.length > 0) { export function buildQuestionFormAnswers(
answers[q.header] = labels.join(", "); questions: QuestionFormQuestion[],
} selections: QuestionSelections,
} otherTexts: QuestionOtherTexts,
): Record<string, string> {
const answers: Record<string, string> = {};
buildQuestionAnswers(questions, selections, otherTexts).forEach((answer, i) => {
if (answer.selected.length === 0 && answer.text === undefined) return;
answers[questions[i].header] = [...answer.selected, ...(answer.text ? [answer.text] : [])].join(
", ",
);
});
return answers; return answers;
} }
@@ -12,7 +12,7 @@ import { EditingTextInput as TextInput } from "@/components/ui/text-input";
import type { EditingTextInputHandle } from "@/components/ui/text-input/types"; import type { EditingTextInputHandle } from "@/components/ui/text-input/types";
import { import {
areQuestionsAnswered, areQuestionsAnswered,
buildQuestionFormAnswers, buildQuestionFormResponse,
isQuestionAnswered, isQuestionAnswered,
parseQuestionFormQuestions, parseQuestionFormQuestions,
questionShowsTextInput, questionShowsTextInput,
@@ -402,13 +402,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
const handleSubmit = useCallback(() => { const handleSubmit = useCallback(() => {
if (!questions || !allAnswered || isResponding) return; if (!questions || !allAnswered || isResponding) return;
setRespondingAction("submit"); setRespondingAction("submit");
onRespond({ onRespond(
behavior: "allow", buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
updatedInput: { );
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
}, [ }, [
questions, questions,
allAnswered, allAnswered,
@@ -423,13 +419,9 @@ export function QuestionFormCard({ permission, onRespond, isResponding }: Questi
if (!questions) return; if (!questions) return;
setRespondingAction("dismiss"); setRespondingAction("dismiss");
if (shouldSubmitEmptyOnDismiss(questions)) { if (shouldSubmitEmptyOnDismiss(questions)) {
onRespond({ onRespond(
behavior: "allow", buildQuestionFormResponse(permission.request.input, questions, selections, otherTexts),
updatedInput: { );
...permission.request.input,
answers: buildQuestionFormAnswers(questions, selections, otherTexts),
},
});
return; return;
} }
onRespond({ onRespond({
+10
View File
@@ -466,12 +466,22 @@ export interface AgentPermissionRequest {
metadata?: AgentMetadata; metadata?: AgentMetadata;
} }
/**
* The answer to one question of a `question` permission request, aligned by index with
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
*/
export interface AgentQuestionAnswer {
selected: string[];
text?: string;
}
export type AgentPermissionResponse = export type AgentPermissionResponse =
| { | {
behavior: "allow"; behavior: "allow";
selectedActionId?: string; selectedActionId?: string;
updatedInput?: AgentMetadata; updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[]; updatedPermissions?: AgentPermissionUpdate[];
questionAnswers?: AgentQuestionAnswer[];
} }
| { | {
behavior: "deny"; behavior: "deny";
+6
View File
@@ -456,6 +456,11 @@ const AgentPermissionActionSchema = z.object({
intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(), intent: z.enum(["implement", "implement_resume", "dismiss"]).optional(),
}); });
const AgentQuestionAnswerSchema = z.object({
selected: z.array(z.string()),
text: z.string().optional(),
});
export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> = export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
z.discriminatedUnion("behavior", [ z.discriminatedUnion("behavior", [
z.object({ z.object({
@@ -463,6 +468,7 @@ export const AgentPermissionResponseSchema: z.ZodType<AgentPermissionResponse> =
selectedActionId: z.string().optional(), selectedActionId: z.string().optional(),
updatedInput: z.record(z.string(), z.unknown()).optional(), updatedInput: z.record(z.string(), z.unknown()).optional(),
updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(), updatedPermissions: z.array(AgentPermissionUpdateSchema).optional(),
questionAnswers: z.array(AgentQuestionAnswerSchema).optional(),
}), }),
z.object({ z.object({
behavior: z.literal("deny"), behavior: z.literal("deny"),
@@ -54,6 +54,7 @@ import {
type ImportableProviderSession, type ImportableProviderSession,
type ListImportableSessionsOptions, type ListImportableSessionsOptions,
} from "./agent-sdk-types.js"; } from "./agent-sdk-types.js";
import { resolveQuestionAnswers } from "./question-answers.js";
import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js"; import { buildArchivedAgentRecord, type ArchivedStoredAgentRecord } from "./agent-archive.js";
import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js"; import type { StoredAgentRecord, AgentStorage } from "./agent-storage.js";
import type { AgentOwner } from "./agent-owner.js"; import type { AgentOwner } from "./agent-owner.js";
@@ -2981,7 +2982,11 @@ export class AgentManager {
agent.inFlightPermissionResponses.add(requestId); agent.inFlightPermissionResponses.add(requestId);
try { try {
const result = await agent.session.respondToPermission(requestId, response); const request = agent.pendingPermissions.get(requestId);
const result = await agent.session.respondToPermission(
requestId,
request ? resolveQuestionAnswers(request, response) : response,
);
agent.pendingPermissions.delete(requestId); agent.pendingPermissions.delete(requestId);
try { try {
@@ -504,12 +504,22 @@ export interface AgentPermissionRequest {
metadata?: AgentMetadata; metadata?: AgentMetadata;
} }
/**
* The answer to one question of a `question` permission request, aligned by index with
* `input.questions`. `selected` holds the chosen option labels; `text` holds a typed answer.
*/
export interface AgentQuestionAnswer {
selected: string[];
text?: string;
}
export type AgentPermissionResponse = export type AgentPermissionResponse =
| { | {
behavior: "allow"; behavior: "allow";
selectedActionId?: string; selectedActionId?: string;
updatedInput?: AgentMetadata; updatedInput?: AgentMetadata;
updatedPermissions?: AgentPermissionUpdate[]; updatedPermissions?: AgentPermissionUpdate[];
questionAnswers?: AgentQuestionAnswer[];
} }
| { | {
behavior: "deny"; behavior: "deny";
@@ -5042,6 +5042,7 @@ describe("Codex app-server provider", () => {
await session.respondToPermission("permission-call-question-2", { await session.respondToPermission("permission-call-question-2", {
behavior: "allow", behavior: "allow",
questionAnswers: [{ selected: ["Tea"] }],
updatedInput: { updatedInput: {
answers: { answers: {
Drink: "Tea", Drink: "Tea",
@@ -5061,6 +5062,7 @@ describe("Codex app-server provider", () => {
requestId: "permission-call-question-2", requestId: "permission-call-question-2",
resolution: { resolution: {
behavior: "allow", behavior: "allow",
questionAnswers: [{ selected: ["Tea"] }],
updatedInput: { updatedInput: {
answers: { answers: {
Drink: "Tea", Drink: "Tea",
@@ -5100,6 +5102,33 @@ describe("Codex app-server provider", () => {
}); });
}); });
test("answers multi-select questions with option labels that contain commas", async () => {
const session = createSession();
const pendingResponse = asInternals(session).handleToolApprovalRequest({
itemId: "call-question-3",
threadId: "thread-1",
turnId: "turn-1",
questions: [
{
id: "colors",
header: "Colors",
question: "Which colors?",
multiSelect: true,
options: [{ label: "Red, bright" }, { label: "Blue" }],
},
],
});
await session.respondToPermission("permission-call-question-3", {
behavior: "allow",
questionAnswers: [{ selected: ["Red, bright", "Blue"], text: "Green, dark" }],
});
await expect(pendingResponse).resolves.toEqual({
answers: { colors: { answers: ["Red, bright", "Blue", "Green, dark"] } },
});
});
test("emits a synthetic plan approval permission after a successful Codex plan turn", () => { test("emits a synthetic plan approval permission after a successful Codex plan turn", () => {
const session = createSession({ const session = createSession({
featureValues: { plan_mode: true, fast_mode: true }, featureValues: { plan_mode: true, fast_mode: true },
@@ -38,6 +38,7 @@ import {
type ProviderRefreshContext, type ProviderRefreshContext,
type ResolveAgentDefaultModeInput, type ResolveAgentDefaultModeInput,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { questionAnswerValues } from "../question-answers.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { runProviderRefreshActivity } from "../provider-refresh-deadline.js"; import { runProviderRefreshActivity } from "../provider-refresh-deadline.js";
import type { Logger } from "pino"; import type { Logger } from "pino";
@@ -1264,39 +1265,21 @@ export function mapCodexQuestionRequestToToolCall(params: {
}; };
} }
function mapCodexQuestionResponseByHeader(params: { function mapCodexQuestionResponse(params: {
questions: CodexQuestionPrompt[]; questions: CodexQuestionPrompt[];
response: AgentPermissionResponse; response: AgentPermissionResponse;
}): Record<string, { answers: string[] }> | null { }): Record<string, { answers: string[] }> | null {
if (params.response.behavior !== "allow") { if (params.response.behavior !== "allow" || !params.response.questionAnswers) {
return null; return null;
} }
const updatedInputRecord = toObjectRecord(params.response.updatedInput); const questionAnswers = params.response.questionAnswers;
const answersRecord = toObjectRecord(updatedInputRecord?.answers);
if (!answersRecord) {
return null;
}
const answers: Record<string, { answers: string[] }> = {}; const answers: Record<string, { answers: string[] }> = {};
for (const question of params.questions) { params.questions.forEach((question, index) => {
const rawAnswer = answersRecord[question.header]; const values = questionAnswerValues(questionAnswers[index]);
if (typeof rawAnswer !== "string") {
continue;
}
const normalizedAnswer = rawAnswer.trim();
if (!normalizedAnswer) {
continue;
}
const values = question.multiSelect
? normalizedAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
: [normalizedAnswer];
if (values.length > 0) { if (values.length > 0) {
answers[question.id] = { answers: values }; answers[question.id] = { answers: values };
} }
} });
return Object.keys(answers).length > 0 ? answers : null; return Object.keys(answers).length > 0 ? answers : null;
} }
@@ -4604,7 +4587,7 @@ export class CodexAppServerAgentSession implements AgentSession {
? pendingRequest.metadata.itemId ? pendingRequest.metadata.itemId
: requestId; : requestId;
if (response.behavior === "allow") { if (response.behavior === "allow") {
const mappedAnswers = mapCodexQuestionResponseByHeader({ const mappedAnswers = mapCodexQuestionResponse({
questions, questions,
response, response,
}); });
@@ -332,7 +332,7 @@ describe("OpenCode auto_accept feature", () => {
await session.respondToPermission("question-1", { await session.respondToPermission("question-1", {
behavior: "allow", behavior: "allow",
updatedInput: { answers: { Decision: "Proceed" } }, questionAnswers: [{ selected: ["Proceed"] }],
}); });
expect(openCodeClient.calls.questionReply).toHaveLength(1); expect(openCodeClient.calls.questionReply).toHaveLength(1);
@@ -386,7 +386,7 @@ describe("OpenCode auto_accept feature", () => {
await session.respondToPermission("question-1", { await session.respondToPermission("question-1", {
behavior: "allow", behavior: "allow",
updatedInput: { answers: { Decision: "Use another answer" } }, questionAnswers: [{ selected: [], text: "Use another answer" }],
}); });
expect(openCodeClient.calls.questionReply).toEqual([ expect(openCodeClient.calls.questionReply).toEqual([
@@ -5296,7 +5296,7 @@ describe("OpenCode provider subagent contract", () => {
await parent.respondToPermission("question_provider_child", { await parent.respondToPermission("question_provider_child", {
behavior: "allow", behavior: "allow",
updatedInput: { answers: { Path: "A" } }, questionAnswers: [{ selected: ["A"] }],
}); });
expect(parentClient.calls.questionReply).toContainEqual( expect(parentClient.calls.questionReply).toContainEqual(
expect.objectContaining({ expect.objectContaining({
@@ -5308,6 +5308,50 @@ describe("OpenCode provider subagent contract", () => {
await parent.close(); await parent.close();
}); });
test("replies to questions with option labels and custom answers that contain commas", async () => {
const { parent, openCode } = await createParentSession("ses_parent_comma_question");
openCode.emitEvent({
type: "question.asked",
properties: {
id: "question_comma",
sessionID: "ses_parent_comma_question",
questions: [
{
question: "Which colors?",
header: "Colors",
multiple: true,
options: [
{ label: "Red, bright", description: "Warm" },
{ label: "Blue", description: "Cool" },
],
},
{
question: "Which city?",
header: "City",
options: [{ label: "Paris, France", description: "Europe" }],
},
],
},
});
await vi.waitFor(() => expect(parent.getPendingPermissions()).toHaveLength(1));
await parent.respondToPermission("question_comma", {
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: ["Paris, France"] },
],
});
expect(openCode.calls.questionReply).toContainEqual(
expect.objectContaining({
requestID: "question_comma",
answers: [["Red, bright", "Blue", "Green, dark"], ["Paris, France"]],
}),
);
await parent.close();
});
test("emits the same child status when descriptor semantics change", async () => { test("emits the same child status when descriptor semantics change", async () => {
const { parent, openCode } = await createParentSession("ses_parent_child_changed_status"); const { parent, openCode } = await createParentSession("ses_parent_child_changed_status");
const events: AgentStreamEvent[] = []; const events: AgentStreamEvent[] = [];
@@ -57,6 +57,7 @@ import {
type ToolCallDetail, type ToolCallDetail,
type ToolCallTimelineItem, type ToolCallTimelineItem,
} from "../agent-sdk-types.js"; } from "../agent-sdk-types.js";
import { questionAnswerValues } from "../question-answers.js";
import { importSessionFromPersistence } from "../provider-session-import.js"; import { importSessionFromPersistence } from "../provider-session-import.js";
import { import {
raceProviderRefreshAbort, raceProviderRefreshAbort,
@@ -4957,19 +4958,10 @@ class OpenCodeAgentSession implements AgentSession {
directory, directory,
}); });
} else { } else {
const answersRecord = readOpenCodeRecord(response.updatedInput?.answers);
const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : []; const questions = Array.isArray(pending.input?.questions) ? pending.input.questions : [];
const answers = questions.map((item) => { const answers = questions.map((_, index) =>
const header = readNonEmptyString(readOpenCodeRecord(item)?.header); questionAnswerValues(response.questionAnswers?.[index]),
const rawAnswer = header ? readNonEmptyString(answersRecord?.[header]) : null; );
if (!rawAnswer) {
return [];
}
return rawAnswer
.split(",")
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0);
});
await this.client.question.reply({ await this.client.question.reply({
requestID: requestId, requestID: requestId,
@@ -35,17 +35,23 @@ describe("OpenCode v2 questions", () => {
}); });
const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" }); const session = await client.createSession({ provider: "opencode", cwd: "/tmp/project" });
try { try {
expect(session.getPendingPermissions()).toHaveLength(1); expect(session.getPendingPermissions()).toEqual([
expect.objectContaining({
input: {
questions: expect.arrayContaining([
expect.objectContaining({ header: "features", multiSelect: true }),
]),
},
}),
]);
await session.respondToPermission("question", { await session.respondToPermission("question", {
behavior: "allow", behavior: "allow",
updatedInput: { questionAnswers: [
answers: { { selected: ["Blue"] },
color: "Blue", { selected: ["Search"], text: "Web, images" },
features: ["Search"], { selected: [], text: "3" },
count: "3", { selected: [], text: "false" },
enabled: "false", ],
},
},
}); });
expect(answers).toEqual([ expect(answers).toEqual([
{ {
@@ -53,7 +59,7 @@ describe("OpenCode v2 questions", () => {
formID: "question", formID: "question",
answer: { answer: {
color: "blue-id", color: "blue-id",
features: ["search-id"], features: ["search-id", "Web, images"],
count: 3, count: 3,
enabled: false, enabled: false,
}, },
@@ -4,9 +4,11 @@ import type { V2Api } from "./api.js";
import type { import type {
AgentPermissionRequest, AgentPermissionRequest,
AgentPermissionResponse, AgentPermissionResponse,
AgentQuestionAnswer,
AgentSessionConfig, AgentSessionConfig,
AgentStreamEvent, AgentStreamEvent,
} from "../../../agent-sdk-types.js"; } from "../../../agent-sdk-types.js";
import { questionAnswerValues } from "../../../question-answers.js";
export class SessionPermissions { export class SessionPermissions {
private readonly pending = new Map<string, AgentPermissionRequest>(); private readonly pending = new Map<string, AgentPermissionRequest>();
@@ -39,16 +41,13 @@ export class SessionPermissions {
if (response.behavior === "deny") if (response.behavior === "deny")
await this.client.session.form.cancel({ sessionID: form.sessionID, formID: form.id }); await this.client.session.form.cancel({ sessionID: form.sessionID, formID: form.id });
else { else {
const raw = response.updatedInput?.answers; const answers = response.questionAnswers;
const answer: Record<string, FormValue> = {}; const answer: Record<string, FormValue> = {};
if (!raw || typeof raw !== "object" || Array.isArray(raw)) if (!answers) throw new Error("OpenCode question response requires answers");
throw new Error("OpenCode question response requires answers"); form.fields.forEach((field, index) => {
for (const field of form.fields) { const normalized = formAnswer(field, answers[index]);
const value: unknown =
Reflect.get(raw, field.key) ?? Reflect.get(raw, field.title ?? field.key);
const normalized = formAnswer(field, value);
if (normalized !== undefined) answer[field.key] = normalized; if (normalized !== undefined) answer[field.key] = normalized;
} });
await this.client.session.form.reply({ await this.client.session.form.reply({
sessionID: form.sessionID, sessionID: form.sessionID,
formID: form.id, formID: form.id,
@@ -103,7 +102,7 @@ export class SessionPermissions {
header: field.key, header: field.key,
question: field.title ?? field.key, question: field.title ?? field.key,
options: "options" in field ? field.options : undefined, options: "options" in field ? field.options : undefined,
multiple: field.type === "multiselect", multiSelect: field.type === "multiselect",
})), })),
}, },
}; };
@@ -124,9 +123,13 @@ function permissionReply(response: AgentPermissionResponse): "reject" | "once" |
return response.selectedActionId === "always" ? "always" : "once"; return response.selectedActionId === "always" ? "always" : "once";
} }
function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValue | undefined { function formAnswer(
if (value === undefined) return undefined; field: FormInfo["fields"][number],
if (field.type === "external") return undefined; answer: AgentQuestionAnswer | undefined,
): FormValue | undefined {
const values = questionAnswerValues(answer);
const [value] = values;
if (value === undefined || field.type === "external") return undefined;
const labelValue = (label: string) => { const labelValue = (label: string) => {
const options = "options" in field ? field.options : undefined; const options = "options" in field ? field.options : undefined;
return ( return (
@@ -136,21 +139,15 @@ function formAnswer(field: FormInfo["fields"][number], value: unknown): FormValu
); );
}; };
if (field.type === "multiselect") { if (field.type === "multiselect") {
if (Array.isArray(value) && value.every((item: unknown) => typeof item === "string")) return values.map(labelValue);
return value.map(labelValue); } else if (field.type === "string") {
} else if (field.type === "string" && typeof value === "string") {
return labelValue(value); return labelValue(value);
} else if (field.type === "boolean") { } else if (field.type === "boolean") {
if (typeof value === "boolean") return value;
if (value === "true") return true; if (value === "true") return true;
if (value === "false") return false; if (value === "false") return false;
} else if (field.type === "number" || field.type === "integer") { } else if (field.type === "number" || field.type === "integer") {
const numeric = typeof value === "string" && value.trim() ? Number(value) : value; const numeric = value.trim() ? Number(value) : Number.NaN;
if ( if (Number.isFinite(numeric) && (field.type !== "integer" || Number.isInteger(numeric)))
typeof numeric === "number" &&
Number.isFinite(numeric) &&
(field.type !== "integer" || Number.isInteger(numeric))
)
return numeric; return numeric;
} }
throw new Error(`Invalid answer for OpenCode question ${field.key}`); throw new Error(`Invalid answer for OpenCode question ${field.key}`);
@@ -24,6 +24,7 @@ import {
transformPiModels, transformPiModels,
} from "./agent.js"; } from "./agent.js";
import { FakePi } from "./test-utils/fake-pi.js"; import { FakePi } from "./test-utils/fake-pi.js";
import { HIDDEN_GOAL_CONTINUATION } from "./test-utils/hidden-custom-message.js";
import { createPiExtensionHost } from "./extensions/index.js"; import { createPiExtensionHost } from "./extensions/index.js";
import { PiExtensionHost } from "./extensions/host.js"; import { PiExtensionHost } from "./extensions/host.js";
import type { PiModel, PiThinkingLevel } from "./rpc-types.js"; import type { PiModel, PiThinkingLevel } from "./rpc-types.js";
@@ -1080,6 +1081,17 @@ describe("PiRpcAgentSession", () => {
]); ]);
}); });
test("hides Pi custom messages that the extension marks display: false", async () => {
const { pi, events } = await createSession();
const fakeSession = pi.latestSession();
fakeSession.emit({ type: "agent_start" });
fakeSession.emit({ type: "turn_start" });
fakeSession.emit({ type: "message_end", message: HIDDEN_GOAL_CONTINUATION });
expect(events.timelineItems()).toEqual([]);
});
test("canceling a silent Pi extension command leaves the session usable", async () => { test("canceling a silent Pi extension command leaves the session usable", async () => {
const { pi, session, events } = await createSession(); const { pi, session, events } = await createSession();
const fakeSession = pi.latestSession(); const fakeSession = pi.latestSession();
@@ -59,7 +59,7 @@ import {
toDiagnosticErrorMessage, toDiagnosticErrorMessage,
} from "../diagnostic-utils.js"; } from "../diagnostic-utils.js";
import { import {
getUserMessageText, getCustomMessageText,
streamPiHistory, streamPiHistory,
type PiCapturedUserMessageEntry, type PiCapturedUserMessageEntry,
} from "./history-mapper.js"; } from "./history-mapper.js";
@@ -2300,7 +2300,7 @@ export class PiRpcAgentSession implements AgentSession {
if (event.message.role === "custom") { if (event.message.role === "custom") {
const customMapping = this.extensionHost.mapCustomMessage(event.message); const customMapping = this.extensionHost.mapCustomMessage(event.message);
this.emitExtensionOutput(customMapping, turnId); this.emitExtensionOutput(customMapping, turnId);
const text = getUserMessageText(event.message.content); const text = getCustomMessageText(event.message);
if (text) { if (text) {
this.emit({ this.emit({
type: "timeline", type: "timeline",
@@ -15,12 +15,18 @@ const [start, ...rest] = fixture.frames;
const dialogs = rest.filter((frame: { type: string }) => frame.type === "extension_ui_request"); const dialogs = rest.filter((frame: { type: string }) => frame.type === "extension_ui_request");
const end = rest.find((frame: { type: string }) => frame.type === "tool_execution_end"); const end = rest.find((frame: { type: string }) => frame.type === "tool_execution_end");
function startedHost() { const answers = [
{ selected: ["Cookie"] },
{ selected: ["Red", "Blue"] },
{ selected: [], text: "Nebula" },
];
function startedHost(args: unknown = start.args) {
const host = createPiExtensionHost(); const host = createPiExtensionHost();
const permission = host.onToolStart({ const permission = host.onToolStart({
callId: start.toolCallId, callId: start.toolCallId,
toolName: start.toolName, toolName: start.toolName,
args: start.args, args,
status: "running", status: "running",
result: null, result: null,
}); });
@@ -47,12 +53,7 @@ test("real rpiv RPC dialogs use one form and preserve choices, multi-select, and
}); });
expect(host.mapDialog(dialogs[0], "pi")).toEqual({ type: "deferred" }); expect(host.mapDialog(dialogs[0], "pi")).toEqual({ type: "deferred" });
expect( expect(
host.respondToPermission(permission, { host.respondToPermission(permission, { behavior: "allow", questionAnswers: answers }),
behavior: "allow",
updatedInput: {
answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" },
},
}),
).toEqual({ responses: [{ id: dialogs[0].id, response: { value: dialogs[0].options[1] } }] }); ).toEqual({ responses: [{ id: dialogs[0].id, response: { value: dialogs[0].options[1] } }] });
expect(host.mapDialog(dialogs[1], "pi")).toEqual({ expect(host.mapDialog(dialogs[1], "pi")).toEqual({
type: "response", type: "response",
@@ -68,6 +69,21 @@ test("real rpiv RPC dialogs use one form and preserve choices, multi-select, and
}); });
}); });
test("multi-select labels containing a comma map back to their option numbers", () => {
const args = structuredClone(start.args);
args.questions[1].options[0].label = "Red, warm";
const { host, permission } = startedHost(args);
host.respondToPermission(permission, {
behavior: "allow",
questionAnswers: [answers[0], { selected: ["Red, warm", "Blue"] }, answers[2]],
});
host.mapDialog(dialogs[0], "pi");
expect(host.mapDialog(dialogs[1], "pi")).toEqual({
type: "response",
response: { value: "1,2" },
});
});
test("cancellation declines each remaining real RPC dialog", () => { test("cancellation declines each remaining real RPC dialog", () => {
const { host, permission } = startedHost(); const { host, permission } = startedHost();
expect(host.respondToPermission(permission, { behavior: "deny" })).toEqual({ responses: [] }); expect(host.respondToPermission(permission, { behavior: "deny" })).toEqual({ responses: [] });
@@ -153,12 +169,7 @@ test("the Pi agent releases a deferred captured dialog when the form is answered
expect(permission.input?.questions).toHaveLength(3); expect(permission.input?.questions).toHaveLength(3);
runtime.emit(dialogs[0]); runtime.emit(dialogs[0]);
expect(runtime.extensionUiResponses).toEqual([]); expect(runtime.extensionUiResponses).toEqual([]);
await session.respondToPermission(permission.id, { await session.respondToPermission(permission.id, { behavior: "allow", questionAnswers: answers });
behavior: "allow",
updatedInput: {
answers: { Snack: "Cookie", Colors: "Red, Blue", Project: "Nebula" },
},
});
for (const dialog of dialogs.slice(1)) runtime.emit(dialog); for (const dialog of dialogs.slice(1)) runtime.emit(dialog);
expect(runtime.extensionUiResponses).toEqual([ expect(runtime.extensionUiResponses).toEqual([
{ id: dialogs[0].id, response: { value: dialogs[0].options[1] } }, { id: dialogs[0].id, response: { value: dialogs[0].options[1] } },
@@ -1,5 +1,6 @@
import { z } from "zod"; import { z } from "zod";
import type { AgentPermissionRequest } from "../../../../agent-sdk-types.js"; import type { AgentPermissionRequest, AgentQuestionAnswer } from "../../../../agent-sdk-types.js";
import { questionAnswerValues } from "../../../../question-answers.js";
import type { PiExtension, PiExtensionDialog, PiExtensionUiResponse } from "../contract.js"; import type { PiExtension, PiExtensionDialog, PiExtensionUiResponse } from "../contract.js";
const Params = z const Params = z
@@ -55,38 +56,34 @@ const Result = z.object({
type Question = z.infer<typeof Params>["questions"][number]; type Question = z.infer<typeof Params>["questions"][number];
const PERMISSION_PREFIX = "rpiv-question:"; const PERMISSION_PREFIX = "rpiv-question:";
function answerFor(answers: Record<string, unknown>, question: Question): string | null {
const answer = answers[question.header];
return typeof answer === "string" ? answer : null;
}
function replyToDialog( function replyToDialog(
dialog: PiExtensionDialog, dialog: PiExtensionDialog,
question: Question, question: Question,
answer: string, answer: AgentQuestionAnswer,
customInput: boolean, customInput: boolean,
): { response: PiExtensionUiResponse; customInput: boolean } { ): { response: PiExtensionUiResponse; customInput: boolean } {
const options = z.array(z.string()).safeParse(dialog.options).data; const options = z.array(z.string()).safeParse(dialog.options).data;
if (customInput) return { response: { value: answer }, customInput: false }; const text = questionAnswerValues(answer).join(", ");
if (customInput) return { response: { value: text }, customInput: false };
if (question.multiSelect) { if (question.multiSelect) {
const labels = answer.split(", "); const indices = answer.selected.map((label) =>
const indices = labels.map((label) =>
question.options.findIndex((option) => option.label === label), question.options.findIndex((option) => option.label === label),
); );
if (indices.every((index) => index >= 0)) { if (!answer.text && indices.every((index) => index >= 0)) {
return { return {
response: { value: indices.map((index) => index + 1).join(",") }, response: { value: indices.map((index) => index + 1).join(",") },
customInput: false, customInput: false,
}; };
} }
return { response: { value: answer }, customInput: false }; return { response: { value: text }, customInput: false };
} }
const index = question.options.findIndex((option) => option.label === answer); const [label] = answer.selected;
if (index >= 0) { const index = question.options.findIndex((option) => option.label === label);
if (!answer.text && index >= 0) {
return { return {
response: { response: {
value: value:
options?.[index] ?? `${index + 1}. ${answer} — ${question.options[index].description}`, options?.[index] ?? `${index + 1}. ${label} — ${question.options[index].description}`,
}, },
customInput: false, customInput: false,
}; };
@@ -106,7 +103,7 @@ export const rpivAskUserQuestion: PiExtension = {
let active: { let active: {
callId: string; callId: string;
questions: Question[]; questions: Question[];
answers: Record<string, unknown> | null; answers: AgentQuestionAnswer[] | null;
cancelled: boolean; cancelled: boolean;
index: number; index: number;
customInput: boolean; customInput: boolean;
@@ -117,8 +114,10 @@ export const rpivAskUserQuestion: PiExtension = {
if (!active || active.cancelled) return { cancelled: true }; if (!active || active.cancelled) return { cancelled: true };
const question = active.questions[active.index]; const question = active.questions[active.index];
if (!question || !active.answers) return { cancelled: true }; if (!question || !active.answers) return { cancelled: true };
const answer = answerFor(active.answers, question); const answer = active.answers[active.index];
if (answer === null) return { cancelled: true }; if (!answer || (answer.selected.length === 0 && answer.text === undefined)) {
return { cancelled: true };
}
const mapped = replyToDialog(dialog, question, answer, active.customInput); const mapped = replyToDialog(dialog, question, answer, active.customInput);
active.customInput = mapped.customInput; active.customInput = mapped.customInput;
if (!active.customInput) active.index++; if (!active.customInput) active.index++;
@@ -169,11 +168,7 @@ export const rpivAskUserQuestion: PiExtension = {
}, },
respondToPermission(request, response) { respondToPermission(request, response) {
if (!active || request.id !== `${PERMISSION_PREFIX}${active.callId}`) return undefined; if (!active || request.id !== `${PERMISSION_PREFIX}${active.callId}`) return undefined;
const answers = response.behavior === "allow" ? response.updatedInput?.answers : undefined; active.answers = (response.behavior === "allow" && response.questionAnswers) || null;
active.answers =
answers && typeof answers === "object" && !Array.isArray(answers)
? (answers as Record<string, unknown>)
: null;
active.cancelled = response.behavior === "deny" || !active.answers; active.cancelled = response.behavior === "deny" || !active.answers;
const deferred = active.deferred; const deferred = active.deferred;
active.deferred = null; active.deferred = null;
@@ -65,16 +65,18 @@ export async function verifySubagentFixture(
}); });
for (const message of fixture.messages) { for (const message of fixture.messages) {
if (message.role !== "custom" || typeof message.content !== "string") continue; if (message.role !== "custom" || typeof message.content !== "string") continue;
const assistantText = { const assistantText = expect.objectContaining({
type: "assistant_message" as const, type: "timeline",
text: message.content, item: { type: "assistant_message", text: message.content },
}; });
expect(live).toContainEqual( // Claimed notifications keep their text unless the extension hides it from the user.
expect.objectContaining({ type: "timeline", item: assistantText }), if (message.display === false) {
); expect(live).not.toContainEqual(assistantText);
expect(replay).toContainEqual( expect(replay).not.toContainEqual(assistantText);
expect.objectContaining({ type: "timeline", item: assistantText }), } else {
); expect(live).toContainEqual(assistantText);
expect(replay).toContainEqual(assistantText);
}
} }
const liveSubagents = live.filter((event) => event.type === "provider_subagent"); const liveSubagents = live.filter((event) => event.type === "provider_subagent");
const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline"); const firstTimeline = liveSubagents.findIndex((event) => event.event.type === "timeline");
@@ -3,6 +3,7 @@ import { describe, expect, test } from "vitest";
import type { AgentStreamEvent } from "../../agent-sdk-types.js"; import type { AgentStreamEvent } from "../../agent-sdk-types.js";
import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js"; import { streamPiHistory, type PiCapturedUserMessageEntry } from "./history-mapper.js";
import type { PiAgentMessage } from "./rpc-types.js"; import type { PiAgentMessage } from "./rpc-types.js";
import { HIDDEN_GOAL_CONTINUATION } from "./test-utils/hidden-custom-message.js";
async function collectHistory( async function collectHistory(
messages: PiAgentMessage[], messages: PiAgentMessage[],
@@ -140,6 +141,10 @@ describe("Pi history mapper", () => {
]); ]);
}); });
test("drops custom messages that the extension marks display: false", async () => {
await expect(collectHistory([HIDDEN_GOAL_CONTINUATION])).resolves.toEqual([]);
});
test("uses Pi tree entry ids for replayed user messages", async () => { test("uses Pi tree entry ids for replayed user messages", async () => {
await expect( await expect(
collectHistory( collectHistory(
@@ -20,10 +20,6 @@ export interface PiCapturedUserMessageEntry {
} }
export interface PiHistoryMapperHooks { export interface PiHistoryMapperHooks {
mapCustomMessage?: (
text: string,
provider: string,
) => Extract<AgentStreamEvent, { type: "timeline" }> | null;
resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string; resolveToolCallId?: (toolCallId: string, toolCall: PiTrackedToolCall) => string;
mapToolDetail?: ( mapToolDetail?: (
toolCall: PiTrackedToolCall, toolCall: PiTrackedToolCall,
@@ -56,6 +52,11 @@ export function getUserMessageText(content: string | (PiTextContent | PiImageCon
return textParts.join("\n\n"); return textParts.join("\n\n");
} }
/** The text Pi shows for a custom message. Extensions hide model-only messages with `display: false`. */
export function getCustomMessageText(message: Extract<PiAgentMessage, { role: "custom" }>): string {
return message.display === false ? "" : getUserMessageText(message.content);
}
export class PiHistoryMapper { export class PiHistoryMapper {
private readonly pendingToolCalls = new Map<string, PiTrackedToolCall>(); private readonly pendingToolCalls = new Map<string, PiTrackedToolCall>();
private readonly hydrations: Promise<AgentStreamEvent[]>[] = []; private readonly hydrations: Promise<AgentStreamEvent[]>[] = [];
@@ -125,11 +126,7 @@ export class PiHistoryMapper {
): AgentStreamEvent[] { ): AgentStreamEvent[] {
const extensionMapping = this.extensionHost.mapCustomMessage(message); const extensionMapping = this.extensionHost.mapCustomMessage(message);
const extensionEvents = this.extensionEvents(extensionMapping); const extensionEvents = this.extensionEvents(extensionMapping);
const text = getUserMessageText(message.content); const text = getCustomMessageText(message);
const mappedEvent = text ? this.hooks.mapCustomMessage?.(text, this.provider) : null;
if (mappedEvent) {
return [...extensionEvents, mappedEvent];
}
return [ return [
...extensionEvents, ...extensionEvents,
...(text ...(text
@@ -0,0 +1,23 @@
import type { PiAgentMessage } from "../rpc-types.js";
// A pi-goal-x 0.31.9 (2e8ad767) checkpoint continuation, captured verbatim from a real
// `pi --mode rpc` run (Pi 0.84.4) on 2026-09-26. pi-goal-x sends it with `display: false`:
// the marker is for the model, and Pi's TUI never renders it.
export const HIDDEN_GOAL_CONTINUATION: PiAgentMessage = {
role: "custom",
customType: "pi-goal-event",
content: '<pi_goal_continuation goal_id="muioeg51-6db1a7" kind="checkpoint" v="2"/>',
display: false,
details: {
version: 3,
generation: "13a8082c-e249-4df5-ac0e-fdc863ad289e",
dispatchId: "a9c0ef02-d39c-42a2-8d5a-993a96b4e827",
kind: "checkpoint",
goalId: "muioeg51-6db1a7",
status: "active",
revision: 3,
checkpointSeq: 1,
timestamp: 1790444456255,
},
timestamp: 1790444456256,
};
@@ -0,0 +1,89 @@
import { describe, expect, test } from "vitest";
import type { AgentPermissionRequest } from "./agent-sdk-types.js";
import { resolveQuestionAnswers } from "./question-answers.js";
const request: AgentPermissionRequest = {
id: "question-1",
provider: "codex",
name: "request_user_input",
kind: "question",
input: {
questions: [
{
question: "Which colors?",
header: "Colors",
options: [{ label: "Red, bright" }, { label: "Blue" }],
multiSelect: true,
allowOther: true,
},
{
question: "Which snack?",
header: "Snack",
options: [{ label: "Cookie" }, { label: "Apple" }],
multiSelect: false,
allowOther: true,
},
{ question: "Anything else?", header: "Notes", options: [], allowEmpty: true },
],
},
};
describe("resolveQuestionAnswers", () => {
test("keeps structured answers and derives the header-keyed text answers", () => {
const resolved = resolveQuestionAnswers(request, {
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: ["Cookie"] },
{ selected: [], text: "" },
],
});
expect(resolved).toEqual({
behavior: "allow",
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green, dark" },
{ selected: ["Cookie"] },
{ selected: [], text: "" },
],
updatedInput: {
answers: { Colors: "Red, bright, Blue, Green, dark", Snack: "Cookie", Notes: "" },
},
});
});
test("reads text answers from clients without structured answers by matching option labels", () => {
const resolved = resolveQuestionAnswers(request, {
behavior: "allow",
updatedInput: {
answers: { Colors: "Red, bright, Blue, Green", Snack: "Pretzel", Notes: "" },
},
});
expect(resolved).toMatchObject({
questionAnswers: [
{ selected: ["Red, bright", "Blue"], text: "Green" },
{ selected: [], text: "Pretzel" },
{ selected: [], text: "" },
],
updatedInput: {
answers: { Colors: "Red, bright, Blue, Green", Snack: "Pretzel", Notes: "" },
},
});
});
test("rejects structured answers that do not line up with the questions", () => {
expect(() =>
resolveQuestionAnswers(request, {
behavior: "allow",
questionAnswers: [{ selected: ["Blue"] }],
}),
).toThrow("3 questions");
});
test("leaves denials and non-question requests unchanged", () => {
expect(resolveQuestionAnswers(request, { behavior: "deny" })).toEqual({ behavior: "deny" });
const tool = { ...request, kind: "tool" as const };
expect(resolveQuestionAnswers(tool, { behavior: "allow" })).toEqual({ behavior: "allow" });
});
});
@@ -0,0 +1,114 @@
import { z } from "zod";
import type {
AgentPermissionRequest,
AgentPermissionResponse,
AgentQuestionAnswer,
} from "./agent-sdk-types.js";
// Every provider publishes `question` requests in the shape the app's question form reads.
const QuestionsSchema = z.array(
z
.object({
header: z.string(),
options: z.array(z.object({ label: z.string() }).passthrough()).optional(),
multiSelect: z.boolean().optional(),
})
.passthrough(),
);
type Question = z.infer<typeof QuestionsSchema>[number];
const TextAnswersSchema = z.record(z.string(), z.unknown());
// Text answers join a multi-select's labels and typed answer with this separator.
const TEXT_SEPARATOR = ", ";
/**
* Gives an answered `question` request both answer forms before it reaches the provider:
* `questionAnswers`, one structured answer per question, and `updatedInput.answers`, the
* header-keyed text that Claude's AskUserQuestion and plugin providers read. Clients that
* send only text (the CLI, MCP, and apps before structured answers) are parsed by option label.
*/
export function resolveQuestionAnswers(
request: AgentPermissionRequest,
response: AgentPermissionResponse,
): AgentPermissionResponse {
if (request.kind !== "question" || response.behavior !== "allow") return response;
const parsed = QuestionsSchema.safeParse(request.input?.questions);
if (!parsed.success) return response;
const questions = parsed.data;
if (response.questionAnswers && response.questionAnswers.length !== questions.length) {
throw new Error(
`Question answers must answer all ${questions.length} questions; received ${response.questionAnswers.length}`,
);
}
const textAnswers = TextAnswersSchema.safeParse(response.updatedInput?.answers).data;
const questionAnswers =
response.questionAnswers ??
(textAnswers ? questions.map((question) => parseTextAnswer(question, textAnswers)) : null);
if (!questionAnswers) return response;
return {
...response,
questionAnswers,
updatedInput: {
...response.updatedInput,
answers: textAnswers ?? formatTextAnswers(questions, questionAnswers),
},
};
}
/** The chosen option labels followed by the typed answer, if there is one. */
export function questionAnswerValues(answer: AgentQuestionAnswer | undefined): string[] {
if (!answer) return [];
const text = answer.text?.trim();
return text ? [...answer.selected, text] : answer.selected;
}
function parseTextAnswer(
question: Question,
textAnswers: Record<string, unknown>,
): AgentQuestionAnswer {
const raw = textAnswers[question.header];
if (Array.isArray(raw)) return { selected: raw.filter((item) => typeof item === "string") };
if (typeof raw === "number" || typeof raw === "boolean") {
return { selected: [], text: String(raw) };
}
if (typeof raw !== "string") return { selected: [] };
const text = raw.trim();
const labels = new Set(question.options?.map((option) => option.label));
if (!question.multiSelect) {
return labels.has(text) ? { selected: [text] } : { selected: [], text };
}
// Labels can contain the separator, so match the longest run of segments that is a label.
const segments = text.split(TEXT_SEPARATOR);
const selected: string[] = [];
const typed: string[] = [];
let start = 0;
while (start < segments.length) {
let end = segments.length;
while (end > start && !labels.has(segments.slice(start, end).join(TEXT_SEPARATOR))) end--;
if (end > start) {
selected.push(segments.slice(start, end).join(TEXT_SEPARATOR));
start = end;
} else {
typed.push(segments[start]);
start++;
}
}
return typed.length > 0 ? { selected, text: typed.join(TEXT_SEPARATOR) } : { selected };
}
function formatTextAnswers(
questions: Question[],
questionAnswers: AgentQuestionAnswer[],
): Record<string, string> {
const answers: Record<string, string> = {};
questions.forEach((question, index) => {
const answer = questionAnswers[index];
if (answer.selected.length === 0 && answer.text === undefined) return;
answers[question.header] = questionAnswerValues(answer).join(TEXT_SEPARATOR);
});
return answers;
}