fix: the empty-new-session bug — three live client defects

Live-driven via the owner's dev server (browser tools), reproduced on
/chat/new: send fires, optimistic appends, POST never happens, and the
page shows the frozen empty state. Three compounding defects:

1. session-core: default fetch stored UNBOUND — the browser's native
   fetch requires this === Window, so every call threw 'Illegal
   invocation' and the POST /api/chat never fired. Node's undici has no
   receiver discipline, which is why 994/994 tests passed while the app
   was dead. Now defaulted through a receiver-preserving wrapper; pinned
   by a strict-this regression test (the browser's semantics simulated.

2. session.ts: state memo keyed on [model, forceRender] — the reducer
   dispatch is STABLE by contract, so the memo computed once per mount:
   no optimistic bubble, no stream events, no error banner. The view was
   frozen at first render. Rekeyed on a version counter bumped by the
   model's onChange.

3. view.ts: turn_end carries the clean 'Question posed' placeholder as
   a toolResult message (details: null) — applyResult flipped the quiz
   part to output-available with a null output and AnsweredCard crashed
   on answer.dismissed (killed the whole page render). Fork 2 now holds
   on the message-attachment path too: clean question results with null
   details are ignored; the AnsweredCard renders only the learner's real
   answer. Regression pin extended in session-core.test.ts.

Verified live end to end: new session → text send → probe questions in
the dock → ask answer → graded ✓ quiz card with explanation → reload
renders the full transcript from the native record with the pending
question intact.
EOF
)
This commit is contained in:
amosblomqvist
2026-09-26 02:13:18 +03:00
parent f3003d357a
commit 0803fbd88d
4 changed files with 69 additions and 7 deletions
+12 -1
View File
@@ -222,7 +222,18 @@ function toolPartOf(
}
function applyResultTo(part: LhViewPart | undefined, result: ToolResultMessage): void {
if (part) applyResult(part, result);
if (part) {
// Fork 2 (owner, Wave D): the clean "Question posed" placeholder
// (details: null — tool-execute questionResult) is client-owned
// question territory. The dock mounts on the dangling call; the
// AnsweredCard renders only the learner's REAL answer (details set).
// Flipping the part to output-available on the placeholder would hand
// AnsweredCard a null and crash the render (the live crash, 2026-09-26).
const tool = part.type.startsWith("tool-") ? part.type.slice("tool-".length) : part.type;
const isQuestion = (QUESTION_TOOLS as readonly string[]).includes(tool);
if (isQuestion && !result.isError && result.details == null) return;
applyResult(part, result);
}
// A result with no visible part (e.g. a call whose assistant message
// rendered nothing) is dropped from the view — the record keeps it.
}