Merge PR #154 short opening URLs and recoverable prompt handoff
This commit is contained in:
+69
-12
@@ -26,6 +26,7 @@ import { Composer } from "@/components/composer";
|
|||||||
import { useEnterSubmits } from "@/components/enter-submits";
|
import { useEnterSubmits } from "@/components/enter-submits";
|
||||||
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
import { blurAfterPointerClick } from "@/components/focus-hygiene";
|
||||||
import { dockStateKey } from "@/components/dock-state-key";
|
import { dockStateKey } from "@/components/dock-state-key";
|
||||||
|
import { clearOpeningPrompt, readOpeningPromptEntry, stashOpeningPrompt, OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||||
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
|
import { forwardDockWheel, useDockHeight } from "@/components/dock-height";
|
||||||
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
import { QueuedSteersBody, type QueuedSteerLine } from "@/components/queued-steers";
|
||||||
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
import { DictationBar, finishDictation, joinDictation, useDictation } from "@/components/dictation";
|
||||||
@@ -251,7 +252,7 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// 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 });
|
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
|
||||||
|
|
||||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -261,16 +262,6 @@ export function Chat({
|
|||||||
// The reattach settled terminal → the sync-and-continue seam (below).
|
// The reattach settled terminal → the sync-and-continue seam (below).
|
||||||
setReattachSettled(() => reattachSettledRef.current());
|
setReattachSettled(() => reattachSettledRef.current());
|
||||||
|
|
||||||
// New chat with a topic: send the first message exactly once
|
|
||||||
// (initialPromptOwed — the pure gate, pinned in the test suite).
|
|
||||||
const sentInitial = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (!sentInitial.current && initialPromptOwed(initialMessages, initialPrompt)) {
|
|
||||||
sentInitial.current = true;
|
|
||||||
void send({ type: "text", text: initialPrompt! });
|
|
||||||
}
|
|
||||||
}, [initialPrompt, initialMessages, send]);
|
|
||||||
|
|
||||||
// A turn running server-side, attached to no live view of ours (navigate
|
// 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
|
// away mid-turn, or a wake turn discovered mid-session): the reattach
|
||||||
// stream is the primary channel (resumeStream — the same rendering path
|
// stream is the primary channel (resumeStream — the same rendering path
|
||||||
@@ -579,6 +570,66 @@ export function Chat({
|
|||||||
* Auto-continuations (probe answers) never set it — their refusal
|
* Auto-continuations (probe answers) never set it — their refusal
|
||||||
* opens recovery with no replay. */
|
* opens recovery with no replay. */
|
||||||
const draftRef = useRef<string | null>(null);
|
const draftRef = useRef<string | null>(null);
|
||||||
|
const openingDraftRef = useRef<string | null>(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
|
// 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
|
// 503 models_not_configured envelope renders as the SETUP FLOW, never
|
||||||
@@ -1161,6 +1212,10 @@ export function Chat({
|
|||||||
openModelPicker();
|
openModelPicker();
|
||||||
return; // No optimistic send and no change to the learner's draft.
|
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
|
draftRef.current = t; // the refusal flow's preserve-and-replay seam
|
||||||
applyText("");
|
applyText("");
|
||||||
void send({ type: "text", text: t });
|
void send({ type: "text", text: t });
|
||||||
@@ -1592,6 +1647,7 @@ export function Chat({
|
|||||||
</Shimmer>
|
</Shimmer>
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{openingError && <p role="alert" className="text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
<div className="flex items-center justify-between gap-3 rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
<span>Something went wrong: {error.message}</span>
|
<span>Something went wrong: {error.message}</span>
|
||||||
@@ -1779,7 +1835,8 @@ export function Chat({
|
|||||||
* 2026-10-02 abort chain proper (question before turn). */
|
* 2026-10-02 abort chain proper (question before turn). */
|
||||||
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
|
export type EscAbortTarget = "mermaid" | "plan" | "setup" | "question" | "problem" | "turn" | null;
|
||||||
/**
|
/**
|
||||||
* Does the ?prompt topic still owe its first send? The gate is "no real
|
* Does the opening topic still owe its first send? The topic is the
|
||||||
|
* server's ?prompt= or the sessionStorage stash. The gate is "no real
|
||||||
* conversation yet" (no user/assistant message) — NOT emptiness: a
|
* conversation yet" (no user/assistant message) — NOT emptiness: a
|
||||||
* session started with map picks carries a graph-context MARKER as
|
* session started with map picks carries a graph-context MARKER as
|
||||||
* message[0], and that scaffolding must never swallow the first send
|
* message[0], and that scaffolding must never swallow the first send
|
||||||
|
|||||||
+17
-15
@@ -12,6 +12,8 @@ import { DictationBar, joinDictation, useDictation } from "@/components/dictatio
|
|||||||
import { useFieldCycle } from "@/components/field-cycle";
|
import { useFieldCycle } from "@/components/field-cycle";
|
||||||
import { SidebarToggle } from "@/components/sidebar";
|
import { SidebarToggle } from "@/components/sidebar";
|
||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
|
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||||
|
import { SessionStarter } from "@/components/start-session";
|
||||||
import type { TurnUsage } from "@/src/core";
|
import type { TurnUsage } from "@/src/core";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -56,6 +58,9 @@ export function Home({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
const [busy, setBusy] = useState(false);
|
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
|
/* 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
|
* the keyboard from the stop. Falling edge only; this surface's only
|
||||||
* dictation target IS this textarea, so no case-split is needed. */
|
* dictation target IS this textarea, so no case-split is needed. */
|
||||||
@@ -105,7 +110,7 @@ export function Home({
|
|||||||
};
|
};
|
||||||
const begin = async () => {
|
const begin = async () => {
|
||||||
const t = topicRef.current.trim();
|
const t = topicRef.current.trim();
|
||||||
if (!t || busy) return;
|
if (!t || startingRef.current) return;
|
||||||
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
|
// Unconfigured: the send INTERCEPTS into the setup flow (the draft
|
||||||
// stays in the textarea; the learner sends it after Save).
|
// stays in the textarea; the learner sends it after Save).
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
@@ -128,29 +133,25 @@ export function Home({
|
|||||||
openModelPicker();
|
openModelPicker();
|
||||||
return; // Preserve the draft and never create an orphan session.
|
return; // Preserve the draft and never create an orphan session.
|
||||||
}
|
}
|
||||||
|
startingRef.current = true;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
setStartError(false);
|
||||||
try {
|
try {
|
||||||
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
// The map context (owner, 2026-10-04): picks in EITHER mode ride
|
||||||
// the CREATION as graphContext — the server writes a
|
// the CREATION as graphContext — the server writes a
|
||||||
// graph-context marker as message[0] (a separate custom message
|
// graph-context marker as message[0] (a separate custom message
|
||||||
// before the learner's first user message, propositions + the
|
// before the learner's first user message, propositions + the
|
||||||
// chains among them); the prompt stays the learner's own words.
|
// chains among them); the prompt stays the learner's own words.
|
||||||
const res = await fetch("/api/chats", {
|
const target = await starterRef.current.start({
|
||||||
method: "POST",
|
title: t,
|
||||||
headers: { "Content-Type": "application/json" },
|
...(mode === "practice" ? { kind: "practice" } : {}),
|
||||||
body: JSON.stringify({
|
...(practiceProps.length > 0 ? { graphContext: { propositions: practiceProps } } : {}),
|
||||||
title: t,
|
}, fetch, JSON.stringify(catalog?.slots.strong));
|
||||||
...(mode === "practice" ? { kind: "practice" } : {}),
|
router.push(target);
|
||||||
...(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.
|
|
||||||
router.push(`/chat/${id}?prompt=${encodeURIComponent(t)}`);
|
|
||||||
} catch {
|
} catch {
|
||||||
|
startingRef.current = false;
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
|
setStartError(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -239,6 +240,7 @@ export function Home({
|
|||||||
)
|
)
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{startError && <p role="alert" className="mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
|
||||||
<form
|
<form
|
||||||
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
className="flex items-end gap-1.5 rounded-2xl border bg-card p-1.5 shadow-lg"
|
||||||
onSubmit={(e) => {
|
onSubmit={(e) => {
|
||||||
|
|||||||
+34
-24
@@ -12,6 +12,8 @@ import { useModelSetup, selectedModelUnavailable, openModelPicker, ZeroKeysPill
|
|||||||
import { StatsBar } from "@/components/stats-bar";
|
import { StatsBar } from "@/components/stats-bar";
|
||||||
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
import { ChainTableOverlay } from "@/components/chain-table.tsx";
|
||||||
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
import { ComposerActions, NodePickerControl } from "@/components/composer-actions";
|
||||||
|
import { OPENING_PROMPT_FAILURE } from "@/components/opening-prompt";
|
||||||
|
import { SessionStarter } from "@/components/start-session";
|
||||||
import type { ChatSettings, GraphSnapshot } from "@/src/core";
|
import type { ChatSettings, GraphSnapshot } from "@/src/core";
|
||||||
|
|
||||||
/** The draft's pick set (owner round 10): the SAME shape the home pick
|
/** The draft's pick set (owner round 10): the SAME shape the home pick
|
||||||
@@ -33,18 +35,18 @@ export type DraftPick = { id: string; name: string; statement: string };
|
|||||||
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
|
const DOCK = "mx-auto w-full max-w-[46rem] px-4";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The draft's whole behavior as one pure seam: create the session (title =
|
* Create, safely hand over the full opening text, then return a short
|
||||||
* the first message), persist the lifted pre-chat settings, return the push
|
* /chat/<id> target. Storage/creation failures leave the caller's draft
|
||||||
* target — /chat/<id>?prompt=<msg> — whose initialPrompt path delivers the
|
* intact. A per-composer starter reuses an already-created session on an
|
||||||
* message as turn 1. Injected fetch/router so tests pin the contract.
|
* unchanged retry. The server stamps settings at birth; no settings PUT.
|
||||||
* Returns null on empty text; rethrows on transport failure (the caller's
|
|
||||||
* busy flag resets).
|
|
||||||
*/
|
*/
|
||||||
export async function startDraftSession(
|
export async function startDraftSession(
|
||||||
text: string,
|
text: string,
|
||||||
fetchLike: typeof fetch,
|
fetchLike: typeof fetch,
|
||||||
mode?: "practice",
|
mode?: "practice",
|
||||||
picks?: readonly DraftPick[],
|
picks?: readonly DraftPick[],
|
||||||
|
starter = new SessionStarter(),
|
||||||
|
settingsIdentity?: string,
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (!t) return null;
|
if (!t) return null;
|
||||||
@@ -52,19 +54,13 @@ export async function startDraftSession(
|
|||||||
// from the pick bar creates its session WITH the graph context — the
|
// from the pick bar creates its session WITH the graph context — the
|
||||||
// same contract as the home screen's practice send (the marker with
|
// same contract as the home screen's practice send (the marker with
|
||||||
// [id] name: statement lines, target validation's truth set).
|
// [id] name: statement lines, target validation's truth set).
|
||||||
const res = await fetchLike("/api/chats", {
|
return starter.start(
|
||||||
method: "POST",
|
mode === "practice"
|
||||||
headers: { "Content-Type": "application/json" },
|
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
|
||||||
body: JSON.stringify(
|
: { title: t },
|
||||||
mode === "practice"
|
fetchLike,
|
||||||
? { title: t, kind: "practice", ...(picks && picks.length > 0 ? { graphContext: { propositions: picks } } : {}) }
|
settingsIdentity,
|
||||||
: { 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.
|
|
||||||
return `/chat/${id}?prompt=${encodeURIComponent(t)}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The pure shell (no router) — the render-test seam: the draft view's
|
/** The pure shell (no router) — the render-test seam: the draft view's
|
||||||
@@ -77,6 +73,7 @@ export function DraftShell({
|
|||||||
text,
|
text,
|
||||||
onChange,
|
onChange,
|
||||||
onSend,
|
onSend,
|
||||||
|
startError = false,
|
||||||
setupCta,
|
setupCta,
|
||||||
mode = "teach",
|
mode = "teach",
|
||||||
picks = [],
|
picks = [],
|
||||||
@@ -88,6 +85,7 @@ export function DraftShell({
|
|||||||
text: string;
|
text: string;
|
||||||
onChange: (v: string) => void;
|
onChange: (v: string) => void;
|
||||||
onSend: () => void;
|
onSend: () => void;
|
||||||
|
startError?: boolean;
|
||||||
/** The practice draft (owner, 2026-10-04): ?mode=practice — the
|
/** The practice draft (owner, 2026-10-04): ?mode=practice — the
|
||||||
* created session carries kind:"practice" and the copy changes
|
* created session carries kind:"practice" and the copy changes
|
||||||
* (the practice agent opens; no probing). */
|
* (the practice agent opens; no probing). */
|
||||||
@@ -190,6 +188,7 @@ export function DraftShell({
|
|||||||
)}
|
)}
|
||||||
setup={setupCta}
|
setup={setupCta}
|
||||||
/>
|
/>
|
||||||
|
{startError && <p role="alert" className="pointer-events-auto mb-2 text-sm text-destructive">{OPENING_PROMPT_FAILURE}</p>}
|
||||||
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
<div className="pointer-events-auto overflow-hidden rounded-2xl border bg-card shadow-lg">
|
||||||
<Composer
|
<Composer
|
||||||
busy={busy}
|
busy={busy}
|
||||||
@@ -243,6 +242,9 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
setText(v);
|
setText(v);
|
||||||
};
|
};
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
const startingRef = useRef(false);
|
||||||
|
const starterRef = useRef(new SessionStarter());
|
||||||
|
const [startError, setStartError] = useState(false);
|
||||||
// THE PICK BAR (owner round 10): the draft's lifted pick set + the
|
// THE PICK BAR (owner round 10): the draft's lifted pick set + the
|
||||||
// snapshot the standalone chain-table picker reads. Lazy — the graph
|
// snapshot the standalone chain-table picker reads. Lazy — the graph
|
||||||
// is fetched on FIRST open only, then reused.
|
// is fetched on FIRST open only, then reused.
|
||||||
@@ -280,7 +282,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
|
|
||||||
const send = useCallback(() => {
|
const send = useCallback(() => {
|
||||||
const draft = textRef.current.trim();
|
const draft = textRef.current.trim();
|
||||||
if (!draft || busy) return;
|
if (!draft || startingRef.current) return;
|
||||||
if (!configured) {
|
if (!configured) {
|
||||||
setSetupOpen(true);
|
setSetupOpen(true);
|
||||||
return;
|
return;
|
||||||
@@ -300,19 +302,27 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
|
|||||||
openModelPicker();
|
openModelPicker();
|
||||||
return; // The learner replaces it explicitly, then sends again.
|
return; // The learner replaces it explicitly, then sends again.
|
||||||
}
|
}
|
||||||
|
startingRef.current = true;
|
||||||
setBusy(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) => {
|
.then((target) => {
|
||||||
if (target) router.push(target);
|
if (target) router.push(target);
|
||||||
else setBusy(false);
|
else { startingRef.current = false; setBusy(false); }
|
||||||
})
|
})
|
||||||
.catch(() => setBusy(false));
|
.catch(() => {
|
||||||
}, [busy, router, configured, zeroKeys, catalog]);
|
startingRef.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
setStartError(true);
|
||||||
|
});
|
||||||
|
}, [router, configured, zeroKeys, catalog, mode]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DraftShell
|
<DraftShell
|
||||||
busy={busy}
|
busy={busy}
|
||||||
|
startError={startError}
|
||||||
text={text}
|
text={text}
|
||||||
onChange={applyText}
|
onChange={applyText}
|
||||||
onSend={send}
|
onSend={send}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
/**
|
||||||
|
* 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}`;
|
||||||
|
|
||||||
|
export function stashOpeningPrompt(id: string, text: string, attempted = false): boolean {
|
||||||
|
try {
|
||||||
|
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 false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
return readOpeningPromptEntry(id)?.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-14
@@ -180,6 +180,8 @@ function sameDetails(a: unknown, b: unknown): boolean {
|
|||||||
export interface SessionState {
|
export interface SessionState {
|
||||||
messages: LhViewMessage[];
|
messages: LhViewMessage[];
|
||||||
nativeMessages: LhMessage[];
|
nativeMessages: LhMessage[];
|
||||||
|
/** Server-read history only; optimistic bubbles are not delivery proof. */
|
||||||
|
hasConfirmedConversation: boolean;
|
||||||
/** Completed conversation usage; null after compaction or before first usage. */
|
/** Completed conversation usage; null after compaction or before first usage. */
|
||||||
contextTokens: number | null;
|
contextTokens: number | null;
|
||||||
status: SessionStatus;
|
status: SessionStatus;
|
||||||
@@ -257,6 +259,9 @@ export class SessionModel {
|
|||||||
// The single live consume (POST response or reattach stream): a newer
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
// consume cancels the older — one reader at a time.
|
// consume cancels the older — one reader at a time.
|
||||||
private consumeAbort: AbortController | null = null;
|
private consumeAbort: AbortController | null = null;
|
||||||
|
/** Retain the latest request across stop(): an opening POST still owns
|
||||||
|
* its refusal after view detachment, but never after a newer request. */
|
||||||
|
private latestRequest: AbortController | null = null;
|
||||||
/** Synchronous ready check for the gate (send/read mount logic). */
|
/** Synchronous ready check for the gate (send/read mount logic). */
|
||||||
readyRef = true;
|
readyRef = true;
|
||||||
|
|
||||||
@@ -299,6 +304,7 @@ export class SessionModel {
|
|||||||
return {
|
return {
|
||||||
messages: projectView(messages, live),
|
messages: projectView(messages, live),
|
||||||
nativeMessages: messages,
|
nativeMessages: messages,
|
||||||
|
hasConfirmedConversation: this.base.some(m => m.role === "user" || m.role === "assistant"),
|
||||||
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
|
contextTokens: measuredContextTokens(messages, this.incompleteUsageIds),
|
||||||
status: this.statusValue,
|
status: this.statusValue,
|
||||||
error: this.errorValue ?? providerError,
|
error: this.errorValue ?? providerError,
|
||||||
@@ -553,6 +559,7 @@ export class SessionModel {
|
|||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
|
this.latestRequest = controller;
|
||||||
// REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02):
|
// REPLAY ONTO A CLEAN SLATE (owner, optimistic round 3, 2026-10-02):
|
||||||
// the reattach stream replays the turn from its FIRST event — an
|
// the reattach stream replays the turn from its FIRST event — an
|
||||||
// aborted earlier attempt (React StrictMode's simulated remount)
|
// aborted earlier attempt (React StrictMode's simulated remount)
|
||||||
@@ -637,40 +644,41 @@ export class SessionModel {
|
|||||||
* POST + consume. Ready-gated: one turn per client at a time (the
|
* POST + consume. Ready-gated: one turn per client at a time (the
|
||||||
* server's 409 + identity retry covers the wake-race window).
|
* server's 409 + identity retry covers the wake-race window).
|
||||||
*/
|
*/
|
||||||
async send(input: ChatInput): Promise<void> {
|
async send(input: ChatInput, onAccepted?: () => void): Promise<boolean> {
|
||||||
const recovering = this.statusValue === "error";
|
const recovering = this.statusValue === "error";
|
||||||
if (!this.readyRef && !recovering) return;
|
if (!this.readyRef && !recovering) return false;
|
||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
|
this.latestRequest = controller;
|
||||||
this.setError(null);
|
this.setError(null);
|
||||||
this.setStatus("submitted");
|
this.setStatus("submitted");
|
||||||
if (recovering && input.type === "answer") {
|
if (recovering && input.type === "answer") {
|
||||||
try {
|
try {
|
||||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return false;
|
||||||
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
||||||
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return false;
|
||||||
// No verified record/liveness: retain the draft and never guess
|
// No verified record/liveness: retain the draft and never guess
|
||||||
// whether a lost acknowledgement meant the input was accepted.
|
// whether a lost acknowledgement meant the input was accepted.
|
||||||
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
||||||
this.setMessages(record.messages, record.turnError);
|
this.setMessages(record.messages, record.turnError);
|
||||||
if (record.turnInFlight) {
|
if (record.turnInFlight) {
|
||||||
await this.resumeStream(false);
|
await this.resumeStream(false);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
|
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
|
||||||
// The call already resolved, or no longer owns the dock. Adopt
|
// The call already resolved, or no longer owns the dock. Adopt
|
||||||
// truth, never POST that answer a second time.
|
// truth, never POST that answer a second time.
|
||||||
this.setStatus("ready");
|
this.setStatus("ready");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
if (this.consumeAbort !== controller || controller.signal.aborted) return false;
|
||||||
this.setError((err as Error).message);
|
this.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
||||||
@@ -697,6 +705,7 @@ export class SessionModel {
|
|||||||
if (optimistic.length > 0) this.mutate(() => {
|
if (optimistic.length > 0) this.mutate(() => {
|
||||||
this.tail = [...this.tail, ...optimistic];
|
this.tail = [...this.tail, ...optimistic];
|
||||||
});
|
});
|
||||||
|
let accepted = false;
|
||||||
try {
|
try {
|
||||||
// SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 —
|
// SUBMITS ARE NOT ABORTABLE BY VIEW TEARDOWN (owner, 2026-10-10 —
|
||||||
// the dead-session-start postmortem): the POST must NOT ride
|
// the dead-session-start postmortem): the POST must NOT ride
|
||||||
@@ -727,6 +736,9 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
throw new Error(message);
|
throw new Error(message);
|
||||||
}
|
}
|
||||||
|
accepted = true;
|
||||||
|
// Opening-message handover ends at acceptance, not stream completion.
|
||||||
|
onAccepted?.();
|
||||||
// CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze
|
// CONNECTION DIET B (owner, 2026-10-10 — parallel-session freeze
|
||||||
// postmortem): the POST acknowledges and closes; there is no stream
|
// postmortem): the POST acknowledges and closes; there is no stream
|
||||||
// on this response to consume. Attach through the SAME reattach wire
|
// on this response to consume. Attach through the SAME reattach wire
|
||||||
@@ -738,13 +750,24 @@ export class SessionModel {
|
|||||||
// A stop() or a newer send/consume won while the ack was in
|
// A stop() or a newer send/consume won while the ack was in
|
||||||
// flight: the turn is live server-side either way — do not attach
|
// flight: the turn is live server-side either way — do not attach
|
||||||
// a stream from a view that is gone or superseded.
|
// a stream from a view that is gone or superseded.
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return true;
|
||||||
await this.resumeStream(true);
|
await this.resumeStream(true);
|
||||||
|
return true;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
const failedOpening = input.type === "text" && onAccepted !== undefined && !accepted;
|
||||||
// Restore the question when acceptance failed or is unknown. Remove
|
if (failedOpening) {
|
||||||
// only THIS optimistic answer, never saved copies/newer live content.
|
// The POST outlives stop(), including StrictMode's cleanup. Remove
|
||||||
// Other input kinds retain their existing optimistic presentation.
|
// only its own failed optimistic bubble even if a newer request
|
||||||
|
// won; never touch canonical messages or another attempt's tail.
|
||||||
|
const ids = new Set(optimistic.map(m => m.id));
|
||||||
|
this.mutate(() => { this.tail = this.tail.filter(m => !ids.has(m.id)); });
|
||||||
|
}
|
||||||
|
const detachedOpening = failedOpening && this.consumeAbort === null && this.latestRequest === controller;
|
||||||
|
if ((this.consumeAbort !== controller || controller.signal.aborted) && !detachedOpening) return accepted;
|
||||||
|
// A detached opening refusal must still reach the replayed mount's
|
||||||
|
// existing setup recovery. A newer send/reattach ALWAYS owns errors
|
||||||
|
// and status instead, even if that request has since been stopped.
|
||||||
|
// Ordinary text and answer teardown behavior stays unchanged.
|
||||||
if (input.type === "answer") {
|
if (input.type === "answer") {
|
||||||
const ids = new Set(optimistic.map(m => m.id));
|
const ids = new Set(optimistic.map(m => m.id));
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
@@ -754,6 +777,10 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
this.setError((err as Error).message);
|
this.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -803,7 +830,7 @@ export class SessionModel {
|
|||||||
* result (fork 1), riding as its input event. */
|
* result (fork 1), riding as its input event. */
|
||||||
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
|
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
|
||||||
void tool;
|
void tool;
|
||||||
return this.send({ type: "answer", toolCallId, answer: output });
|
return this.send({ type: "answer", toolCallId, answer: output }).then(() => undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
|
/** The dismissal path (owner design 2026-10-03, "A2"): the skipped
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
|
|||||||
[model],
|
[model],
|
||||||
);
|
);
|
||||||
|
|
||||||
const send = useCallback((input: ChatInput) => void 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 continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
|
||||||
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
||||||
const submitAnswer = useCallback(
|
const submitAnswer = useCallback(
|
||||||
@@ -129,6 +129,7 @@ export function useChatSession({ chatId, initialMessages, initialError, initiall
|
|||||||
return {
|
return {
|
||||||
messages: state.messages,
|
messages: state.messages,
|
||||||
nativeMessages: state.nativeMessages,
|
nativeMessages: state.nativeMessages,
|
||||||
|
hasConfirmedConversation: state.hasConfirmedConversation,
|
||||||
contextTokens: state.contextTokens,
|
contextTokens: state.contextTokens,
|
||||||
status: state.status,
|
status: state.status,
|
||||||
error: state.error,
|
error: state.error,
|
||||||
|
|||||||
@@ -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<string> {
|
||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
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<T>() {
|
||||||
|
let resolve!: (value: T) => void;
|
||||||
|
let reject!: (reason: unknown) => void;
|
||||||
|
const promise = new Promise<T>((done, fail) => { resolve = done; reject = fail; });
|
||||||
|
return { promise, resolve, reject };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("new-session opening delivery acceptance", () => {
|
||||||
|
it("notifies acceptance before the record read or teacher stream completes", async () => {
|
||||||
|
const record = deferred<Response>();
|
||||||
|
const accepted = deferred<void>();
|
||||||
|
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<Response>();
|
||||||
|
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");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const failure of ["network", "503"] as const) {
|
||||||
|
it(`a teardown racing an opening ${failure} failure restores the bubble/error recovery state`, async () => {
|
||||||
|
const post = deferred<Response>();
|
||||||
|
let notified = 0;
|
||||||
|
const envelope = JSON.stringify({ code: "models_not_configured", missing: ["strong"] });
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||||
|
assert.equal(String(url), "/api/chat", "failure must not attach or resend");
|
||||||
|
return post.promise;
|
||||||
|
} });
|
||||||
|
const sending = m.send({ type: "text", text: "recover this opening" }, () => { notified++; });
|
||||||
|
m.stop(); // StrictMode's cleanup detaches the consume, NOT this POST.
|
||||||
|
if (failure === "network") post.reject(new TypeError("opening connection lost"));
|
||||||
|
else post.resolve(new Response(envelope, { status: 503 }));
|
||||||
|
assert.equal(await sending, false);
|
||||||
|
assert.equal(notified, 0);
|
||||||
|
assert.equal(m.getState().nativeMessages.length, 0, "no stranded optimistic opening bubble");
|
||||||
|
assert.equal(m.status, "error");
|
||||||
|
assert.equal(m.getState().error?.message, failure === "network" ? "opening connection lost" : envelope,
|
||||||
|
"the raw refusal must reach the existing setup-recovery effect");
|
||||||
|
});
|
||||||
|
|
||||||
|
it(`an older opening ${failure} failure removes only its own bubble while a newer send owns state`, async () => {
|
||||||
|
const first = deferred<Response>();
|
||||||
|
const second = deferred<Response>();
|
||||||
|
let posts = 0;
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||||
|
assert.equal(String(url), "/api/chat");
|
||||||
|
return ++posts === 1 ? first.promise : second.promise;
|
||||||
|
} });
|
||||||
|
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||||
|
m.stop();
|
||||||
|
const newer = m.send({ type: "text", text: "newer text" });
|
||||||
|
if (failure === "network") first.reject(new TypeError("older lost acknowledgment"));
|
||||||
|
else first.resolve(new Response("older refusal", { status: 503 }));
|
||||||
|
assert.equal(await older, false);
|
||||||
|
assert.equal(m.status, "submitted", "older failure must not settle the newer send");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
|
||||||
|
second.resolve(new Response("newer failure", { status: 503 }));
|
||||||
|
assert.equal(await newer, false);
|
||||||
|
assert.equal(m.getState().error?.message, "newer failure");
|
||||||
|
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"],
|
||||||
|
"ordinary text retains its existing optimistic failure presentation");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("a newer send remains the owner even after it fails and is stopped", async () => {
|
||||||
|
const first = deferred<Response>();
|
||||||
|
let posts = 0;
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => {
|
||||||
|
return ++posts === 1 ? first.promise : new Response("newer failure", { status: 503 });
|
||||||
|
} });
|
||||||
|
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||||
|
m.stop();
|
||||||
|
await m.send({ type: "text", text: "newer text" });
|
||||||
|
m.stop();
|
||||||
|
first.reject(new TypeError("older failure"));
|
||||||
|
assert.equal(await older, false);
|
||||||
|
assert.equal(m.status, "ready");
|
||||||
|
assert.equal(m.getState().error?.message, "newer failure");
|
||||||
|
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["newer text"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a replacement reattach owns state instead of the older stopped opening POST", async () => {
|
||||||
|
const post = deferred<Response>();
|
||||||
|
const record = deferred<Response>();
|
||||||
|
const saved = newUserMessage("server-owned text");
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||||
|
if (String(url) === "/api/chat") return post.promise;
|
||||||
|
if (String(url).endsWith("/stream")) return new Response("data: [DONE]\n\n");
|
||||||
|
return record.promise;
|
||||||
|
} });
|
||||||
|
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||||
|
m.stop();
|
||||||
|
const replacement = m.resumeStream();
|
||||||
|
post.reject(new TypeError("older failure"));
|
||||||
|
assert.equal(await older, false);
|
||||||
|
assert.equal(m.status, "submitted");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
record.resolve(Response.json({ messages: [saved] }));
|
||||||
|
await replacement;
|
||||||
|
assert.equal(m.status, "ready");
|
||||||
|
assert.deepEqual(m.getState().nativeMessages, [saved]);
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stopped replacement reattach still supersedes the older opening refusal", async () => {
|
||||||
|
const post = deferred<Response>();
|
||||||
|
const record = deferred<Response>();
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async url => {
|
||||||
|
return String(url) === "/api/chat" ? post.promise : record.promise;
|
||||||
|
} });
|
||||||
|
const older = m.send({ type: "text", text: "older opening" }, () => assert.fail("not accepted"));
|
||||||
|
m.stop();
|
||||||
|
const replacement = m.resumeStream();
|
||||||
|
m.stop();
|
||||||
|
post.resolve(new Response("older refusal", { status: 503 }));
|
||||||
|
assert.equal(await older, false);
|
||||||
|
assert.equal(m.status, "ready");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
assert.equal(m.getState().nativeMessages.length, 0);
|
||||||
|
record.resolve(Response.json({ messages: [] }));
|
||||||
|
await replacement;
|
||||||
|
assert.equal(m.status, "ready");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a stopped ordinary text failure retains the pre-existing teardown behavior", async () => {
|
||||||
|
const post = deferred<Response>();
|
||||||
|
const m = new SessionModel({ chatId: "c1", initialMessages: [], fetch: async () => post.promise });
|
||||||
|
const sending = m.send({ type: "text", text: "ordinary text" });
|
||||||
|
m.stop();
|
||||||
|
post.reject(new TypeError("connection lost"));
|
||||||
|
assert.equal(await sending, false);
|
||||||
|
assert.equal(m.status, "ready");
|
||||||
|
assert.equal(m.getState().error, null);
|
||||||
|
assert.deepEqual(m.getState().nativeMessages.map(message => message.content), ["ordinary text"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, string>;
|
||||||
|
let storage: Pick<Storage, "getItem" | "setItem" | "removeItem">;
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -2,9 +2,24 @@ import React from "react";
|
|||||||
(globalThis as Record<string, unknown>).React = React;
|
(globalThis as Record<string, unknown>).React = React;
|
||||||
|
|
||||||
import { renderToStaticMarkup } from "react-dom/server";
|
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 assert from "node:assert/strict";
|
||||||
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
|
import { DraftShell, startDraftSession } from "../components/new-session.tsx";
|
||||||
|
import { clearOpeningPrompt, readOpeningPrompt, OPENING_PROMPT_FAILURE } from "../components/opening-prompt.ts";
|
||||||
|
|
||||||
|
const originalStorage = Object.getOwnPropertyDescriptor(globalThis, "sessionStorage");
|
||||||
|
beforeEach(() => {
|
||||||
|
const data = new Map<string, string>();
|
||||||
|
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"
|
* The sessionless draft view (/chat/new, owner 2026-09-13): "+ New session"
|
||||||
@@ -25,10 +40,10 @@ function fakeFetch(id: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("startDraftSession — the create-on-send contract", () => {
|
describe("startDraftSession — the create-on-send contract", () => {
|
||||||
it("creates the session titled with the first message, returns the prompt redirect", async () => {
|
it("creates the session titled with the first message and returns a short path", async () => {
|
||||||
const { f, calls } = fakeFetch("c_new");
|
const { f, calls } = fakeFetch("c_new");
|
||||||
const target = await startDraftSession("teach me orbital mechanics", f);
|
const target = await startDraftSession("teach me orbital mechanics", f);
|
||||||
assert.equal(target, "/chat/c_new?prompt=teach%20me%20orbital%20mechanics");
|
assert.equal(target, "/chat/c_new");
|
||||||
assert.equal(calls.length, 1);
|
assert.equal(calls.length, 1);
|
||||||
assert.equal(calls[0]!.url, "/api/chats");
|
assert.equal(calls[0]!.url, "/api/chats");
|
||||||
assert.equal(calls[0]!.method, "POST");
|
assert.equal(calls[0]!.method, "POST");
|
||||||
@@ -41,6 +56,37 @@ describe("startDraftSession — the create-on-send contract", () => {
|
|||||||
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
|
assert.equal(calls.length, 1, "a single create call — the birth stamp is server-side");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("a long opening text stays out of the URL and is stashed for the chat page", async () => {
|
||||||
|
const mem = new Map<string, string>();
|
||||||
|
const fake = {
|
||||||
|
getItem: (k: string) => mem.get(k) ?? null,
|
||||||
|
setItem: (k: string, v: string) => {
|
||||||
|
mem.set(k, v);
|
||||||
|
},
|
||||||
|
removeItem: (k: string) => {
|
||||||
|
mem.delete(k);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const holder = globalThis as { sessionStorage?: Storage };
|
||||||
|
const prev = holder.sessionStorage;
|
||||||
|
Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: fake });
|
||||||
|
try {
|
||||||
|
const { f } = fakeFetch("c_long");
|
||||||
|
const text = `${"B2 First grammar item. ".repeat(1500)}end`;
|
||||||
|
const target = await startDraftSession(text, f);
|
||||||
|
assert.equal(target, "/chat/c_long");
|
||||||
|
assert.equal(target!.includes("?"), false);
|
||||||
|
const stashed = readOpeningPrompt("c_long");
|
||||||
|
assert.equal(stashed?.length, text.length);
|
||||||
|
assert.equal(stashed, text);
|
||||||
|
clearOpeningPrompt("c_long");
|
||||||
|
assert.equal(readOpeningPrompt("c_long"), undefined);
|
||||||
|
} finally {
|
||||||
|
if (prev === undefined) delete holder.sessionStorage;
|
||||||
|
else Object.defineProperty(globalThis, "sessionStorage", { configurable: true, value: prev });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("empty or whitespace text never creates anything", async () => {
|
it("empty or whitespace text never creates anything", async () => {
|
||||||
const { f, calls } = fakeFetch("never");
|
const { f, calls } = fakeFetch("never");
|
||||||
assert.equal(await startDraftSession("", f), null);
|
assert.equal(await startDraftSession("", f), null);
|
||||||
@@ -50,6 +96,12 @@ describe("startDraftSession — the create-on-send contract", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("DraftShell — the sessionless surface", () => {
|
describe("DraftShell — the sessionless surface", () => {
|
||||||
|
it("storage failure leaves the full draft editable beside the approved error", () => {
|
||||||
|
const html = renderToStaticMarkup(<DraftShell busy={false} text="keep all my text" onChange={() => {}} onSend={() => {}} startError />);
|
||||||
|
assert.ok(html.includes('role="alert"'));
|
||||||
|
assert.ok(html.includes(OPENING_PROMPT_FAILURE));
|
||||||
|
assert.match(html, /<textarea[^>]*>keep all my text<\/textarea>/);
|
||||||
|
});
|
||||||
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
|
it("renders the composer blob, the stats bar, and the fresh-chat empty state", () => {
|
||||||
const html = renderToStaticMarkup(
|
const html = renderToStaticMarkup(
|
||||||
<DraftShell
|
<DraftShell
|
||||||
@@ -188,7 +240,7 @@ describe("the practice draft (?mode=practice, 2026-10-04)", () => {
|
|||||||
return new Response(JSON.stringify({ id: "c9" }), { status: 201 });
|
return new Response(JSON.stringify({ id: "c9" }), { status: 201 });
|
||||||
}) as unknown as typeof fetch;
|
}) as unknown as typeof fetch;
|
||||||
const target = await startDraftSession("the parity chain", f, "practice");
|
const target = await startDraftSession("the parity chain", f, "practice");
|
||||||
assert.equal(target, "/chat/c9?prompt=the%20parity%20chain");
|
assert.equal(target, "/chat/c9");
|
||||||
assert.deepEqual(body, { title: "the parity chain", kind: "practice" });
|
assert.deepEqual(body, { title: "the parity chain", kind: "practice" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user