- components/chat-transport.ts (NEW): createChatTransport(chatId) — the chat.tsx seam is now a one-line swap. Its fetch turns a 409 on POST /api/chat (a learner send racing the wake pipeline's ~1s adoption window) into: wait out the in-flight turn (poll turnInFlight, 30s cap), refetch the record, MERGE the outgoing messages onto the fresh server list, re-POST. A bare retry would clobber the steer + wake messages via the write-ahead persist (steerDelivered makes that loss permanent) — merging is the whole point. Unit-tested: pass-through, wait+merge+reorder, dedupe by id, repeated-409 loop, bounded give-up. - src/core/pending.ts: findPendingJobCall deleted (dead since wave 1 — job tools resolve server-side with an ack, nothing pends client-side). JOB_TOOLS kept (runner.ts still uses it). Header doc rewritten. - src/server/jobs.ts: GRACE_MS 1500 → 750, with the evidence in the comment: the race window is only the learner's answer→POST flight (sendAutomaticallyWhen dispatches synchronously; ~10ms localhost, <300ms realistic RTT — 750 is 2.5× worst case) and misses self-heal via the pump's gate recheck, the 409 backoff ramp, and the new transport merge-retry. POLL_MS=400 kept (sampling granularity only). - tests/jobs.test.ts: prompt-size guard — every fragment ≤8k chars, composed prompt ≤60k — the README-leak class (815 bytes ≈ 200 tokens per teacher turn before wave 1's fix) can't silently recur. Gates: typecheck, 279/279 tests, lint, build — all green.
47 lines
1.6 KiB
TypeScript
47 lines
1.6 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.
|
|
*
|
|
* 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 = msg.parts.length - 1; p >= 0; 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);
|
|
}
|
|
|
|
|