diff --git a/components/chat.tsx b/components/chat.tsx index 11bcd0a..52f93bd 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -26,7 +26,7 @@ import { Composer } from "@/components/composer"; import { useEnterSubmits } from "@/components/enter-submits"; import { blurAfterPointerClick } from "@/components/focus-hygiene"; import { dockStateKey } from "@/components/dock-state-key"; -import { clearOpeningPrompt, readOpeningPrompt } from "@/components/opening-prompt"; +import { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; import { forwardDockWheel, useDockHeight } from "@/components/dock-height"; import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers"; import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation"; @@ -238,7 +238,7 @@ export function Chat({ // (core/view.ts projectView); sends are ONE explicit input event each // ({chatId, input} — the stash indirection died with the SDK), and the // turn stream is the VERBATIM AgentEvent passthrough the server emits. - const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = + const { messages, nativeMessages, hasConfirmedConversation, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } = useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }); const sessionFindScopeRef = useRef(null); @@ -248,28 +248,6 @@ export function Chat({ // The reattach settled terminal → the sync-and-continue seam (below). setReattachSettled(() => reattachSettledRef.current()); - // New chat with a topic: send the first message exactly once - // (initialPromptOwed — the pure gate, pinned in the test suite). - // The topic arrives as the server's ?prompt= (old links) or from the - // sessionStorage stash the composer wrote before navigating here. - // The stash stays until send resolves, so a reload before the POST - // lands can retry. sentInitial blocks a second send on this mount. - const sentInitial = useRef(false); - useEffect(() => { - const prompt = initialPrompt || readOpeningPrompt(chatId); - if (!sentInitial.current && prompt && initialPromptOwed(initialMessages, prompt)) { - sentInitial.current = true; - void send({ type: "text", text: prompt }).then( - (landed) => { - if (landed) clearOpeningPrompt(chatId); - }, - () => { - /* leave the stash: the next mount retries */ - }, - ); - } - }, [chatId, initialPrompt, initialMessages, send]); - // A turn running server-side, attached to no live view of ours (navigate // away mid-turn, or a wake turn discovered mid-session): the reattach // stream is the primary channel (resumeStream — the same rendering path @@ -579,6 +557,66 @@ export function Chat({ * Auto-continuations (probe answers) never set it — their refusal * opens recovery with no replay. */ const draftRef = useRef(null); + const openingDraftRef = useRef(null); + const [openingError, setOpeningError] = useState(false); + const openingMountedRef = useRef(false); + useEffect(() => { + openingMountedRef.current = true; + return () => { openingMountedRef.current = false; }; + }, []); + + const deliverOpeningPrompt = useCallback((prompt: string) => { + openingDraftRef.current = prompt; + // Persist uncertainty BEFORE the POST: another mount must restore, + // not race the still-arriving request. No storage means no auto-send. + if (!stashOpeningPrompt(chatId, prompt, true)) { + applyText(prompt); + setOpeningError(true); + return; + } + setOpeningError(false); + draftRef.current = prompt; // The existing 503 flow must restore it too. + applyText(""); + void send({ type: "text", text: prompt }, () => { + clearOpeningPrompt(chatId); + openingDraftRef.current = null; + if (draftRef.current === prompt) draftRef.current = null; + }).then(accepted => { + if (!accepted && openingMountedRef.current && openingDraftRef.current === prompt && !textRef.current) { + applyText(prompt); + } + }); + }, [chatId, send, applyText]); + + const sentInitial = useRef(false); + useEffect(() => { + if (sentInitial.current) return; + sentInitial.current = true; + const entry = readOpeningPromptEntry(chatId); + const prompt = initialPrompt || entry?.text; // Legacy links still work. + if (!initialPromptOwed(initialMessages, prompt)) { + if (initialMessages.some(m => m.role === "user" || m.role === "assistant")) clearOpeningPrompt(chatId); + return; + } + openingDraftRef.current = prompt!; + if (entry?.attempted || initiallyInFlight) { + applyText(prompt!); // Interrupted start: the learner explicitly resends. + return; + } + deliverOpeningPrompt(prompt!); + }, [chatId, initialPrompt, initialMessages, initiallyInFlight, deliverOpeningPrompt, applyText]); + + useEffect(() => { + if (!hasConfirmedConversation) return; + clearOpeningPrompt(chatId); + // A later canonical read may confirm a lost acknowledgment. Clear + // only the unchanged recovered draft, never the learner's newer work. + const opening = openingDraftRef.current; + if (opening !== null && textRef.current === opening) applyText(""); + if (draftRef.current === opening) draftRef.current = null; + openingDraftRef.current = null; + setOpeningError(false); + }, [chatId, hasConfirmedConversation, applyText]); // THE BYOK REFUSAL (owner, 2026-09-22): a send that lands the server's // 503 models_not_configured envelope renders as the SETUP FLOW, never @@ -1155,6 +1193,10 @@ export function Chat({ openModelPicker(); return; // No optimistic send and no change to the learner's draft. } + if (openingDraftRef.current !== null) { + deliverOpeningPrompt(t); + return; + } draftRef.current = t; // the refusal flow's preserve-and-replay seam applyText(""); void send({ type: "text", text: t }); @@ -1586,6 +1628,7 @@ export function Chat({ )} + {openingError &&

{OPENING_PROMPT_FAILURE}

} {error && (
Something went wrong: {error.message} diff --git a/components/home.tsx b/components/home.tsx index c999473..0388291 100644 --- a/components/home.tsx +++ b/components/home.tsx @@ -12,7 +12,8 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio import { useFieldCycle } from "@/components/field-cycle"; import { SidebarToggle } from "@/components/sidebar"; import { StatsBar } from "@/components/stats-bar"; -import { stashOpeningPrompt } from "@/components/opening-prompt"; +import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; +import { SessionStarter } from "@/components/start-session"; import type { TurnUsage } from "@/src/core"; /** @@ -57,6 +58,9 @@ export function Home({ }, }); const [busy, setBusy] = useState(false); + const startingRef = useRef(false); + const starterRef = useRef(new SessionStarter()); + const [startError, setStartError] = useState(false); /* Run end focuses the topic input (owner, 2026-10-01): the hands are on * the keyboard from the stop. Falling edge only; this surface's only * dictation target IS this textarea, so no case-split is needed. */ @@ -106,7 +110,7 @@ export function Home({ }; const begin = async () => { const t = topicRef.current.trim(); - if (!t || busy) return; + if (!t || startingRef.current) return; // Unconfigured: the send INTERCEPTS into the setup flow (the draft // stays in the textarea; the learner sends it after Save). if (!configured) { @@ -129,32 +133,25 @@ export function Home({ openModelPicker(); return; // Preserve the draft and never create an orphan session. } + startingRef.current = true; setBusy(true); + setStartError(false); try { // The map context (owner, 2026-10-04): picks in EITHER mode ride // the CREATION as graphContext — the server writes a // graph-context marker as message[0] (a separate custom message // before the learner's first user message, propositions + the // chains among them); the prompt stays the learner's own words. - const res = await fetch("/api/chats", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - title: t, - ...(mode === "practice" ? { kind: "practice" } : {}), - ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), - }), - }); - const { id } = (await res.json()) as { id: string }; - // No settings PUT on create (per-session models, owner - // 2026-09-28): the SERVER stamps the newborn from the strong - // slot — the same default this surface's model control edits. - // The opening text stays out of the URL (opening-prompt.ts): - // a long paste as ?prompt= is a 431 and a blank window. - stashOpeningPrompt(id, t); - router.push(`/chat/${id}`); + const target = await starterRef.current.start({ + title: t, + ...(mode === "practice" ? { kind: "practice" } : {}), + ...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}), + }, fetch, JSON.stringify(catalog?.slots.strong)); + router.push(target); } catch { + startingRef.current = false; setBusy(false); + setStartError(true); } }; @@ -243,6 +240,7 @@ export function Home({ ) )} /> + {startError &&

{OPENING_PROMPT_FAILURE}

}
{ diff --git a/components/new-session.tsx b/components/new-session.tsx index 9d60ed1..f8e4c63 100644 --- a/components/new-session.tsx +++ b/components/new-session.tsx @@ -12,7 +12,8 @@ import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill import { StatsBar } from "@/components/stats-bar"; import { ChainTableOverlay } from "@/components/chain-table.tsx"; import { ComposerActions, NodePickerControl } from "@/components/composer-actions"; -import { stashOpeningPrompt } from "@/components/opening-prompt"; +import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt"; +import { SessionStarter } from "@/components/start-session"; import type { ChatSettings, GraphSnapshot } from "@/src/core"; /** The draft's pick set (owner round 10): the SAME shape the home pick @@ -34,20 +35,18 @@ export type DraftPick = { id: string; name: string; statement: string }; const DOCK = "mx-auto w-full max-w-[46rem] px-4"; /** - * The draft's whole behavior as one pure seam: create the session (title = - * the first message), persist the lifted pre-chat settings, stash the - * opening text beside the navigation, and return the push target — - * /chat/. The chat page reads the stash and delivers it as turn 1. - * The text never rides the URL: a long paste exceeds Node's header limit - * and the window commits an empty document. Injected fetch so tests pin - * the contract. Returns null on empty text; rethrows on transport failure - * (the caller's busy flag resets). + * Create, safely hand over the full opening text, then return a short + * /chat/ target. Storage/creation failures leave the caller's draft + * intact. A per-composer starter reuses an already-created session on an + * unchanged retry. The server stamps settings at birth; no settings PUT. */ export async function startDraftSession( text: string, fetchLike: typeof fetch, mode?: "practice", picks?: readonly DraftPick[], + starter = new SessionStarter(), + settingsIdentity?: string, ): Promise { const t = text.trim(); if (!t) return null; @@ -55,20 +54,13 @@ export async function startDraftSession( // from the pick bar creates its session WITH the graph context — the // same contract as the home screen's practice send (the marker with // [id] name: statement lines, target validation's truth set). - const res = await fetchLike("/api/chats", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify( - mode === "practice" - ? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) } - : { title: t }, - ), - }); - const { id } = (await res.json()) as { id: string }; - // No settings PUT on create (per-session models, owner 2026-09-28): - // the SERVER stamps the newborn from the strong slot. - stashOpeningPrompt(id, t); - return `/chat/${id}`; + return starter.start( + mode === "practice" + ? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) } + : { title: t }, + fetchLike, + settingsIdentity, + ); } /** The pure shell (no router) — the render-test seam: the draft view's @@ -81,6 +73,7 @@ export function DraftShell({ text, onChange, onSend, + startError = false, setupCta, mode = "teach", picks = [], @@ -92,6 +85,7 @@ export function DraftShell({ text: string; onChange: (v: string) => void; onSend: () => void; + startError?: boolean; /** The practice draft (owner, 2026-10-04): ?mode=practice — the * created session carries kind:"practice" and the copy changes * (the practice agent opens; no probing). */ @@ -194,6 +188,7 @@ export function DraftShell({ )} setup={setupCta} /> + {startError &&

{OPENING_PROMPT_FAILURE}

}
{ const draft = textRef.current.trim(); - if (!draft || busy) return; + if (!draft || startingRef.current) return; if (!configured) { setSetupOpen(true); return; @@ -304,19 +302,27 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi openModelPicker(); return; // The learner replaces it explicitly, then sends again. } + startingRef.current = true; setBusy(true); - void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined) + setStartError(false); + void startDraftSession(draft, fetch, mode === "practice" ? "practice" : undefined, mode === "practice" ? picksRef.current : undefined, + starterRef.current, JSON.stringify(catalog?.slots.strong)) .then((target) => { if (target) router.push(target); - else setBusy(false); + else { startingRef.current = false; setBusy(false); } }) - .catch(() => setBusy(false)); - }, [busy, router, configured, zeroKeys, catalog]); + .catch(() => { + startingRef.current = false; + setBusy(false); + setStartError(true); + }); + }, [router, configured, zeroKeys, catalog, mode]); return ( <> ?prompt=`. - * Node's default max HTTP header size is 16KB. A long paste (a syllabus) - * makes that GET return 431, and Electron commits an empty document: the - * window stays the native background and looks frozen. The chat record - * already exists, with an empty transcript, because POST /api/chats - * succeeded first. - * - * sessionStorage holds the trimmed text under `lh.openingPrompt.`. - * The push target stays `/chat/`. The chat page still honors a - * `?prompt=` search param for an old link; when that param is absent it - * reads this stash and sends it once. The stash survives until that send - * resolves, so a reload before the POST lands can retry. Absent storage - * (node tests, a thrown access) is a no-op — the short path still returns. + * Same-tab handover of a new session's full opening text. Never put the + * message in the navigation URL: long query payloads can be rejected before + * the chat page loads. Storage is required for automatic delivery, not an + * optional best-effort side effect of navigating away from the source draft. */ +export const OPENING_PROMPT_FAILURE = "Couldn’t open the session. Your message is still here. Try sending again."; + +export interface OpeningPrompt { + text: string; + /** Written BEFORE sending. A later mount restores a draft, never reposts. */ + attempted: boolean; +} const keyFor = (id: string) => `lh.openingPrompt.${id}`; -function store(): Storage | null { +export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean { try { - if (typeof sessionStorage === "undefined" || sessionStorage == null) return null; - return sessionStorage; + const value = JSON.stringify({ text, attempted } satisfies OpeningPrompt); + // Catch both the storage getter and its methods (quota/security errors). + const storage = globalThis.sessionStorage; + storage.setItem(keyFor(id), value); + return storage.getItem(keyFor(id)) === value; } catch { - return null; + return false; } } -export function stashOpeningPrompt(id: string, text: string): void { - store()?.setItem(keyFor(id), text); +export function readOpeningPromptEntry(id: string): OpeningPrompt | undefined { + try { + const value = globalThis.sessionStorage.getItem(keyFor(id)); + if (!value) return undefined; + try { + const entry: unknown = JSON.parse(value); + if (entry && typeof entry === "object" && "text" in entry && typeof entry.text === "string" && + entry.text.length > 0 && "attempted" in entry && typeof entry.attempted === "boolean") { + return { text: entry.text, attempted: entry.attempted }; + } + } catch { /* Old raw-text stashes have no proof that sending never began. */ } + return { text: value, attempted: true }; + } catch { + return undefined; + } } export function readOpeningPrompt(id: string): string | undefined { - const value = store()?.getItem(keyFor(id)); - return value && value.length > 0 ? value : undefined; + return readOpeningPromptEntry(id)?.text; } -export function clearOpeningPrompt(id: string): void { - store()?.removeItem(keyFor(id)); +export function clearOpeningPrompt(id: string): boolean { + try { + globalThis.sessionStorage.removeItem(keyFor(id)); + return true; + } catch { + // Canonical conversation/acknowledgment remains the truth. A later + // mount with real conversation can try cleanup again; never crash it. + return false; + } } diff --git a/components/session-core.ts b/components/session-core.ts index 35d2021..8c53aa4 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -179,6 +179,8 @@ function sameDetails(a: unknown, b: unknown): boolean { export interface SessionState { messages: LhViewMessage[]; nativeMessages: LhMessage[]; + /** Server-read history only; optimistic bubbles are not delivery proof. */ + hasConfirmedConversation: boolean; /** Completed conversation usage; null after compaction or before first usage. */ contextTokens: number | null; status: SessionStatus; @@ -298,6 +300,7 @@ export class SessionModel { return { messages: projectView(messages, live), nativeMessages: messages, + hasConfirmedConversation: this.base.some(m => m.role === "user" || m.role === "assistant"), contextTokens: measuredContextTokens(messages, this.incompleteUsageIds), status: this.statusValue, error: this.errorValue ?? providerError, @@ -636,7 +639,7 @@ export class SessionModel { * POST + consume. Ready-gated: one turn per client at a time (the * server's 409 + identity retry covers the wake-race window). */ - async send(input: ChatInput): Promise { + async send(input: ChatInput, onAccepted?: () => void): Promise { const recovering = this.statusValue === "error"; if (!this.readyRef && !recovering) return false; this.consumeAbort?.abort(); @@ -721,6 +724,8 @@ export class SessionModel { throw new Error((await res.text()) || `The send failed (${res.status}).`); } accepted = true; + // Opening-message handover ends at acceptance, not stream completion. + onAccepted?.(); // CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze // postmortem): the POST acknowledges and closes; there is no stream // on this response to consume. Attach through the SAME reattach wire @@ -737,10 +742,10 @@ export class SessionModel { return true; } catch (err) { if (this.consumeAbort !== controller || controller.signal.aborted) return accepted; - // Restore the question when acceptance failed or is unknown. Remove - // only THIS optimistic answer, never saved copies/newer live content. - // Other input kinds retain their existing optimistic presentation. - if (input.type === "answer") { + // Restore the question, or the new-session opening draft, when + // acceptance failed/is unknown. Other text sends keep their existing + // optimistic presentation; remove only THIS attempt's local entries. + if (input.type === "answer" || (input.type === "text" && onAccepted && !accepted)) { const ids = new Set(optimistic.map(m => m.id)); this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); @@ -749,9 +754,9 @@ export class SessionModel { } this.setError((err as Error).message); this.setStatus("error"); - // true only once the POST ack landed. A throw before that (the - // fetch, or !res.ok) leaves the message unsent — the caller keeps - // its opening-text stash and a reload can retry. + // true means we observed this POST's acknowledgment. false does + // NOT prove non-delivery: a network failure may hide acceptance. + // Opening-message callers restore a draft, never automatically retry. return accepted; } } diff --git a/components/session.ts b/components/session.ts index 3c35d3b..4b1cbb5 100644 --- a/components/session.ts +++ b/components/session.ts @@ -102,7 +102,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall [model], ); - const send = useCallback((input: ChatInput) => model.send(input), [model]); + const send = useCallback((input: ChatInput, onAccepted?: () => void) => model.send(input, onAccepted), [model]); const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]); const steer = useCallback((text: string) => void model.steer(text), [model]); const submitAnswer = useCallback( @@ -129,6 +129,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall return { messages: state.messages, nativeMessages: state.nativeMessages, + hasConfirmedConversation: state.hasConfirmedConversation, contextTokens: state.contextTokens, status: state.status, error: state.error, diff --git a/components/start-session.ts b/components/start-session.ts new file mode 100644 index 0000000..1db399b --- /dev/null +++ b/components/start-session.ts @@ -0,0 +1,34 @@ +import { clearOpeningPrompt, OPENING_PROMPT_FAILURE, stashOpeningPrompt } from "./opening-prompt"; + +interface SessionCreation { + title: string; + kind?: "practice"; + graphContext?: { propositions: readonly { id: string; name: string; statement: string }[] }; +} + +/** Per source-composer instance. Retain a successful creation if storage + * fails, so an unchanged retry does not create another empty session. + * Changed text/context/settings is a different start, not a stale title. + */ +export class SessionStarter { + private created?: { identity: string; id: string }; + + async start(body: SessionCreation, fetchLike: typeof fetch, settingsIdentity?: string): Promise { + const identity = JSON.stringify([body, settingsIdentity]); + if (this.created?.identity !== identity) { + if (this.created) clearOpeningPrompt(this.created.id); + this.created = undefined; + const res = await fetchLike("/api/chats", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) throw new Error(OPENING_PROMPT_FAILURE); + const result = await res.json() as { id?: unknown }; + if (typeof result.id !== "string" || !/^[A-Za-z0-9_-]+$/.test(result.id)) throw new Error(OPENING_PROMPT_FAILURE); + this.created = { identity, id: result.id }; + } + if (!stashOpeningPrompt(this.created.id, body.title)) throw new Error(OPENING_PROMPT_FAILURE); + return `/chat/${this.created.id}`; + } +} diff --git a/scripts/smoke-opening-prompt.mjs b/scripts/smoke-opening-prompt.mjs new file mode 100644 index 0000000..c1f4665 --- /dev/null +++ b/scripts/smoke-opening-prompt.mjs @@ -0,0 +1,251 @@ +/** Focused synthetic live-UI check. No model/provider calls: /api/chat is + * intercepted, catalog is a fixture, app HOME/data/auth are throwaway, and + * the browser uses a fresh profile. Creates only synthetic session records. + * Run: node scripts/smoke-opening-prompt.mjs + */ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import fs from "node:fs"; +import net from "node:net"; +import { createRequire } from "node:module"; +import os from "node:os"; +import path from "node:path"; +import puppeteer from "puppeteer-core"; + +const root = path.resolve(import.meta.dirname, ".."); +const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh154-ui-")); +fs.mkdirSync(path.join(home, "data")); +fs.writeFileSync(path.join(home, "auth.json"), "{}\n"); +// Next's supported font fixture avoids external font downloads in this check. +const require = createRequire(import.meta.url); +const { getFontAxes } = require("next/dist/compiled/@next/font/dist/google/get-font-axes.js"); +const { getGoogleFontsUrl } = require("next/dist/compiled/@next/font/dist/google/get-google-fonts-url.js"); +const fonts = Object.fromEntries(["JetBrains Mono", "Source Sans 3"].map(family => [ + getGoogleFontsUrl(family, getFontAxes(family, ["variable"], ["normal"]), "swap"), + `@font-face { font-family: '${family}'; src: local('Arial'); font-weight: 100 900; }`, +])); +fs.writeFileSync(path.join(home, "fonts.cjs"), `module.exports = ${JSON.stringify(fonts)};\n`); +const listener = net.createServer(); +listener.listen(0, "127.0.0.1"); +await once(listener, "listening"); +const port = listener.address().port; +await new Promise(resolve => listener.close(resolve)); +const origin = `http://127.0.0.1:${port}`; +const env = { + PATH: process.env.PATH, HOME: home, TMPDIR: home, NODE_ENV: "development", + LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), + LH_IGNORE_ENV_KEYS: "1", NEXT_TELEMETRY_DISABLED: "1", + NEXT_FONT_GOOGLE_MOCKED_RESPONSES: path.join(home, "fonts.cjs"), +}; +const server = spawn(process.execPath, ["node_modules/next/dist/bin/next", "dev", "--hostname", "127.0.0.1", "--port", String(port)], { + cwd: root, env, stdio: ["ignore", "pipe", "pipe"], +}); +let logs = ""; +const capture = data => { logs = (logs + data).slice(-24000); }; +server.stdout.on("data", capture); server.stderr.on("data", capture); +let browser; +let page; +const errors = []; +const checks = []; +try { + await new Promise((resolve, reject) => { + const deadline = setTimeout(() => reject(new Error("app startup timed out")), 60000); + const ready = () => { if (logs.includes("Ready in")) { clearTimeout(deadline); server.stdout.off("data", ready); resolve(); } }; + server.stdout.on("data", ready); + server.once("exit", code => { clearTimeout(deadline); reject(new Error(`app exited ${code}`)); }); + ready(); + }); + browser = await puppeteer.launch({ + executablePath: process.env.LH_TEST_CHROME || "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", + headless: true, userDataDir: path.join(home, "browser"), env, + args: ["--disable-background-networking", "--disable-sync", "--no-first-run", "--disable-default-apps"], + }); + page = await browser.newPage(); + page.setDefaultTimeout(45000); + page.on("pageerror", err => errors.push(err.message)); + const model = { id: "synthetic", name: "Synthetic (no calls)", provider: "fixture", input: ["text"], reasoning: false, supportedLevels: ["off"], cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, contextWindow: 8192, maxTokens: 1024 }; + const slot = { model: { provider: model.provider, id: model.id }, thinkingLevel: "off" }; + const catalog = { models: [model], providers: [{ id: "fixture", label: "Fixture", keyPresent: true }], defaults: null, + slots: { strong: slot, cheap: slot, vision: slot }, rolePins: {} }; + const posts = []; + const creates = []; + const accepted = new Map(); + let mode = "accepted"; + let held = []; + await page.setRequestInterception(true); + page.on("request", async request => { + try { + const url = new URL(request.url()); + if (url.origin !== origin) { await request.abort(); return; } + const pathname = url.pathname; + const respond = body => request.respond({ status: 200, contentType: "application/json", body: JSON.stringify(body) }); + if (pathname === "/api/models") return await respond(catalog); + // Notifications are outside this handover check. 204 tells native + // EventSource not to reconnect or hold a test connection open. + if (pathname === "/api/events") return await request.respond({ status: 204 }); + if (pathname === "/api/chat") { + const body = JSON.parse(request.postData()); posts.push(body); + if (mode === "held-post") { held.push(request); return; } + if (mode === "lost-ack") return await request.abort("failed"); + if (mode === "refused") return await request.respond({ status: 503, contentType: "application/json", body: JSON.stringify({ code: "models_not_configured", missing: ["strong"] }) }); + accepted.set(body.chatId, [{ id: "saved_user", role: "user", content: body.input.text, timestamp: Date.now() }]); + return await respond({ ok: true }); + } + if (/^\/api\/chats\/[^/]+\/stream$/.test(pathname)) { + // Keep teacher delivery unfinished: cleanup must NOT wait for it. + held.push(request); return; + } + const match = pathname.match(/^\/api\/chats\/([^/]+)$/); + if (match && request.method() === "GET" && accepted.has(match[1])) { + return await respond({ messages: accepted.get(match[1]), turns: [], turnInFlight: false }); + } + if (pathname === "/api/chats" && request.method() === "POST") creates.push(JSON.parse(request.postData())); + await request.continue(); + } catch (err) { if (!/Target closed|Session closed|Invalid InterceptionId/.test(String(err))) errors.push(String(err)); } + }); + async function open(route) { + // CDP-intercepted requests do not settle themselves on navigation. + // Release synthetic streams from prior cases rather than pin slots. + await Promise.all(held.splice(0).map(request => request.abort("aborted").catch(() => {}))); + await page.goto(origin + route, { waitUntil: "domcontentloaded", timeout: 120000 }); + await page.waitForSelector(route === "/" ? "textarea" : '[data-testid="composer"] textarea'); + await page.waitForFunction(() => !document.querySelector('[data-testid="setup-cta"]') && !document.querySelector('[data-testid="setup-cta-keys"]')); + } + async function fill(text) { + await page.evaluate(value => { + const textarea = document.querySelector('[data-testid="composer"] textarea') || document.querySelector("textarea"); + Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value").set.call(textarea, value); + textarea.dispatchEvent(new Event("input", { bubbles: true })); + }, text); + } + async function submit() { + await page.waitForFunction(() => { + const button = document.querySelector('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]'); + return button && !button.disabled; + }); + await page.locator('button[aria-label="Start session"], [data-testid="composer"] button[aria-label="Send"]').click(); + } + async function stash(id) { return page.evaluate(chat => sessionStorage.getItem(`lh.openingPrompt.${chat}`), id); } + async function assertDraft(text) { await page.waitForFunction(value => document.querySelector('[data-testid="composer"] textarea')?.value === value, {}, text); } + const long = "A full synthetic syllabus — 世界 & ? #. ".repeat(1200).trim(); + for (const route of ["/", "/chat/new"]) { + for (const text of ["a short synthetic topic", long]) { + await open(route); + const count = posts.length; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname) && !location.search); + await page.waitForFunction(() => document.querySelector('[data-testid="composer"]')); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + assert.equal(new URL(page.url()).search, ""); + assert.equal(await stash(posts.at(-1).chatId), null); + checks.push(`${route}: ${text.length > 1000 ? "long" : "short"} input delivered intact through short URL; stash removed while stream held`); + } + } + // The old long-query mechanism really is rejected by this isolated Node server. + const oldResponse = await fetch(`${origin}${new URL(page.url()).pathname}?prompt=${encodeURIComponent(long)}`); + assert.equal(oldResponse.status, 431); await oldResponse.text(); + checks.push("old long-query request returned431 (browser-shell blank appearance not asserted)"); + + for (const route of ["/", "/chat/new"]) { + await open(route); + await page.evaluate(() => { + const setItem = Storage.prototype.setItem; + Storage.prototype.setItem = function(key, value) { + if (window.failOpeningStorage && key.startsWith("lh.openingPrompt.")) throw new DOMException("synthetic quota", "QuotaExceededError"); + return setItem.call(this, key, value); + }; + window.failOpeningStorage = true; + }); + const createCount = creates.length; const sendCount = posts.length; + await fill(long); await submit(); + await page.waitForSelector('[role="alert"]'); + assert.equal(new URL(page.url()).pathname, route); + assert.equal(await page.$eval("textarea", element => element.value), long); + assert.equal(posts.length, sendCount); + assert.equal(creates.length, createCount + 1); + await page.evaluate(() => { window.failOpeningStorage = false; }); + await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => !Object.keys(sessionStorage).some(k => k.startsWith("lh.openingPrompt."))); + assert.equal(creates.length, createCount + 1, "unchanged retry reuses creation"); + assert.equal(posts.at(-1).input.text, long); + checks.push(`${route}: quota failure preserved visible draft/no navigation; retry reused creation`); + } + + for (const failure of ["lost-ack", "refused", "held-post"]) { + mode = failure; + await open("/chat/new"); + const text = `recover interrupted ${failure}`; + await fill(text); await submit(); + await page.waitForFunction(() => /^\/chat\/chat_/.test(location.pathname)); + await page.waitForFunction(() => Object.values(sessionStorage).some(v => v.includes('"attempted":true'))); + if (failure !== "held-post") await assertDraft(text); + const count = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await assertDraft(text); + assert.equal(posts.length, count, "interrupted start must not automatically repost"); + checks.push(`${failure}: reload restored full draft, no automatic repost`); + if (failure === "lost-ack") { + mode = "accepted"; + await submit(); + const id = new URL(page.url()).pathname.split("/").at(-1); + await page.waitForFunction(chat => !sessionStorage.getItem(`lh.openingPrompt.${chat}`), {}, id); + assert.equal(posts.length, count + 1); + assert.equal(posts.at(-1).input.text, text); + checks.push("restored draft sent only after explicit click and cleaned up on acceptance"); + } + } + mode = "accepted"; + // Marker-only session must still automatically deliver a fresh opening. + await open("/chat/new"); + const markerId = await page.evaluate(async () => { + const res = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "marker fixture", graphContext: { propositions: [{ id: "synthetic_node", name: "Fixture", statement: "Synthetic" }] } }) }); + return (await res.json()).id; + }); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "marker opening", attempted: false })), markerId); + const markerCount = posts.length; + await page.goto(`${origin}/chat/${markerId}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, markerCount + 1); + assert.equal(posts.at(-1).input.text, "marker opening"); + checks.push("graph-marker-only record still receives opening text"); + // A real persisted user prevents delivery and cleans a stale copy on mount. + const chatFile = path.join(home, "data", "chats", `${markerId}.json`); + const record = JSON.parse(fs.readFileSync(chatFile, "utf8")); + record.messages.push({ id: "confirmed_fixture", role: "user", content: "already received", timestamp: Date.now() }); + fs.writeFileSync(chatFile, JSON.stringify(record)); + await page.evaluate(id => sessionStorage.setItem(`lh.openingPrompt.${id}`, JSON.stringify({ text: "stale opening", attempted: true })), markerId); + const completedCount = posts.length; + await page.reload({ waitUntil: "domcontentloaded" }); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, markerId); + assert.equal(posts.length, completedCount); + checks.push("persisted real conversation removed stale stash without reposting"); + await open("/chat/new"); + const legacyId = await page.evaluate(async () => { + const response = await fetch("/api/chats", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ title: "legacy fixture" }) }); + return (await response.json()).id; + }); + const legacyCount = posts.length; + await page.goto(`${origin}/chat/${legacyId}?prompt=${encodeURIComponent("legacy opening")}`, { waitUntil: "domcontentloaded" }); + await page.waitForFunction(() => document.body.innerText.includes("legacy opening")); + await page.waitForFunction(id => !sessionStorage.getItem(`lh.openingPrompt.${id}`), {}, legacyId); + assert.equal(posts.length, legacyCount + 1); + assert.equal(posts.at(-1).input.text, "legacy opening"); + checks.push("legacy short ?prompt link still sends its complete opening message"); + assert.deepEqual(errors, []); + console.log(JSON.stringify({ checks, pageErrors: errors, isolation: "synthetic catalog/POST+streams; empty auth; no providers", origin }, null, 2)); +} catch (err) { + console.error(err); + console.error(JSON.stringify({ checks, errors, url: page?.url(), dom: await page?.evaluate(() => ({ textareas: [...document.querySelectorAll('textarea')].map(e => ({ length: e.value.length, placeholder: e.placeholder })), buttons: [...document.querySelectorAll('button[aria-label="Send"], button[aria-label="Start session"]')].map(e => ({ disabled: e.disabled, label: e.getAttribute('aria-label') })), alerts: [...document.querySelectorAll('[role="alert"]')].map(e => e.textContent), failStorage: window.failOpeningStorage, text: document.body.innerText.slice(-1200) })).catch(() => null) }, null, 2)); + console.error(logs); process.exitCode = 1; +} finally { + await browser?.close(); + const exited = once(server, "exit"); + server.kill("SIGTERM"); + const killTimer = setTimeout(() => server.kill("SIGKILL"), 5000); killTimer.unref(); + await exited; clearTimeout(killTimer); + fs.rmSync(home, { recursive: true, force: true }); +} diff --git a/tests/opening-delivery.test.ts b/tests/opening-delivery.test.ts new file mode 100644 index 0000000..11926bc --- /dev/null +++ b/tests/opening-delivery.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { SessionModel } from "../components/session-core.ts"; +import { newUserMessage } from "../src/core/native-messages.ts"; + +function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise(done => { resolve = done; }); + return { promise, resolve }; +} + +describe("new-session opening delivery acceptance", () => { + it("notifies acceptance before the record read or teacher stream completes", async () => { + const record = deferred(); + const accepted = deferred(); + const calls: string[] = []; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + calls.push(String(url)); + if (String(url) === "/api/chat") return Response.json({ ok: true }); + if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n"); + return record.promise; + } }); + let completed = false; + const sending = m.send({ type: "text", text: "my syllabus" }, () => accepted.resolve()).then(result => { completed = true; return result; }); + await accepted.promise; + assert.equal(completed, false); + assert.equal(m.getState().hasConfirmedConversation, false, "optimistic user text is not canonical proof"); + record.resolve(Response.json({ messages: [newUserMessage("my syllabus")] })); + assert.equal(await sending, true); + assert.equal(m.getState().hasConfirmedConversation, true); + assert.equal(calls.filter(url => url === "/api/chat").length, 1); + }); + + it("a teardown racing the POST still cleans up on acknowledgment without attaching", async () => { + const post = deferred(); + let notified = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + assert.equal(String(url), "/api/chat"); + return post.promise; + } }); + const sending = m.send({ type: "text", text: "hello" }, () => { notified++; }); + m.stop(); + post.resolve(Response.json({ ok: true })); + assert.equal(await sending, true); + assert.equal(notified, 1); + }); + + for (const failure of ["refusal", "409", "lost acknowledgment"] as const) { + it(`${failure} does not notify acceptance or leave a false confirmed conversation`, async () => { + let notified = false; + const m = new SessionModel({ chatId: "c1", initialMessages: [], waitMaxMs: 0, fetch: async () => { + if (failure === "lost acknowledgment") throw new TypeError("connection lost"); + return new Response("refused", { status: failure === "409" ? 409 : 503 }); + } }); + assert.equal(await m.send({ type: "text", text: "keep this" }, () => { notified = true; }), false); + assert.equal(notified, false); + assert.equal(m.getState().hasConfirmedConversation, false); + assert.equal(m.getState().nativeMessages.length, 0, "restore the opening draft, not a stranded optimistic bubble"); + assert.equal(m.status, "error"); + }); + } + + it("post-acceptance attachment failure remains accepted and does not resend input", async () => { + let posts = 0; + let notified = 0; + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => { + if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); } + throw new TypeError("record read failed"); + } }); + assert.equal(await m.send({ type: "text", text: "hello" }, () => { notified++; }), true); + assert.equal(notified, 1); + assert.equal(posts, 1); + assert.equal(m.status, "error"); + }); + + it("a busy gate sends nothing, then canonical adoption can confirm an earlier lost acknowledgment", async () => { + const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => { throw new Error("must not post"); } }); + m.readyRef = false; + assert.equal(await m.send({ type: "text", text: "hello" }, () => assert.fail("must not acknowledge")), false); + m.setMessages([newUserMessage("hello")]); + assert.equal(m.getState().hasConfirmedConversation, true); + }); +}); diff --git a/tests/opening-prompt.test.ts b/tests/opening-prompt.test.ts new file mode 100644 index 0000000..f89f659 --- /dev/null +++ b/tests/opening-prompt.test.ts @@ -0,0 +1,123 @@ +import assert from "node:assert/strict"; +import { afterEach, beforeEach, describe, it } from "node:test"; +import { clearOpeningPrompt, readOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt } from "../components/opening-prompt.ts"; +import { SessionStarter } from "../components/start-session.ts"; + +const original = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +let data: Map; +let storage: Pick; +function install(value: unknown) { + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value }); +} +beforeEach(() => { + data = new Map(); + storage = { + getItem: key => data.get(key) ?? null, + setItem: (key, value) => { data.set(key, value); }, + removeItem: key => { data.delete(key); }, + }; + install(storage); +}); +afterEach(() => { + if (original) Object.defineProperty(globalThis, "sessionStorage", original); + else Reflect.deleteProperty(globalThis, "sessionStorage"); +}); +const fail = () => { throw new Error("synthetic storage failure"); }; + +describe("opening message storage", () => { + it("keeps full text off the URL and records attempted delivery before send", () => { + const text = "a syllabus with Unicode 世界 & ? # ".repeat(1500); + assert.equal(stashOpeningPrompt("c1", text), true); + assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: false }); + assert.equal(stashOpeningPrompt("c1", text, true), true); + assert.deepEqual(readOpeningPromptEntry("c1"), { text, attempted: true }); + assert.equal(clearOpeningPrompt("c1"), true); + assert.equal(readOpeningPrompt("c1"), undefined); + }); + + it("treats a legacy raw-text stash conservatively as a possibly attempted send", () => { + data.set("lh.openingPrompt.old", "teach me logic"); + assert.deepEqual(readOpeningPromptEntry("old"), { text: "teach me logic", attempted: true }); + }); + + for (const unavailable of [undefined, null]) { + it(`reports unavailable storage (${unavailable}) instead of claiming a safe handover`, () => { + install(unavailable); + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + assert.equal(readOpeningPrompt("c1"), undefined); + assert.equal(clearOpeningPrompt("c1"), false); + }); + } + it("catches a throwing storage getter", () => { + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, get: fail }); + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + assert.equal(readOpeningPrompt("c1"), undefined); + assert.equal(clearOpeningPrompt("c1"), false); + }); + for (const method of ["getItem", "setItem", "removeItem"] as const) { + it(`contains ${method} exceptions without crashing a source or chat page`, () => { + storage[method] = fail; + assert.doesNotThrow(() => stashOpeningPrompt("c1", "keep this")); + assert.doesNotThrow(() => readOpeningPrompt("c1")); + assert.doesNotThrow(() => clearOpeningPrompt("c1")); + if (method !== "removeItem") assert.equal(stashOpeningPrompt("c1", "keep this"), false); + }); + } + it("does not accept a storage write that failed silently", () => { + storage.setItem = () => {}; + assert.equal(stashOpeningPrompt("c1", "keep this"), false); + }); +}); + +describe("new-session create and handover", () => { + function createFetch() { + const bodies: unknown[] = []; + const fetchLike = (async (_url, init) => { + bodies.push(JSON.parse(String(init?.body))); + return Response.json({ id: `chat_${bodies.length}` }); + }) as typeof fetch; + return { bodies, fetchLike }; + } + it("refuses navigation on quota failure, then reuses the created session for unchanged retry", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + const text = "full text ".repeat(4000); + const setItem = storage.setItem; + storage.setItem = fail; + await assert.rejects(starter.start({ title: text }, fetchLike)); + assert.equal(bodies.length, 1); + storage.setItem = setItem; + assert.equal(await starter.start({ title: text }, fetchLike), "/chat/chat_1"); + assert.equal(bodies.length, 1); + assert.equal(readOpeningPrompt("chat_1"), text); + }); + it("reuses creation even when verifying the first storage write failed", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + const getItem = storage.getItem; + storage.getItem = fail; + await assert.rejects(starter.start({ title: "hello" }, fetchLike)); + storage.getItem = getItem; + assert.equal(await starter.start({ title: "hello" }, fetchLike), "/chat/chat_1"); + assert.equal(bodies.length, 1); + }); + it("changed text, mode, picks or birth settings create a matching new session", async () => { + const starter = new SessionStarter(); + const { bodies, fetchLike } = createFetch(); + await starter.start({ title: "original" }, fetchLike, "model-a"); + await starter.start({ title: "edited" }, fetchLike, "model-a"); + await starter.start({ title: "edited", kind: "practice" }, fetchLike, "model-a"); + const picked = { title: "edited", kind: "practice" as const, graphContext: { propositions: [{ id: "p1", name: "P", statement: "S" }] } }; + await starter.start(picked, fetchLike, "model-a"); + await starter.start(picked, fetchLike, "model-b"); + assert.equal(bodies.length, 5); + assert.equal(readOpeningPrompt("chat_1"), undefined); + }); + it("does not stash or navigate on a refused creation or invalid ID", async () => { + for (const response of [new Response("unavailable", { status: 503 }), Response.json({}), Response.json({ id: "../bad?prompt=leak" })]) { + const starter = new SessionStarter(); + await assert.rejects(starter.start({ title: "hello" }, (async () => response) as typeof fetch)); + assert.equal(data.size, 0); + } + }); +}); diff --git a/tests/render-new-session.test.tsx b/tests/render-new-session.test.tsx index e62a550..bde25da 100644 --- a/tests/render-new-session.test.tsx +++ b/tests/render-new-session.test.tsx @@ -2,10 +2,24 @@ import React from "react"; (globalThis as Record).React = React; import { renderToStaticMarkup } from "react-dom/server"; -import { describe, it } from "node:test"; +import { afterEach, beforeEach, describe, it } from "node:test"; import assert from "node:assert/strict"; import { DraftShell, startDraftSession } from "../components/new-session.tsx"; -import { clearOpeningPrompt, readOpeningPrompt } from "../components/opening-prompt.ts"; +import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts"; + +const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage"); +beforeEach(() => { + const data = new Map(); + Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: { + getItem: (key: string) => data.get(key) ?? null, + setItem: (key: string, value: string) => { data.set(key, value); }, + removeItem: (key: string) => { data.delete(key); }, + } }); +}); +afterEach(() => { + if (originalStorage) Object.defineProperty(globalThis, "sessionStorage", originalStorage); + else Reflect.deleteProperty(globalThis, "sessionStorage"); +}); /** * The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session" @@ -82,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => { }); describe("DraftShell — the sessionless surface", () => { + it("storage failure leaves the full draft editable beside the approved error", () => { + const html = renderToStaticMarkup( {}} onSend={() => {}} startError />); + assert.ok(html.includes('role="alert"')); + assert.ok(html.includes(OPENING_PROMPT_FAILURE)); + assert.match(html, /]*>keep all my text<\/textarea>/); + }); it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => { const html = renderToStaticMarkup(