import type { AgentTickClient } from "./agentTickClient"; import { DEFAULT_AGENT_TICK_PRESENTATION } from "./config"; import { formatAskPromptBody } from "./piAskUserCore"; import type { AgentTickApprovalResponse, AgentTickActivitySession, AgentTickPresentation, AskResponse, CreateApprovalRequestInput, CreateApprovalRequestResult, QuestionOption, RequestWaiterStopReason, } from "./types"; export type RemoteSteeringMapping = { request: CreateApprovalRequestInput; choiceIdToSelections: Map; }; export type RemoteSteeringHandle = { requestPromise: Promise; resultPromise: Promise; errorPromise: Promise; abandon: (reason?: RequestWaiterStopReason) => void; stopWaiter: (reason: RequestWaiterStopReason) => void; }; function buildSmallMultiSelectChoices(options: QuestionOption[]): { choices: Array<{ id: string; label: string; kind: string }>; choiceIdToSelections: Map } { const choices: Array<{ id: string; label: string; kind: string }> = []; const choiceIdToSelections = new Map(); const count = 2 ** options.length; for (let mask = 1; mask < count; mask += 1) { const indices = options.map((_, index) => index).filter((index) => (mask & (1 << index)) !== 0); const selections = indices.map((index) => options[index].title); const id = `options_${indices.map((index) => index + 1).join("_")}`; choiceIdToSelections.set(id, selections); choices.push({ id, label: selections.join(" + "), kind: "option" }); } return { choices, choiceIdToSelections }; } export function buildSteeringRequest(input: { question: string; context?: string; description?: string; options: QuestionOption[]; allowMultiple: boolean; allowFreeform: boolean; presentation?: AgentTickPresentation; session?: AgentTickActivitySession; }): RemoteSteeringMapping { const presentation = input.presentation ?? DEFAULT_AGENT_TICK_PRESENTATION; const useStructuredQuestion = input.options.length > 0 && input.allowMultiple; const useCombinationExpansion = input.allowMultiple && !useStructuredQuestion && input.options.length > 1 && input.options.length <= 3; const expanded = useCombinationExpansion ? buildSmallMultiSelectChoices(input.options) : { choiceIdToSelections: new Map(), choices: useStructuredQuestion ? [] : input.options.map((option, index) => { const id = `option_${index + 1}`; return { id, label: option.title, description: option.description, flags: option.flags, kind: "option" }; }), }; const choiceIdToSelections = expanded.choiceIdToSelections; if (!useCombinationExpansion) { input.options.forEach((option, index) => choiceIdToSelections.set(`option_${index + 1}`, [option.title])); } const choices = expanded.choices; // The current Agent Tick server fills in default choices when none are // provided. For questionnaire requests, this deny-only cancel choice // suppresses those defaults while mobile/web still render a Submit Answers // action instead of choice buttons. choices.push({ id: "cancel", label: "Cancel", kind: "deny" }); const bodyParts: string[] = []; const promptBody = formatAskPromptBody({ question: input.question, previousContext: input.context, description: input.description, }); if (promptBody) bodyParts.push(promptBody); if (input.allowMultiple && useCombinationExpansion) { bodyParts.push("Multi-select is represented as combination choices for this small option list."); } return { request: { requestType: useStructuredQuestion ? "questionnaire" : "steering", title: input.question, ...(input.session ?? {}), body: bodyParts.join("\n\n") || undefined, choices, questions: useStructuredQuestion ? [{ header: "Pi needs your input", question: input.question, options: input.options.map((option) => ({ label: option.title, description: option.description })), multiSelect: input.allowMultiple, }] : undefined, // Remote Agent Tick responses are a minimal-access gateway: the phone app // must only return selected choice IDs / structured option answers, never // arbitrary user-supplied strings that are passed back to the agent. allowFreeformReply: false, metadata: { source: "pi-agent-tick", choiceInteractionMode: presentation.choiceInteractionMode, optionPlacement: presentation.optionPlacement, confirmBeforeSubmit: presentation.confirmBeforeSubmit, }, }, choiceIdToSelections, }; } function getChoiceId(response: AgentTickApprovalResponse): string | undefined { return response.choiceId ?? response.response?.choiceId; } function getChoiceIds(response: AgentTickApprovalResponse): string[] | undefined { return response.choiceIds ?? response.response?.choiceIds; } function getAnswers(response: AgentTickApprovalResponse): Record | undefined { return response.answers ?? response.response?.answers; } function isDenied(response: AgentTickApprovalResponse): boolean { const value = getChoiceId(response) ?? getChoiceIds(response)?.[0] ?? response.status ?? response.state; return response.cancelled === true || response.denied === true || value === "cancel" || value === "deny" || value === "cancelled" || value === "denied" || value === "timeout"; } export function mapSteeringResponse( response: AgentTickApprovalResponse, choiceIdToSelections: Map, ): AskResponse | null { if (isDenied(response)) return null; const answers = getAnswers(response); const answerSelections = answers ? Object.values(answers).flat().map((answer) => answer.trim()).filter(Boolean) : []; const choiceIds = getChoiceIds(response); const choiceId = getChoiceId(response); const selectedIds = choiceIds?.length ? choiceIds : choiceId ? [choiceId] : []; const selections = answerSelections.length > 0 ? answerSelections : selectedIds.flatMap((id) => choiceIdToSelections.get(id) ?? []); if (selections.length > 0) { return { kind: "selection", selections }; } return null; } export function startRemoteSteering(input: { client: AgentTickClient; question: string; context?: string; description?: string; options: QuestionOption[]; allowMultiple: boolean; allowFreeform: boolean; timeoutMs: number; presentation?: AgentTickPresentation; session?: AgentTickActivitySession; signal?: AbortSignal; onError?: (error: unknown) => void; }): RemoteSteeringHandle { const mapping = buildSteeringRequest(input); let reportError: (error: unknown) => void = () => undefined; const errorPromise = new Promise((resolve) => { reportError = resolve; }); const notifyError = (error: unknown) => { input.onError?.(error); reportError(error); }; const requestPromise = input.client.createApprovalRequest(mapping.request, input.signal).then((request) => { if (!request.waiterToken) throw new Error("Agent Tick mirrored prompt did not receive waiter credentials"); return request; }); const stopWaiter = (reason: RequestWaiterStopReason) => { void requestPromise .then((request) => request.waiterToken ? input.client.stopRequestWaiter(request.id, request.waiterToken, reason) : undefined) .catch(() => undefined); }; const resultPromise = requestPromise .then(async (request) => { const response = await input.client.waitForApproval(request.id, request.waiterToken, input.timeoutMs, input.signal); if (response.terminal && request.waiterToken) { void input.client.stopRequestWaiter(request.id, request.waiterToken, "responded").catch(() => undefined); } return mapSteeringResponse(response, mapping.choiceIdToSelections); }) .catch((error) => { void requestPromise .then((request) => request.waiterToken ? input.client.reportRequestWaiterError(request.id, request.waiterToken, "pi_wait_failed", error instanceof Error ? error.message : String(error)) : undefined) .catch(() => undefined); notifyError(error); return undefined; }); return { requestPromise, resultPromise, errorPromise, stopWaiter, abandon: (reason: RequestWaiterStopReason = "agent_cancelled") => { void requestPromise .then((request) => Promise.allSettled([ input.client.abandonApproval(request.id), request.waiterToken ? input.client.stopRequestWaiter(request.id, request.waiterToken, reason) : Promise.resolve(), ])) .catch(() => undefined); }, }; }