Merge bounded session answer and stream recovery
This commit is contained in:
+32
-20
@@ -38,7 +38,6 @@ import {
|
||||
messageAnchorId,
|
||||
textPartMessageId,
|
||||
kbdEventForAction as kbdEventForChat,
|
||||
needsContinuation,
|
||||
type ChatInput,
|
||||
type ChatSettings,
|
||||
type LhMessage,
|
||||
@@ -57,7 +56,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||
import { TopBar } from "@/components/top-bar";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core";
|
||||
import type { LhViewMessage } from "@/src/core";
|
||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||
import {
|
||||
@@ -198,6 +197,7 @@ export function Chat({
|
||||
chatId,
|
||||
title,
|
||||
initialMessages,
|
||||
initialError,
|
||||
initialPrompt,
|
||||
initiallyInFlight,
|
||||
initiallyCompacting = false,
|
||||
@@ -207,6 +207,8 @@ export function Chat({
|
||||
chatId: string;
|
||||
title: string;
|
||||
initialMessages: LhMessage[];
|
||||
/** Memory-only settlement notice supplied on reopening. */
|
||||
initialError?: string;
|
||||
/** The session kind (practice sessions, owner 2026-10-04): practice
|
||||
* renders no phase chip (no probe→teach arc applies) and a Practice
|
||||
* tag rides the header instead. */
|
||||
@@ -236,8 +238,8 @@ 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, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||
const { messages, nativeMessages, 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<HTMLDivElement | null>(null);
|
||||
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
||||
@@ -434,9 +436,7 @@ export function Chat({
|
||||
useEffect(() => {
|
||||
if (mountContinuation.current) return;
|
||||
mountContinuation.current = true;
|
||||
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
|
||||
void send({ type: "continue" });
|
||||
}
|
||||
if (!turnInFlight) continueIfOwed(initialMessages);
|
||||
// mount-only by design (turnInFlight read at initial value)
|
||||
}, []);
|
||||
|
||||
@@ -532,10 +532,12 @@ export function Chat({
|
||||
}, []);
|
||||
const pendingQuestionId = pending?.toolCallId ?? null;
|
||||
useEffect(() => {
|
||||
// A new pending question never inherits the previous one's draft.
|
||||
readingThinkingRef.current = "";
|
||||
setReadingThinking("");
|
||||
}, [pendingQuestionId]);
|
||||
// Different calls never share a draft; a failed submission of THIS
|
||||
// call restores the reading channel alongside the form's snapshot.
|
||||
const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : "";
|
||||
readingThinkingRef.current = reading;
|
||||
setReadingThinking(reading);
|
||||
}, [pendingQuestionId, questionDraft]);
|
||||
|
||||
// The phase at every message (one linear fold): the transcript draws a
|
||||
// color-coded dotted divider at each boundary; the header chip reads the
|
||||
@@ -717,20 +719,20 @@ export function Chat({
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing; turnError?: string };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
setPricing(record.pricing);
|
||||
if (!Array.isArray(record.messages)) return;
|
||||
setMessages(record.messages);
|
||||
setMessages(record.messages, record.turnError);
|
||||
// The continuation seam (contracts.ts protocol §4): a job result
|
||||
// landing in the record leaves the agent owing a continuation — the
|
||||
// record ENDS on un-consumed results, the dock owns no question, and
|
||||
// nothing runs. The native fold (needsContinuation) is the old SDK
|
||||
// lastAssistantMessageIsCompleteWithToolCalls check.
|
||||
if (status === "ready" && needsContinuation(record.messages)) {
|
||||
void send({ type: "continue" });
|
||||
}
|
||||
}, [chatId, setMessages, send, status]);
|
||||
// Current model truth also guards retained/live failures: canonical
|
||||
// sync must never automatically replace a reply that failed to save.
|
||||
continueIfOwed(record.messages);
|
||||
}, [chatId, setMessages, continueIfOwed]);
|
||||
|
||||
// The settled terminal's other half (stream-reattach wave): the
|
||||
// transport resolved turn-settled — no turn running, the record is
|
||||
@@ -905,8 +907,8 @@ export function Chat({
|
||||
// (endTurn emits from onFinish before the response terminates, so the
|
||||
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
||||
// there, it flags instead, and THIS flip consumes the flag: the record
|
||||
// is guaranteed durable (endTurn fires only after persistTurn, the S2
|
||||
// ordering law), so replacing the view onto it absorbs the tail's
|
||||
// reflects durable truth (a failed settlement retains its notice,
|
||||
// not its unsaved reply), so replacing the view absorbs the tail's
|
||||
// streamed c_-id copies into base under their server ids. Without
|
||||
// this the tail kept those copies forever and the next send's
|
||||
// steer-adoption window rendered them a second time. The wake handoff
|
||||
@@ -1596,6 +1598,8 @@ export function Chat({
|
||||
fold) and NEVER overlaps the composer. */}
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
|
||||
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
|
||||
practice={activeProblem}
|
||||
compactionRunning={compacting}
|
||||
onSubmitThinking={submitThinking}
|
||||
@@ -1663,8 +1667,10 @@ export function Chat({
|
||||
// ride the same callback (QuestionAnswerData.dismissed). The
|
||||
// reading-thinking draft rides along (owner 2026-10-03) — the
|
||||
// check answer is its only carrier.
|
||||
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
|
||||
const reading = readingThinkingRef.current.trim() || undefined;
|
||||
clearReadingThinking();
|
||||
// The pending-id effect clears this only when the question
|
||||
// actually leaves. An offline preflight keeps the draft visible.
|
||||
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
|
||||
}}
|
||||
/>
|
||||
@@ -1821,6 +1827,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
||||
|
||||
function DockOverlay({
|
||||
pending,
|
||||
initialQuestionDraft,
|
||||
onSaveQuestionDraft,
|
||||
practice,
|
||||
compactionRunning,
|
||||
onSubmitThinking,
|
||||
@@ -1843,6 +1851,8 @@ function DockOverlay({
|
||||
queuedSteers,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
initialQuestionDraft?: QuestionFormDraft;
|
||||
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
|
||||
/** Compaction running for this chat (round 14) — the docks' submit
|
||||
* affordance gate (the server's registry is the real one). */
|
||||
compactionRunning: boolean;
|
||||
@@ -2309,6 +2319,8 @@ function DockOverlay({
|
||||
phase={phase}
|
||||
onExpand={expandAndReveal}
|
||||
onSubmit={onSubmitAnswer}
|
||||
initialDraft={initialQuestionDraft}
|
||||
onSaveDraft={onSaveQuestionDraft}
|
||||
onEscape={dictationCancel}
|
||||
dictationClaim={!revealPending}
|
||||
compactionRunning={compactionRunning}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type DockSelection,
|
||||
type ExplainToolInput,
|
||||
type QuestionAnswerData,
|
||||
type QuestionFormDraft,
|
||||
type QuestionData,
|
||||
type QuestionMode,
|
||||
type QuizToolInput,
|
||||
@@ -71,6 +72,9 @@ export interface QuestionDockProps {
|
||||
reveal?: boolean;
|
||||
onExpand: () => void;
|
||||
onSubmit: (output: QuestionAnswerData) => void;
|
||||
/** Restore only this call's unconfirmed submission after recovery. */
|
||||
initialDraft?: QuestionFormDraft;
|
||||
onSaveDraft?: (draft: QuestionFormDraft) => void;
|
||||
/** Escape in a question field stops dictation (STT wave) — threaded
|
||||
* from the page that owns the dictation hook. */
|
||||
onEscape?: () => void;
|
||||
@@ -100,6 +104,8 @@ export function QuestionDock({
|
||||
reveal = false,
|
||||
onExpand,
|
||||
onSubmit,
|
||||
initialDraft,
|
||||
onSaveDraft,
|
||||
onEscape,
|
||||
phase = "probe",
|
||||
dictationClaim = true,
|
||||
@@ -155,6 +161,8 @@ export function QuestionDock({
|
||||
phase={phase}
|
||||
active={expanded}
|
||||
onSubmit={onSubmit}
|
||||
initialDraft={initialDraft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onEscape={onEscape}
|
||||
dictationClaim={dictationClaim}
|
||||
compactionRunning={compactionRunning}
|
||||
@@ -250,6 +258,8 @@ function QuestionDockForm({
|
||||
phase = "probe",
|
||||
active,
|
||||
onSubmit,
|
||||
initialDraft,
|
||||
onSaveDraft,
|
||||
onEscape,
|
||||
dictationClaim = true,
|
||||
compactionRunning = false,
|
||||
@@ -311,22 +321,22 @@ function QuestionDockForm({
|
||||
// single-select displaces, multi-select coexists — picking the ask
|
||||
// "Other…" row in a multi-select must never clear already-picked
|
||||
// options, and picking an option must never clear a typed custom answer.
|
||||
const [sel, setSel] = useState<DockSelection>(emptyDockSelection());
|
||||
const [sel, setSel] = useState<DockSelection>(() => initialDraft?.selection ?? emptyDockSelection());
|
||||
// The learner's transfer channel — thinking, never "a note".
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [other, setOther] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
|
||||
const [other, setOther] = useState(initialDraft?.other ?? "");
|
||||
const [text, setText] = useState(initialDraft?.text ?? "");
|
||||
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
||||
* round, owner 2026-10-01): the run's flush lands via setState, which
|
||||
* a closure waiting through `await finishDictation()` cannot observe
|
||||
* in time — submit reads these, kept current at every write site. */
|
||||
const thinkingTextRef = useRef("");
|
||||
const answerTextRef = useRef("");
|
||||
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
|
||||
const answerTextRef = useRef(initialDraft?.text ?? "");
|
||||
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
||||
// resting at 50 — a real coin flip by definition, untouched or
|
||||
// deliberate (the same number, the same meaning). The dock unmounts
|
||||
// between questions, so each question starts fresh at 50.
|
||||
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
|
||||
const [confIdx, setConfIdx] = useState<number>(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX);
|
||||
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||
|
||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
||||
@@ -390,6 +400,15 @@ function QuestionDockForm({
|
||||
// "untouched"); buildAnswer drops it on "I don't know".
|
||||
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
||||
};
|
||||
// Snapshot before optimistic submission unmounts the form. Ref values
|
||||
// include the dictation finals that just landed, unlike stale closures.
|
||||
onSaveDraft?.({
|
||||
selection: { ...sel, selected: new Set(sel.selected) },
|
||||
thinking: thinkingTextRef.current,
|
||||
other,
|
||||
text: answerTextRef.current,
|
||||
confidenceIndex: confIdx,
|
||||
});
|
||||
onSubmit(buildAnswer(qd, req, toolName));
|
||||
};
|
||||
|
||||
|
||||
+173
-28
@@ -17,6 +17,8 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
needsContinuation,
|
||||
danglingQuestionOf,
|
||||
answeredCallIds,
|
||||
measuredContextTokens,
|
||||
newAnswerResult,
|
||||
newNextMessage,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type LhViewMessage,
|
||||
type LiveExecution,
|
||||
type QuestionAnswerData,
|
||||
type QuestionDraft,
|
||||
} from "../src/core";
|
||||
|
||||
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
||||
@@ -78,13 +81,25 @@ async function turnInFlight(model: SessionModel): Promise<boolean | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
|
||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
||||
const STREAM_DROPPED = "The connection dropped before the turn completed.";
|
||||
|
||||
/** Mark transport failures without confusing JSON/application bugs with drops. */
|
||||
class StreamReadError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire events; return true ONLY for the explicit completion marker. */
|
||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>, boolean> {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||
try { chunk = await reader.read(); }
|
||||
catch (err) { throw new StreamReadError(err); }
|
||||
const { done, value } = chunk;
|
||||
if (done) return false;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
for (;;) {
|
||||
const end = buf.indexOf("\n\n");
|
||||
@@ -94,7 +109,7 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
|
||||
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
const data = dataLine.slice("data: ".length);
|
||||
if (data === "[DONE]") return;
|
||||
if (data === "[DONE]") return true;
|
||||
yield JSON.parse(data) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -196,6 +211,8 @@ export function visibleQueuedSteers(
|
||||
export interface SessionModelOptions extends SessionModelDeps {
|
||||
chatId: string;
|
||||
initialMessages: LhMessage[];
|
||||
/** Retained, memory-only server failure when reopening a session. */
|
||||
initialError?: string;
|
||||
/** Fires on every state change (the hook re-renders). */
|
||||
onChange?: () => void;
|
||||
/** The reattach settled terminal (turn-settled): no turn running, the
|
||||
@@ -220,6 +237,9 @@ export class SessionModel {
|
||||
// at every full sync; the write-ahead absorbs the optimistic entries.
|
||||
private base: LhMessage[];
|
||||
private tail: LhMessage[] = [];
|
||||
private questionDrafts = new Map<string, QuestionDraft>();
|
||||
/** Only learner answers submitted through send(), never silent dismissals. */
|
||||
private unconfirmedAnswers = new Map<string, string>();
|
||||
// The live turn's execution layer (view model mid-turn states):
|
||||
private executions = new Map<string, LiveExecution>();
|
||||
private streamingIds = new Set<string>();
|
||||
@@ -229,6 +249,8 @@ export class SessionModel {
|
||||
private statusValue: SessionStatus = "ready";
|
||||
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||
private errorValue: { message: string } | null = null;
|
||||
/** Dismissing a banner must not authorize an automatic replacement turn. */
|
||||
private automaticContinuationBlocked = false;
|
||||
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||
private liveFailureId: string | null = null;
|
||||
// The single live consume (POST response or reattach stream): a newer
|
||||
@@ -240,6 +262,8 @@ export class SessionModel {
|
||||
constructor(opts: SessionModelOptions) {
|
||||
this.chatId = opts.chatId;
|
||||
this.base = opts.initialMessages;
|
||||
this.errorValue = opts.initialError === undefined ? null : { message: opts.initialError };
|
||||
this.automaticContinuationBlocked = opts.initialError !== undefined;
|
||||
// BOUND: the browser's native fetch is a Window method — storing it
|
||||
// unbound makes every call throw "Illegal invocation" in the browser
|
||||
// (Node's undici doesn't care, so the tests never saw it). Always go
|
||||
@@ -300,6 +324,7 @@ export class SessionModel {
|
||||
|
||||
private setError(message: string | null): void {
|
||||
this.errorValue = message === null ? null : { message };
|
||||
if (message !== null) this.automaticContinuationBlocked = true;
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
@@ -346,6 +371,12 @@ export class SessionModel {
|
||||
}
|
||||
const agent = event as unknown as AgentEvent;
|
||||
switch (agent.type) {
|
||||
case "agent_start":
|
||||
// Positive evidence of a new/replayed server-owned turn. Any
|
||||
// later failure in that replay blocks continuation again.
|
||||
this.automaticContinuationBlocked = false;
|
||||
if (this.errorValue !== null) this.setError(null);
|
||||
return false;
|
||||
case "message_start":
|
||||
case "message_update":
|
||||
case "message_end": {
|
||||
@@ -445,18 +476,28 @@ export class SessionModel {
|
||||
|
||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||
* terminal (stream-route only) fires the settle hook and ends. */
|
||||
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
|
||||
async consumeStream(res: Response, controller = this.consumeAbort, allowRecovery = true): Promise<void> {
|
||||
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||
const reader = res.body.getReader();
|
||||
let sawEvent = false;
|
||||
let sawDone = false;
|
||||
try {
|
||||
for await (const event of sseEvents(reader)) {
|
||||
const events = sseEvents(reader);
|
||||
for (;;) {
|
||||
let next: IteratorResult<Record<string, unknown>, boolean>;
|
||||
try { next = await events.next(); }
|
||||
catch (err) {
|
||||
if (err instanceof StreamReadError) break;
|
||||
throw err;
|
||||
}
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (next.done) { sawDone = next.value; break; }
|
||||
const event = next.value;
|
||||
if (event.type === "turn-settled") {
|
||||
// The raced-settle terminal: no turn to attach to — the
|
||||
// record is current; the settle hook refetches.
|
||||
await reader.cancel().catch(() => {});
|
||||
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
||||
sawDone = true;
|
||||
this.onReattachSettled?.();
|
||||
return;
|
||||
}
|
||||
const acted = this.applyWireEvent(event);
|
||||
@@ -466,14 +507,32 @@ export class SessionModel {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
// A cancelled consume may finish after its replacement has entered
|
||||
// Thinking. Only the current owner can settle shared state.
|
||||
if (this.consumeAbort === controller) {
|
||||
this.streamingIds = new Set();
|
||||
this.executions = new Map();
|
||||
if (this.statusValue !== "error") this.setStatus("ready");
|
||||
if (sawDone && this.statusValue !== "error") this.setStatus("ready");
|
||||
}
|
||||
}
|
||||
if (this.consumeAbort !== controller || sawDone) return;
|
||||
if (!allowRecovery) throw new StreamReadError(new Error(STREAM_DROPPED));
|
||||
// One read-only recovery: refresh the canonical base, then replay the
|
||||
// SAME server-owned turn. Never resend the learner's input.
|
||||
await this.resumeStream(true, false);
|
||||
}
|
||||
|
||||
private async fetchView(url: string, signal: AbortSignal, recoveringDrop: boolean): Promise<Response> {
|
||||
try {
|
||||
const res = await this.fetch(url, { signal });
|
||||
if (recoveringDrop && !res.ok) throw new Error((await res.text()) || STREAM_DROPPED);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (recoveringDrop && !signal.aborted) throw new StreamReadError(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +548,7 @@ export class SessionModel {
|
||||
* lands on top. The stream then replays the turn from its first event
|
||||
* through the SAME consume path.
|
||||
*/
|
||||
async resumeStream(refetchRecordFirst = true): Promise<void> {
|
||||
async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise<void> {
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
@@ -519,16 +578,27 @@ export class SessionModel {
|
||||
this.setStatus("submitted");
|
||||
try {
|
||||
if (refetchRecordFirst) {
|
||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery);
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (recordRes.ok) {
|
||||
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
||||
let text: string;
|
||||
try { text = await recordRes.text(); }
|
||||
catch (err) {
|
||||
if (!allowRecovery && !controller.signal.aborted) throw new StreamReadError(err);
|
||||
throw err;
|
||||
}
|
||||
// Parse outside the transport catch: malformed JSON is a
|
||||
// contract bug, not a connection-drop diagnostic.
|
||||
const record = JSON.parse(text) as { messages?: LhMessage[]; turnError?: string };
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (Array.isArray(record.messages)) {
|
||||
this.mutate(() => {
|
||||
this.base = record.messages!;
|
||||
if (record.turnError !== undefined) {
|
||||
this.errorValue = { message: record.turnError };
|
||||
this.automaticContinuationBlocked = true;
|
||||
}
|
||||
this.forgetResolvedDrafts(record.messages!);
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
});
|
||||
@@ -543,15 +613,19 @@ export class SessionModel {
|
||||
});
|
||||
}
|
||||
}
|
||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery);
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||
await this.consumeStream(res, controller);
|
||||
await this.consumeStream(res, controller, allowRecovery);
|
||||
} catch (err) {
|
||||
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
||||
this.setError((err as Error).message);
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled
|
||||
// A failed record read can leave the optimistic answer in the tail.
|
||||
// Restore its Submit affordance; the next attempt reconciles first.
|
||||
this.mutate(() => {
|
||||
this.tail = this.tail.filter(m => !this.unconfirmedAnswers.has(m.id));
|
||||
this.unconfirmedAnswers.clear();
|
||||
});
|
||||
this.setError(!allowRecovery && err instanceof StreamReadError ? STREAM_DROPPED : (err as Error).message);
|
||||
this.setStatus("error");
|
||||
}
|
||||
}
|
||||
@@ -563,12 +637,42 @@ export class SessionModel {
|
||||
* server's 409 + identity retry covers the wake-race window).
|
||||
*/
|
||||
async send(input: ChatInput): Promise<void> {
|
||||
if (!this.readyRef) return;
|
||||
const recovering = this.statusValue === "error";
|
||||
if (!this.readyRef && !recovering) return;
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
this.setError(null);
|
||||
this.setStatus("submitted");
|
||||
if (recovering && input.type === "answer") {
|
||||
try {
|
||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
||||
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
||||
if (this.consumeAbort !== controller) return;
|
||||
// No verified record/liveness: retain the draft and never guess
|
||||
// whether a lost acknowledgement meant the input was accepted.
|
||||
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
||||
this.setMessages(record.messages, record.turnError);
|
||||
if (record.turnInFlight) {
|
||||
await this.resumeStream(false);
|
||||
return;
|
||||
}
|
||||
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
|
||||
// truth, never POST that answer a second time.
|
||||
this.setStatus("ready");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
||||
// The optimistic appends — exactly the messages the server's
|
||||
// write-ahead will persist (inputAppends), with LOCAL ids so
|
||||
// adoption can tell them from the record's absorbed copies.
|
||||
@@ -585,7 +689,9 @@ export class SessionModel {
|
||||
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||
} else if (input.type === "answer") {
|
||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||
const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() };
|
||||
optimistic.push(result);
|
||||
this.unconfirmedAnswers.set(result.id, input.toolCallId);
|
||||
}
|
||||
if (optimistic.length > 0) this.mutate(() => {
|
||||
this.tail = [...this.tail, ...optimistic];
|
||||
@@ -605,9 +711,7 @@ export class SessionModel {
|
||||
// connection is Diet A's whole job.
|
||||
const res = await this.postInputEvent(input);
|
||||
if (res.status === 409) {
|
||||
this.setError("A turn is already running — try again in a moment.");
|
||||
this.setStatus("error");
|
||||
return;
|
||||
throw new Error("A turn is already running — try again in a moment.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
||||
@@ -629,7 +733,17 @@ export class SessionModel {
|
||||
if (this.consumeAbort !== controller) return;
|
||||
await this.resumeStream(true);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
// 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") {
|
||||
const ids = new Set(optimistic.map(m => m.id));
|
||||
this.mutate(() => {
|
||||
this.tail = this.tail.filter(m => !ids.has(m.id));
|
||||
for (const id of ids) this.unconfirmedAnswers.delete(id);
|
||||
});
|
||||
}
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
}
|
||||
@@ -740,10 +854,32 @@ export class SessionModel {
|
||||
this.setStatus("ready");
|
||||
}
|
||||
|
||||
/** Submission snapshots outlive the dock, not the session mount. */
|
||||
questionDraft(toolCallId: string): QuestionDraft | undefined {
|
||||
return this.questionDrafts.get(toolCallId);
|
||||
}
|
||||
|
||||
saveQuestionDraft(toolCallId: string, draft: Partial<QuestionDraft>): void {
|
||||
this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft });
|
||||
}
|
||||
|
||||
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
|
||||
const answered = answeredCallIds(record);
|
||||
for (const id of answered) this.questionDrafts.delete(id);
|
||||
for (const [id, callId] of this.unconfirmedAnswers) {
|
||||
if (answered.has(callId)) this.unconfirmedAnswers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Full snapshot swap (the idle settle sync — server canon). */
|
||||
setMessages(record: LhMessage[]): void {
|
||||
setMessages(record: LhMessage[], turnError?: string): void {
|
||||
this.mutate(() => {
|
||||
this.base = record;
|
||||
if (turnError !== undefined) {
|
||||
this.errorValue = { message: turnError };
|
||||
this.automaticContinuationBlocked = true;
|
||||
}
|
||||
this.forgetResolvedDrafts(record);
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||
@@ -763,10 +899,19 @@ export class SessionModel {
|
||||
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
||||
this.mutate(() => {
|
||||
this.base = record;
|
||||
this.forgetResolvedDrafts(record);
|
||||
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
||||
});
|
||||
}
|
||||
|
||||
/** Automatic mount/sync continuation is never permission to retry a
|
||||
* failed turn. Read current model truth, not a stale React closure. */
|
||||
async continueIfOwed(record: readonly LhMessage[]): Promise<void> {
|
||||
if (this.statusValue === "ready" && !this.automaticContinuationBlocked && this.getState().error === null && needsContinuation(record)) {
|
||||
await this.send({ type: "continue" });
|
||||
}
|
||||
}
|
||||
|
||||
/** The mount/sync continuation fold (native-messages.ts). */
|
||||
static needsContinuation = needsContinuation;
|
||||
|
||||
|
||||
+11
-3
@@ -22,16 +22,18 @@ export interface UseChatSessionOptions {
|
||||
chatId: string;
|
||||
/** The native record seed (server-rendered page or a full sync). */
|
||||
initialMessages: LhMessage[];
|
||||
/** Transient settlement failure retained by the server, not the transcript. */
|
||||
initialError?: string;
|
||||
/** Reattach on mount: a turn is running server-side with no live view. */
|
||||
initiallyInFlight?: boolean;
|
||||
}
|
||||
|
||||
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
|
||||
export function useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }: UseChatSessionOptions) {
|
||||
// The model is per-mount (chatId-keyed component); onChange forces the
|
||||
// re-render. The ref pattern keeps it stable across renders.
|
||||
const modelRef = useRef<SessionModel | null>(null);
|
||||
if (modelRef.current === null) {
|
||||
modelRef.current = new SessionModel({ chatId, initialMessages });
|
||||
modelRef.current = new SessionModel({ chatId, initialMessages, initialError });
|
||||
}
|
||||
const model = modelRef.current;
|
||||
// The render VERSION — bumped by every model change, so the state memo
|
||||
@@ -101,6 +103,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
);
|
||||
|
||||
const send = useCallback((input: ChatInput) => void model.send(input), [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(
|
||||
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||
@@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
||||
[model],
|
||||
);
|
||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||
const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]);
|
||||
const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(toolCallId, draft), [model]);
|
||||
const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]);
|
||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||
const stop = useCallback(() => model.stop(), [model]);
|
||||
const clearError = useCallback(() => model.clearError(), [model]);
|
||||
@@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
error: state.error,
|
||||
steerQueue: state.steerQueue,
|
||||
send,
|
||||
continueIfOwed,
|
||||
steer,
|
||||
submitAnswer,
|
||||
dismissQuestion,
|
||||
dismissProblem,
|
||||
questionDraft,
|
||||
saveQuestionDraft,
|
||||
setMessages,
|
||||
adoptRecord,
|
||||
stop,
|
||||
|
||||
Reference in New Issue
Block a user