import { isToolUIPart, type UIMessage } from "ai"; /** * The pending-question gate: an unanswered QUESTION tool part (quiz/ask) * blocks the composer — one question at a time, mirroring the teacher's * prompt contract. Derived client state; the server enforces nothing. * * Scan order: MESSAGES newest-first (a later turn's live question supersedes * an older dangling one), but PARTS forward within a message — the model's * FIRST unanswered question is the dock subject. Parallel question calls * (pi executes them in one batch) are thus answered in the same order they * were asked, saved, and rendered as cards; a backward part scan reversed * that order (the live modal-realism observation). * * Job tool parts (research/demo/visual) never pend client-side — the async * protocol resolves them server-side with an immediate ack (see * contracts/jobs.ts "THE ASYNC PROTOCOL"), so job tools are NOT returned * here. This file carries only the question gate and the shared scan helper. */ export interface PendingToolCall { toolCallId: string; tool: string; /** Tool input (quiz/ask args) — lets the composer pick note vs answer mode. */ input: unknown; } /** Tools that render in the question dock and gate the composer. */ export const QUESTION_TOOLS = ["quiz", "ask_user_question"] as const; /** Tools fulfilled by background sub-agent jobs (W2/W6). */ export const JOB_TOOLS = ["research", "demo", "visual"] as const; function findPending(messages: UIMessage[], names: readonly string[]): PendingToolCall | null { for (let m = messages.length - 1; m >= 0; m--) { const msg = messages[m]!; for (let p = 0; p < msg.parts.length; p++) { const part = msg.parts[p]!; if (isToolUIPart(part) && part.state === "input-available") { const tool = part.type.slice("tool-".length); if (names.includes(tool)) { return { toolCallId: part.toolCallId, tool, input: part.input }; } } } } return null; } export function findPendingToolCall(messages: UIMessage[]): PendingToolCall | null { return findPending(messages, QUESTION_TOOLS); }