Live observation (modal-realism run): the teacher batched two quizzes in one turn; the dock showed the SECOND question first, so the answering order was reversed vs the saved record and the transcript cards. Cause: findPending scanned parts BACKWARD within a message, so with two input-available quizzes it picked the last one first. No overwriting was involved — parts are created in call order on both wire and record; the scan direction alone reversed the interaction order. (pi executes a parallel batch with starts + result messages in call order; the execution-end order is completion-racy, which also allowed a millisecond dock-subject flip mid-turn.) Fix A — src/core/pending.ts: parts now scan FORWARD within a message (messages stay newest-first): the FIRST unanswered question is the dock subject; the second appears only after the first is answered. Fix B — src/agent/lh-tool.ts: question tools opt into sequential execution (pi runs the batch in call order when any called tool opts in), so tool-input-available chunks always arrive in call order and the dock subject can never flip mid-turn. Question executes are instant. Pinned in chat-flow.test.ts: a parallel two-quiz batch asserts wire order (A's available chunk before B's), part persistence order, and the dock selection sequence (A first, then B after A is answered). Gates: tsc, lint, build, 307/307.
54 lines
2.0 KiB
TypeScript
54 lines
2.0 KiB
TypeScript
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);
|
|
}
|
|
|
|
|