Files
learnharness/src/core/pending.ts
T
amosblomqvist 2f8cf71f52 Wave 2: 409 merge-retry transport, dead pendingJob code, evidence-based timing, prompt-size guard
- 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.
2026-09-05 21:33:13 +03:00

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);
}