Merge bounded session answer and stream recovery

This commit is contained in:
amosblomqvist
2026-10-25 20:46:25 +02:00
21 changed files with 944 additions and 94 deletions
+32 -20
View File
@@ -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}
+26 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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,