mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-11 05:42:19 +00:00
An aborted stream now leaves a trace on both ends. The unmount cleanup records the interrupted conversation (sessionStorage, one-shot) before the abort fires, and invalidates the cached thread + rail so the return visit refetches the advanced server state. The /coach page consumes the record on its next mount: a known id reopens that thread, a first-turn abort resolves via most-recent; every explicit entry intent (?c=, ?doc=, ?workout=, ?scope=, ?ask=) wins over the resume. The persisted cancelled marker renders as a muted interrupted note with an Ask-again action that resubmits the question through the normal send path and its gates. Localised in all six languages. Refs #781
119 lines
4.9 KiB
TypeScript
119 lines
4.9 KiB
TypeScript
/**
|
|
* Structural guard for two Coach streaming-lifecycle invariants.
|
|
*
|
|
* These are STRUCTURAL assertions over the source text, not behavioural ones.
|
|
* The repo ships no `@testing-library/react` / `renderHook`, so an unmount
|
|
* cleanup and a tray-switch callback cannot be driven behaviourally here. A
|
|
* structural guard is weaker than a behavioural test — it proves the code is
|
|
* present, not that it works — but both defects were *deletions of a call*,
|
|
* which is exactly what a structural guard does catch. Matches the existing
|
|
* `*-guard.test.ts` precedent in this directory.
|
|
*
|
|
* Invariant 1 — `useSendCoachMessage` aborts its in-flight reader on unmount.
|
|
* Without it, the full-page `/coach` surface (which passes no `registerReset`,
|
|
* so the reset effect early-returns) left the SSE reader loop alive on a
|
|
* route change: the provider kept generating and billing, and trailing
|
|
* setState fired on an unmounted component. The drawer worked around this via
|
|
* `handleOpenChange`; route-change unmount had no exit at all.
|
|
*
|
|
* Invariant 2 — switching conversations resets the send state. Without it the
|
|
* previous thread's last assistant/error bubble rendered appended to the newly
|
|
* selected thread until the next send, because `streamingActive` stays true
|
|
* while that messageId is absent from the new thread's messages.
|
|
*/
|
|
import { readFileSync } from "node:fs";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
|
|
const ROOT = join(__dirname, "..");
|
|
|
|
function read(relative: string): string {
|
|
return readFileSync(join(ROOT, relative), "utf8");
|
|
}
|
|
|
|
describe("Coach streaming lifecycle", () => {
|
|
it("useSendCoachMessage registers an unmount cleanup that aborts the reader", () => {
|
|
const source = read("components/insights/coach-panel/use-coach.ts");
|
|
|
|
// An effect with an empty dependency array whose cleanup aborts.
|
|
const hasUnmountAbort =
|
|
/useEffect\([\s\S]*?return\s*\(\)\s*=>\s*\{[^}]*abortRef\.current\?\.abort\(\)/.test(
|
|
source,
|
|
);
|
|
expect(
|
|
hasUnmountAbort,
|
|
"use-coach.ts must abort abortRef in an unmount cleanup effect",
|
|
).toBe(true);
|
|
|
|
// And it must be mount-scoped — a dependency-carrying effect would abort
|
|
// mid-stream on an unrelated re-render.
|
|
const effectWithDeps =
|
|
/return\s*\(\)\s*=>\s*\{\s*abortRef\.current\?\.abort\(\);\s*\};\s*\},\s*\[\]\s*\)/.test(
|
|
source,
|
|
);
|
|
expect(
|
|
effectWithDeps,
|
|
"the unmount abort effect must have an empty dependency array",
|
|
).toBe(true);
|
|
});
|
|
|
|
it("the conversation-switch handler resets the send state", () => {
|
|
const source = read(
|
|
"components/insights/coach-panel/coach-conversation.tsx",
|
|
);
|
|
|
|
// Isolate the HistoryRail onSelect callback body.
|
|
const match = source.match(
|
|
/<HistoryRail[\s\S]*?onSelect=\{\([\s\S]*?\n\s*\}\}/,
|
|
);
|
|
expect(match, "HistoryRail onSelect handler not found").not.toBeNull();
|
|
const onSelectBody = match![0];
|
|
|
|
expect(
|
|
onSelectBody.includes("send.reset()"),
|
|
"selecting another conversation must call send.reset() so the previous " +
|
|
"thread's trailing stream state does not bleed into the new thread",
|
|
).toBe(true);
|
|
});
|
|
|
|
it("the unmount cleanup records the interrupted conversation before the abort (#781)", () => {
|
|
const source = read("components/insights/coach-panel/use-coach.ts");
|
|
|
|
// A cleanup that writes the resume value under the storage key. Without
|
|
// it the unmount abort is silent again: the user returns to a blank new
|
|
// chat with no route back to the interrupted thread.
|
|
const recordsInterruption =
|
|
/return\s*\(\)\s*=>\s*\{[\s\S]*?interruptedResumeValue\(\s*active\s*\)[\s\S]*?sessionStorage\.setItem\(\s*COACH_INTERRUPTED_STORAGE_KEY/.test(
|
|
source,
|
|
);
|
|
expect(
|
|
recordsInterruption,
|
|
"use-coach.ts must record the interrupted conversation " +
|
|
"(COACH_INTERRUPTED_STORAGE_KEY) in an unmount cleanup",
|
|
).toBe(true);
|
|
|
|
// And the recording effect must be defined BEFORE the abort effect so
|
|
// the flag is written while the send is still marked in flight.
|
|
const recordIndex = source.indexOf("COACH_INTERRUPTED_STORAGE_KEY,\n");
|
|
const abortIndex = source.indexOf("abortRef.current?.abort();\n };");
|
|
expect(recordIndex).toBeGreaterThan(-1);
|
|
expect(abortIndex).toBeGreaterThan(-1);
|
|
expect(
|
|
recordIndex < abortIndex,
|
|
"the interruption-recording cleanup must be defined before the " +
|
|
"mount-scoped abort cleanup",
|
|
).toBe(true);
|
|
});
|
|
|
|
it("handleNewChat still resets too — the two paths must not diverge again", () => {
|
|
const source = read(
|
|
"components/insights/coach-panel/coach-conversation.tsx",
|
|
);
|
|
expect(source).toContain("send.reset()");
|
|
// Both call sites present: new-chat and conversation-switch.
|
|
const resetCalls = source.match(/send\.reset\(\)/g) ?? [];
|
|
expect(resetCalls.length).toBeGreaterThanOrEqual(2);
|
|
});
|
|
});
|