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:
@@ -115,6 +115,24 @@ function installFetch(routes: {
|
||||
}
|
||||
|
||||
describe("postInputEvent (the 409 identity-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||
it("the model's default fetch is RECEIVER-SAFE (the browser 'Illegal invocation' pin)", async () => {
|
||||
// The live empty-session bug (2026-09-26): storing globalThis.fetch
|
||||
// UNBOUND and calling it as this.fetch(...) throws "Illegal
|
||||
// invocation" in the browser (native fetch requires this === Window;
|
||||
// Node's undici doesn't care, so every test passed while the app was
|
||||
// dead). Pin the browser's receiver discipline with a strict stub.
|
||||
let receiverWasGlobal = false;
|
||||
globalThis.fetch = (function (this: unknown, ...args: Parameters<typeof fetch>) {
|
||||
if (this !== globalThis) throw new TypeError("Failed to execute 'fetch' on 'Window': Illegal invocation");
|
||||
receiverWasGlobal = true;
|
||||
return Promise.resolve(sseResponse("[DONE]"));
|
||||
}) as typeof fetch;
|
||||
const m = model(); // NO injected fetch — exercises the default wrapper
|
||||
const res = await m.postInputEvent({ type: "text", text: "hi" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.ok(receiverWasGlobal, "the default wrapper calls fetch with the global receiver");
|
||||
});
|
||||
|
||||
it("passes non-409 responses through untouched", async () => {
|
||||
const calls = installFetch({ chatPOST: () => new Response("stream", { status: 200 }) });
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
@@ -303,6 +321,29 @@ describe("send (one input event, verbatim consumption)", () => {
|
||||
assert.equal(parts[0]!.state, "input-available", "the clean placeholder is IGNORED — the dock mounts on it");
|
||||
assert.equal(parts[1]!.state, "output-error");
|
||||
assert.equal(parts[1]!.errorText, "correctAnswer does not match any option value");
|
||||
// turn_end carries the placeholder as a toolResult MESSAGE (details:
|
||||
// null — tool-execute questionResult): it must not flip the part to
|
||||
// output-available either — the AnsweredCard would read .dismissed off
|
||||
// null and crash the whole render (the live crash, 2026-09-26).
|
||||
push({
|
||||
type: "turn_end",
|
||||
message: { role: "assistant", id: "a1", content: [{ type: "text", text: "" }], stopReason: "toolUse" },
|
||||
toolResults: [
|
||||
{
|
||||
id: "tr_q1",
|
||||
role: "toolResult",
|
||||
toolCallId: "q1",
|
||||
toolName: "quiz",
|
||||
content: [{ type: "text", text: "Question posed — the turn ends here; the learner's graded answer arrives as this call's result." }],
|
||||
details: null,
|
||||
isError: false,
|
||||
} as never,
|
||||
],
|
||||
});
|
||||
await settle();
|
||||
const afterTurnEnd = m.getState().messages.flatMap((v) => v.parts).filter((p) => p.type === "tool-quiz");
|
||||
assert.equal(afterTurnEnd[0]!.state, "input-available", "the placeholder toolResult keeps the dock's mount state");
|
||||
assert.equal(afterTurnEnd[0]!.output, undefined, "no null output ever reaches the AnsweredCard");
|
||||
push({ type: "agent_end", messages: [] });
|
||||
push("[DONE]");
|
||||
await done;
|
||||
|
||||
Reference in New Issue
Block a user