Wave D: native AgentEvent wire + client session rewrite (SDK purged)

Server: ui-stream.ts is verbatim pi AgentEvent passthrough (SSE data
lines + [DONE]); only app-level events are the loop-level error and
turn-settled terminal; onError carries raw provider text (owner ruling).

Client: useChat + the AI-SDK transport replaced by components/session-core.ts
(SessionModel: 409 identity-retry, optimistic c_ appends, absorbed()
adoption, needsContinuation, shared consume for POST + reattach) and the
session.ts hook; the golden-master grouping canon moved client-side into
core/view.ts (projectView); scanners read the native record (phaseAt3Lh,
danglingQuestionOf, graph toolParts, plan-blocks, runner, extraction).

Coverage: the native message is the unit (fork 3) with the call+result
inseparability bond rule; view-line-less messages (markers, error
results) are invisible to coverage and never checkpointed.

Deletes: chat-transport, projection, ui-message, their tests (ported to
tests/session-core.test.ts), and the ai + @ai-sdk/react dependencies —
the Vercel SDK is fully out of the tree. Wire pins in chat-flow rewritten
onto the AgentEvent dialect (eventsOf parser).

Gates: tsc clean, 994/994 tests, eslint clean, build green; live smoke
on PORT=3004 (verbatim wire → native record).
This commit is contained in:
amosblomqvist
2026-09-26 01:52:47 +03:00
parent 7c0e8d9a50
commit f3003d357a
44 changed files with 2299 additions and 2205 deletions
+7 -7
View File
@@ -2,7 +2,7 @@
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { UIMessage } from "ai";
import type { LhViewMessage, LhViewPart } from "@/src/core";
import { ArrowDownIcon, DownloadIcon } from "lucide-react";
import type { ComponentProps } from "react";
import { useCallback } from "react";
@@ -100,7 +100,7 @@ export const ConversationScrollButton = ({
);
};
const getMessageText = (message: UIMessage): string =>
const getMessageText = (message: LhViewMessage): string =>
message.parts
.filter((part) => part.type === "text")
.map((part) => part.text)
@@ -110,21 +110,21 @@ export type ConversationDownloadProps = Omit<
ComponentProps<typeof Button>,
"onClick"
> & {
messages: UIMessage[];
messages: LhViewMessage[];
filename?: string;
formatMessage?: (message: UIMessage, index: number) => string;
formatMessage?: (message: LhViewMessage, index: number) => string;
};
const defaultFormatMessage = (message: UIMessage): string => {
const defaultFormatMessage = (message: LhViewMessage): string => {
const roleLabel =
message.role.charAt(0).toUpperCase() + message.role.slice(1);
return `**${roleLabel}:** ${getMessageText(message)}`;
};
export const messagesToMarkdown = (
messages: UIMessage[],
messages: LhViewMessage[],
formatMessage: (
message: UIMessage,
message: LhViewMessage,
index: number
) => string = defaultFormatMessage
): string => messages.map((msg, i) => formatMessage(msg, i)).join("\n\n");
+3 -2
View File
@@ -15,9 +15,10 @@ import { cn } from "@/lib/utils";
import { cjk } from "@streamdown/cjk";
import { code } from "@streamdown/code";
import { createMathPlugin } from "@streamdown/math";
import type { UIMessage } from "ai";
import { ChevronLeftIcon, ChevronRightIcon } from "lucide-react";
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
import type { LhViewMessage } from "@/src/core";
import {
createContext,
memo,
@@ -36,7 +37,7 @@ import { mdComponents } from "@/components/md-blocks";
import { transformMathDelimiters } from "@/src/core";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
from: LhViewMessage["role"];
};
export const Message = ({ className, from, ...props }: MessageProps) => (
+3 -2
View File
@@ -1,7 +1,8 @@
"use client";
import { CheckIcon, CircleX } from "lucide-react";
import type { PdfResult, ResearchResult, SubagentArtifactData, TeacherUIMessage } from "@/src/core";
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { isSubagentFailure } from "@/src/core";
import { DemoOutputCard } from "@/components/artifacts/demo-card";
import { PdfBriefCard, ResearchBriefCard } from "@/components/artifacts/job-card";
@@ -24,7 +25,7 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
* The sub-agent's verification notes are model-facing glue and do NOT
* render; only a failure note does.
*/
export function SubagentSteerCard({ message }: { message: TeacherUIMessage }) {
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined;
// The abort steer (2026-09 dock semantics): its message-type part is
-310
View File
@@ -1,310 +0,0 @@
/**
* Chat transport for the INPUT-EVENTS contract (Wave B, owner ruling
* 2026-09-26): the client never re-POSTs its transcript — every send
* carries ONE explicit input event ({text} | {answer} | {verdict} |
* {continue}) stashed by the call site (chat.tsx) before invoking the SDK
* send. The server owns the record; this transport's ONLY send-time job is
* to consume the stash and put it on the wire as {chatId, input}.
*
* 409 retry (the wake-pipeline race, contracts/jobs.ts "THE ASYNC
* PROTOCOL"): a send can race the server's wake turn (the client only
* notices it via the /api/jobs poll up to a second later) and gets
* 409'd. Under input events the retry is IDENTITY: the event was never
* persisted (the write-ahead only happens once the turn is accepted), so
* waiting for the in-flight turn and re-POSTing the SAME body is safe —
* no merge, no stale-list clobber. The old UIMessage-era merge logic is
* deleted with the UIMessage record (the server now owns the
* record; a lost race means "try again later", not "merge transcripts").
*
* The send path is LOUD about a missing stash: every SDK send must map to
* exactly one event — a send with nothing stashed is a call-site bug
* (it would otherwise silently re-send the previous event), so the
* transport throws instead of guessing.
*/
import { DefaultChatTransport } from "ai";
import type { UIMessageChunk } from "ai";
import type { ChatTransport } from "ai";
import type { ChatInput } from "@/src/core";
import type { TeacherUIMessage } from "@/src/core";
/** How often to poll turnInFlight while the racing turn runs. */
const TURN_POLL_MS = 500;
/** Give up waiting (a wake turn can run long) and surface the 409 as before. */
const TURN_WAIT_MAX_MS = 30_000;
/** Injectable timing (tests). */
export interface RetryFetchOptions {
pollMs?: number;
waitMaxMs?: number;
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener("abort", () => {
clearTimeout(t);
reject(new DOMException("Aborted", "AbortError"));
}, { once: true });
});
}
async function turnInFlight(chatId: string): Promise<boolean | null> {
try {
const res = await fetch(`/api/jobs?chatId=${encodeURIComponent(chatId)}`);
if (!res.ok) return null;
return ((await res.json()) as { turnInFlight?: boolean }).turnInFlight === true;
} catch {
return null; // network hiccup: keep waiting, the deadline bounds us
}
}
/**
* A fetch that turns a 409 on POST /api/chat into: wait for the running
* turn, re-POST the SAME event body. Non-409 responses pass through
* untouched.
*/
/** Exported for tests. */
export function retryingFetch(chatId: string, opts: RetryFetchOptions = {}): typeof globalThis.fetch {
const pollMs = opts.pollMs ?? TURN_POLL_MS;
const waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
return async (input, init) => {
let response = await fetch(input, init);
let waited = 0;
while (response.status === 409) {
// Free the 409 body's socket before waiting on the racing turn.
await response.body?.cancel().catch(() => {});
if (init?.signal?.aborted || waited >= waitMaxMs) return response;
await delay(pollMs, init?.signal ?? undefined);
waited += pollMs;
if (init?.signal?.aborted) return response;
if ((await turnInFlight(chatId)) !== false) continue;
// Turn finished: re-POST the same input event — nothing merged,
// nothing lost (the event was never persisted pre-acceptance).
response = await fetch(input, init); // may 409 again → loop
}
return response;
};
}
/* ────────────────────────────────────────────────────────────────────────
* THE REATTACH TRANSPORT (background-turn runner wave, owner ruling
* 2026-09-25): a turn runs server-side decoupled from every view, and a
* view that arrives late (navigate away mid-turn, wake-turn discovery)
* re-enters it through the SDK's OWN resume path — useChat's
* resumeStream() → reconnectToStream() — so the replay burst and the
* live deltas flow through the same processUIMessageStream assembly as
* first-subscriber streaming. No second rendering dialect exists.
*
* Three adapters over the platform contract, all in this file:
*
* 1. ENDPOINT: the SDK's default reconnect GETs /api/chat/{id}/stream;
* ours is GET /api/chats/[id]/stream (same chunk dialect).
* 2. THE SETTLED TERMINAL: "no turn running" is a terminal EVENT
* (turn-settled + [DONE]), not a 204 — one wire protocol end to end.
* The first SSE event is peeked; turn-settled cancels the body, fires
* onSettled (the Chat clears its in-flight flag and refetches the
* record), and resolves null — the SDK's "no active stream" signal
* (it sets status back to ready, no stream consumed).
* 3. THE CONTINUATION SEAM: the SDK seeds its resume streaming message
* EMPTY (the resume trigger passes lastMessage: undefined), so a
* reattached turn that CONTINUES the seeded last assistant message
* (answer-quiz reaction, wake continuation) would hit the SDK's
* replace-last-message path (start id === last id) and drop that
* message's parts for the whole stream — the answered-quiz card
* would flicker out until the settle sync restores it. When the
* peeked start chunk's messageId equals the seeded last message's id,
* rewrite it to a fresh id: this run's parts mount as their own
* message while the seeded one keeps rendering — pixel-identical to
* the live continuation (card above, reaction below) — and the settle
* syncFromServer lands the server's single assembled message (the
* server is the transcript's canon; the split never persists).
* Part ids stay server-prefixed; nothing keys parts by message id.
* ──────────────────────────────────────────────────────────────────────── */
/** The reattach half of the transport contract (chat.tsx wires the refs). */
export interface ReattachOptions {
/** turn-settled — the stream endpoint found no running turn (raced
* settle, or none ever started): the record is current; the caller
* refetches it and clears its in-flight flag. */
onSettled?: () => void;
/** The LIVE last message id of the seeded transcript (a Chat ref) —
* the continuation detection above. */
lastMessageId?: () => string | undefined;
}
/** The input-events half (Wave B): consume the call site's stashed event.
* Exactly one event per SDK send — the ref is cleared on read, so a
* send with nothing stashed fails LOUDLY rather than re-sending the
* previous event. */
export interface InputEventOptions {
takeInput: () => ChatInput | undefined;
}
/** Concatenate byte payloads (the head stream's event + read-past remainder). */
function concatBytes(a: Uint8Array, b: Uint8Array): Uint8Array {
const out = new Uint8Array(a.length + b.length);
out.set(a);
out.set(b, a.length);
return out;
}
/** Same id shape the server's runPiTurn mints — collision-free by design. */
function freshMessageId(): string {
return `msg_${Date.now().toString(36)}_${crypto.randomUUID().slice(0, 8)}`;
}
/** One parsed SSE event off the wire (data: <json>\n\n framing). */
interface SseEvent {
/** The raw bytes of this event, re-injectable into the head stream. */
raw: Uint8Array;
/** Bytes read PAST the event boundary (the next event can arrive in
* the same read) — they must ride the head of the tail verbatim. */
rest: Uint8Array;
/** The parsed JSON payload (events here are always single data lines). */
data: Record<string, unknown>;
}
/**
* Read the FIRST complete SSE event off a live body (the settled terminal
* and the start chunk are both single small events; a long-lived turn's
* log replays after). Resolves as soon as one \n\n boundary is seen — the
* caller re-injects the raw bytes (plus any read-past remainder) ahead of
* the unconsumed tail, so nothing is lost or reordered.
*/
async function readFirstEvent(reader: ReadableStreamDefaultReader<Uint8Array>): Promise<SseEvent> {
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) throw new Error("The reattach stream ended before its first event.");
buf += decoder.decode(value, { stream: true });
const end = buf.indexOf("\n\n");
if (end === -1) continue;
const event = buf.slice(0, end);
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
if (!dataLine) throw new Error("The reattach stream's first event is malformed.");
return {
raw: new TextEncoder().encode(`${event}\n\n`),
rest: new TextEncoder().encode(buf.slice(end + 2)),
data: JSON.parse(dataLine.slice("data: ".length)) as Record<string, unknown>,
};
}
}
/** The reattach transport: the input-event POST for sends, our seam for resumes. */
class ReattachingChatTransport extends DefaultChatTransport<TeacherUIMessage> {
constructor(
private readonly reattach: ReattachOptions,
private readonly inputEvent: InputEventOptions,
init: ConstructorParameters<typeof DefaultChatTransport<TeacherUIMessage>>[0],
) {
super(init);
}
override async reconnectToStream(
options: Parameters<ChatTransport<TeacherUIMessage>["reconnectToStream"]>[0],
): Promise<ReadableStream<UIMessageChunk> | null> {
const res = await fetch(`/api/chats/${encodeURIComponent(options.chatId)}/stream`, {
signal: options.abortSignal,
});
if (res.status === 204) return null; // the SDK's native no-stream signal
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
if (!res.body) throw new Error("The reattach response body is empty.");
const reader = res.body.getReader();
let first: SseEvent;
try {
first = await readFirstEvent(reader);
} catch (err) {
void reader.cancel().catch(() => {});
throw err;
}
// The settled terminal: nothing to attach to — the record is current.
if (first.data.type === "turn-settled") {
await reader.cancel().catch(() => {});
this.reattach.onSettled?.();
return null;
}
// The continuation seam (see the block comment): a start chunk whose
// id matches the seeded last message would REPLACE that message with
// an empty streaming state — rewrite to a fresh id so this run mounts
// beside it and the settle sync merges the canon back.
let firstEvent = first.raw;
if (
first.data.type === "start" &&
typeof first.data.messageId === "string" &&
first.data.messageId === this.reattach.lastMessageId?.()
) {
firstEvent = new TextEncoder().encode(
`data: ${JSON.stringify({ ...first.data, messageId: freshMessageId() })}\n\n`,
);
}
// The head bytes: the (possibly rewritten) first event plus anything
// the peek read past the boundary — then the live tail, untouched.
const head = first.rest.length > 0 ? concatBytes(firstEvent, first.rest) : firstEvent;
// Re-inject the (possibly rewritten) head ahead of the live tail and
// hand the SDK the SAME SSE parse the POST response goes through.
const tail = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(head);
void (async () => {
try {
for (;;) {
const { done, value } = await reader.read();
if (done) {
controller.close();
return;
}
controller.enqueue(value);
}
} catch (err) {
controller.error(err);
}
})();
},
cancel() {
return reader.cancel();
},
});
return this.processResponseStream(tail);
}
}
/**
* The chat.tsx wiring: `transport: createReattachingTransport(chatId, refs)`.
* Sends POST the stashed input event with the 409 identity-retry fetch;
* resumes take the adapters above.
*/
export function createReattachingTransport(
chatId: string,
reattach: ReattachOptions,
inputEvent: InputEventOptions,
): DefaultChatTransport<TeacherUIMessage> {
return new ReattachingChatTransport(reattach, inputEvent, {
api: "/api/chat",
body: { chatId },
fetch: retryingFetch(chatId),
// THE INPUT-EVENTS CONTRACT (Wave B): replace the SDK's full-transcript
// body with {chatId, input}. The stash is consumed HERE — exactly once
// per SDK send (the 409 retry re-POSTs the built body, not the stash);
// a send with nothing stashed fails LOUDLY (a call-site bug — it must
// never silently re-send the previous event).
prepareSendMessagesRequest: () => {
const input = inputEvent.takeInput();
if (!input) {
throw new Error(
"Send without a stashed input event — every send call site must set pendingInput before invoking the SDK send (Wave B input-events contract).",
);
}
return { body: { chatId, input } };
},
});
}
+58 -135
View File
@@ -1,9 +1,7 @@
"use client";
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createReattachingTransport } from "@/components/chat-transport";
import { useChatSession } from "@/components/session";
import { SetupDialog } from "@/components/setup-dialog";
import { Hint } from "@/components/ui/hint";
import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
@@ -26,7 +24,7 @@ import { DictationBar, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import { useActivity } from "@/components/activity-dock";
import { isProviderKeyRefusal, mergeAdopted, type ChatInput, type ChatSettings, type ModelSlotName } from "@/src/core";
import { isProviderKeyRefusal, needsContinuation, type ChatInput, type ChatSettings, type LhMessage, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
@@ -34,7 +32,8 @@ import { WaypointsIcon } from "lucide-react";
import type { PlanBlockData } from "@/src/core";
import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TeacherUIMessage, TurnUsage } from "@/src/core";
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage } from "@/src/core";
import type { LhViewMessage } from "@/src/core";
import { PhaseChip, PhaseDivider, planNodeCount } from "@/components/phase-divider";
import { CoverageRing } from "@/components/extraction-coverage";
import { findPendingToolCall, phaseAt3, phaseTimeline, streamingToolActivity } from "@/src/core";
@@ -141,7 +140,7 @@ function SystemLine({ children, testId }: { children: React.ReactNode; testId?:
);
}
function isSubagentSteer(m: TeacherUIMessage): boolean {
function isSubagentSteer(m: LhViewMessage): boolean {
return (m.metadata as { subagentResult?: unknown } | undefined)?.subagentResult != null;
}
@@ -164,7 +163,7 @@ export function Chat({
}: {
chatId: string;
title: string;
initialMessages: TeacherUIMessage[];
initialMessages: LhMessage[];
initialPrompt?: string;
/** A teacher turn is running server-side but attached to no client
* (navigated away mid-turn, returned while in flight). */
@@ -174,61 +173,31 @@ export function Chat({
* only decides whether the pill rides the composer. */
initialConfigured?: boolean;
}) {
// Reattach plumbing (background-turn runner wave): the transport holds
// two stable refs — the settled terminal's callback (assigned below,
// once syncFromServer exists) and the LIVE last-message id (the
// continuation seam — see chat-transport.ts). Refs, not closures, so
// the per-render transport recreation can never capture a stale one.
// Reattach plumbing (background-turn runner wave): the hook holds the
// settled terminal's callback — a stable ref assigned below, once
// syncFromServer exists (refs, not closures: no stale capture).
const reattachSettledRef = useRef<() => void>(() => {});
const reattachLastIdRef = useRef<string | undefined>(undefined);
// THE INPUT STASH (Wave B input-events contract, owner ruling
// 2026-09-26): every SDK send maps to EXACTLY ONE explicit input event —
// the call site stashes the event here before invoking the SDK send, and
// the transport consumes it when building the POST body ({chatId, input};
// the client transcript never rides the wire). Consumed-on-read: a send
// with nothing stashed fails LOUDLY in the transport (a call-site bug —
// it must never silently re-send the previous event).
const pendingInputRef = useRef<ChatInput | null>(null);
const stashInput = (input: ChatInput) => {
pendingInputRef.current = input;
};
const takeInput = () => {
const input = pendingInputRef.current;
pendingInputRef.current = null;
return input ?? undefined;
};
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop, clearError, resumeStream } =
useChat<TeacherUIMessage>({
id: chatId,
messages: initialMessages,
transport: createReattachingTransport(
chatId,
{
onSettled: () => reattachSettledRef.current(),
lastMessageId: () => reattachLastIdRef.current,
},
{ takeInput },
),
// After the learner answers (addToolOutput), automatically send the
// continuation request so the teacher reacts to the answer.
sendAutomaticallyWhen: lastAssistantMessageIsCompleteWithToolCalls,
});
// THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the app-owned
// replacement for useChat + the AI-SDK transport. The record is native
// history; the VIEW (grouped, golden-master) is derived client-side
// (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, send, submitAnswer, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
useChatSession({ chatId, initialMessages, initiallyInFlight });
// The reattach settled terminal → the sync-and-continue seam (below).
setReattachSettled(() => reattachSettledRef.current());
// New chat with a topic: send the first message exactly once.
const sentInitial = useRef(false);
useEffect(() => {
if (!sentInitial.current && initialPrompt && initialMessages.length === 0) {
sentInitial.current = true;
stashInput({ type: "text", text: initialPrompt });
void sendMessage({ text: initialPrompt });
void send({ type: "text", text: initialPrompt });
}
}, [initialPrompt, initialMessages.length, sendMessage]);
// The reattach seam's live half: the transport compares the stream's
// start id against the transcript's CURRENT last message — the
// continuation rewrite keys on it (chat-transport.ts, seam 3).
reattachLastIdRef.current = messages.at(-1)?.id;
}, [initialPrompt, initialMessages.length, send]);
// Reattach on mount (stream-reattach wave): a turn that outlived its
// client (navigate away mid-turn, back while in flight) is re-entered
@@ -358,14 +327,8 @@ export function Chat({
useEffect(() => {
if (mountContinuation.current) return;
mountContinuation.current = true;
if (
!turnInFlight &&
status === "ready" &&
messages.length > 0 &&
lastAssistantMessageIsCompleteWithToolCalls({ messages })
) {
stashInput({ type: "continue" });
void sendMessage();
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
void send({ type: "continue" });
}
// mount-only by design (turnInFlight read at initial value)
}, []);
@@ -469,7 +432,7 @@ export function Chat({
void (async () => {
const res = await fetch(`/api/chats/${encodeURIComponent(chatId)}`);
if (!cancelled && res.ok) {
const record = (await res.json().catch(() => null)) as { messages?: TeacherUIMessage[] } | null;
const record = (await res.json().catch(() => null)) as { messages?: LhMessage[] } | null;
if (Array.isArray(record?.messages)) setMessages(record.messages);
}
if (cancelled) return;
@@ -502,22 +465,19 @@ 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?: TeacherUIMessage[]; turns?: TurnUsage[] };
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[] };
if (Array.isArray(record.turns)) setTurns(record.turns);
if (!Array.isArray(record.messages)) return;
setMessages(record.messages);
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
// it inside addToolOutput) — without this the teacher would stay silent
// after a job result lands. sendMessage() with no args re-requests with
// the current list: the continuation. (contracts.ts protocol §4.)
if (
status === "ready" &&
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
) {
stashInput({ type: "continue" });
void sendMessage();
// 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, sendMessage, status]);
}, [chatId, setMessages, send, status]);
// The settled terminal's other half (stream-reattach wave): the
// transport resolved turn-settled — no turn running, the record is
@@ -536,23 +496,20 @@ export function Chat({
// nothing about a running turn should hide it. The full snapshot sync
// mid-stream is forbidden (it breaks the SDK's in-place continuation
// merge — the stale-snapshot lesson), so adoption here is APPEND-ONLY
// (core mergeAdopted): insert the record's new ids in their canonical
// (the session hook's adoptRecord): insert the record's new ids in their canonical
// order, never rewrite a local one, keep the in-flight tail last. The
// full syncFromServer stays the net at idle (reorder, plan blocks, turns).
const messagesRef = useRef(messages);
messagesRef.current = messages;
const adoptAppendOnly = useCallback(async () => {
try {
const res = await fetch(`/api/chats/${chatId}`);
if (!res.ok) return;
const record = (await res.json()) as { messages?: TeacherUIMessage[] };
const record = (await res.json()) as { messages?: LhMessage[] };
if (!Array.isArray(record.messages)) return;
const merged = mergeAdopted(messagesRef.current, record.messages);
if (merged) setMessages(merged);
adoptRecord(record.messages);
} catch {
/* transient — the idle full sync is the net */
}
}, [chatId, setMessages]);
}, [chatId, adoptRecord]);
// Turn-start window: the server inserts claimed steers right after our
// outgoing message, BEFORE the stream produces anything — so a bounded,
@@ -637,38 +594,14 @@ export function Chat({
// verdict is its own custom message — marker part ONLY, no human-
// authored text (the model-facing wording lives in toLlmMessages,
// the native convertToLlm seam; nothing fake is persisted as speech).
// setMessages + sendMessage() (no args) fires ONE request carrying
// both — the same single-turn mechanism syncFromServer already uses,
// so no 409 dance and no double teacher turn.
const stamp = Date.now().toString(36);
const rnd = crypto.randomUUID().slice(0, 8);
const appended: TeacherUIMessage[] = [];
if (note) {
appended.push({
id: `msg_${stamp}_${rnd}`,
role: "user",
parts: [{ type: "text", text: note }],
});
}
appended.push({
id: `verdict_${stamp}_${rnd}`,
role: "user",
parts: [
{
type: "data-verdict",
id: `vd_${stamp}_${rnd}`,
data: { accepted, at: new Date().toISOString() },
},
],
});
setMessages([...messages, ...appended]);
// The event (Wave B): the server appends the note (its own user
// message) + the verdict marker natively — the optimistic ids above
// are local render only; the settle sync lands the canon.
stashInput({ type: "verdict", accepted, note: note || undefined });
void sendMessage();
// ONE input event, ONE turn: the session hook appends the optimistic
// note + verdict marker (local ids — the settle sync lands the
// server canon) and POSTs {type:"verdict"}; the server persists the
// pair as its own write-ahead — the same single-turn mechanism
// syncFromServer uses, no 409 dance, no double teacher turn.
void send({ type: "verdict", accepted, note: note || undefined });
},
[text, messages, setMessages, sendMessage],
[text, send],
);
// ── Session usage (wave-4 stats bar) ──
@@ -955,16 +888,13 @@ export function Chat({
if (res.ok) {
const record = (await res.json()) as {
turnInFlight?: boolean;
messages?: TeacherUIMessage[];
messages?: LhMessage[];
};
// Ground rule (2026-09-15): steer content persisted by the
// wake shows NOW, at the wake turn's start — not at its
// finish. Append-only is safe here: no local stream runs
// under a server-side wake turn.
if (Array.isArray(record.messages)) {
const merged = mergeAdopted(messagesRef.current, record.messages);
if (merged) setMessages(merged);
}
if (Array.isArray(record.messages)) adoptRecord(record.messages);
if (!record.turnInFlight) {
setTurnInFlight(false);
// Heal a stream-death banner (the failed-attach fallback): the
@@ -991,8 +921,7 @@ export function Chat({
if (!t || busy || pending) return;
draftRef.current = t; // the refusal flow's preserve-and-replay seam
setText("");
stashInput({ type: "text", text: t });
void sendMessage({ text: t });
void send({ type: "text", text: t });
};
return (
@@ -1001,8 +930,7 @@ export function Chat({
chatId,
syncFromServer,
sendText: (t: string) => {
stashInput({ type: "text", text: t });
void sendMessage({ text: t });
void send({ type: "text", text: t });
},
openSetup: () => setSetupOpen(true),
}}
@@ -1192,7 +1120,7 @@ export function Chat({
return (
<Reasoning key={pi} isStreaming={status === "streaming" && isLastPart} className="mb-2">
<ReasoningTrigger />
<ReasoningContent>{part.text}</ReasoningContent>
<ReasoningContent>{part.text ?? ""}</ReasoningContent>
</Reasoning>
);
case "step-start":
@@ -1310,17 +1238,13 @@ export function Chat({
}
onSubmitAnswer={(output) => {
if (!pending) return;
// THE INPUT-EVENTS CONTRACT (Wave B): the answer ride order is
// stash-then-send — addToolOutput triggers the SDK's automatic
// continuation send (sendAutomaticallyWhen), and the transport
// consumes THIS stash to build {chatId, input}. Dismissals ride
// the same callback (QuestionAnswerData.dismissed).
stashInput({ type: "answer", toolCallId: pending.toolCallId, answer: output });
void addToolOutput({
tool: pending.tool as "quiz" | "ask_user_question",
toolCallId: pending.toolCallId,
output,
});
// THE ANSWER IS THE QUESTION CALL'S TOOL RESULT (fork 1, owner
// 2026-09-26): one input event, one turn — the hook appends
// the optimistic graded result (the AnsweredCard shows at
// once) and the server's write-ahead persists it before the
// turn; the teacher's reaction streams below it. Dismissals
// ride the same callback (QuestionAnswerData.dismissed).
submitAnswer(pending.tool, pending.toolCallId, output);
}}
/>
</Conversation>
@@ -1355,8 +1279,7 @@ export function Chat({
if (t) {
setText("");
draftRef.current = t;
stashInput({ type: "text", text: t });
void sendMessage({ text: t });
void send({ type: "text", text: t });
}
}}
/>
+513
View File
@@ -0,0 +1,513 @@
/**
* THE SESSION MODEL (Wave D): the chat session's state machine, extracted
* PURE — no React, one injectable fetch — so the attach/replay/settle/
* error/abort paths are testable in the repo's node:test world. The React
* hook (components/session.ts) is a thin adapter: it re-renders on change
* and projects the view (core/view.ts).
*
* ONE VIEW PROTOCOL: every turn is a server-side background process (owner
* ruling 2026-09-25); this model is a view. Sends POST one explicit input
* event ({chatId, input}); the response and the reattach stream
* (GET /api/chats/[id]/stream) are the SAME verbatim AgentEvent SSE,
* applied by the same function. Status vocabulary (pre-Wave-D parity):
* ready | submitted | streaming | error.
*/
import type { AgentEvent } from "@earendil-works/pi-agent-core";
import type { ToolResultMessage } from "@earendil-works/pi-ai";
import {
needsContinuation,
newAnswerResult,
newUserMessage,
newVerdictMessage,
projectView,
type ChatInput,
type LhMessage,
type LhViewMessage,
type LiveExecution,
type QuestionAnswerData,
} from "../src/core";
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
/** How often to poll turnInFlight while a racing turn runs (409 retry). */
export const TURN_POLL_MS = 500;
/** Give up waiting (a wake turn can run long) and surface the 409. */
export const TURN_WAIT_MAX_MS = 30_000;
export interface SessionModelDeps {
/** Injectable fetch (tests); defaults to the global. */
fetch?: typeof globalThis.fetch;
/** Injectable retry timing (tests). */
pollMs?: number;
waitMaxMs?: number;
}
/** A local optimistic id (never a server stamp — absorption and the
* settle swap both key on this prefix). */
let localSeq = 0;
function localId(): string {
return `c_${Date.now().toString(36)}_${(localSeq++).toString(36)}`;
}
function delay(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve, reject) => {
const t = setTimeout(resolve, ms);
signal?.addEventListener(
"abort",
() => {
clearTimeout(t);
reject(new DOMException("Aborted", "AbortError"));
},
{ once: true },
);
});
}
async function turnInFlight(model: SessionModel): Promise<boolean | null> {
try {
const res = await model.fetch(`/api/jobs?chatId=${encodeURIComponent(model.chatId)}`);
if (!res.ok) return null;
return ((await res.json()) as { turnInFlight?: boolean }).turnInFlight === true;
} catch {
return null; // network hiccup: keep waiting, the deadline bounds us
}
}
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
const decoder = new TextDecoder();
let buf = "";
for (;;) {
const { done, value } = await reader.read();
if (done) return;
buf += decoder.decode(value, { stream: true });
for (;;) {
const end = buf.indexOf("\n\n");
if (end === -1) break;
const event = buf.slice(0, end);
buf = buf.slice(end + 2);
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
if (!dataLine) continue;
const data = dataLine.slice("data: ".length);
if (data === "[DONE]") return;
yield JSON.parse(data) as Record<string, unknown>;
}
}
}
/** The dangling question call's tool name (the optimistic answer result's
* toolName — the server stamps the same from its own record scan). */
export function pendingCallTool(messages: readonly LhMessage[], toolCallId: string): string | null {
for (const m of messages) {
if ((m as { role?: string }).role !== "assistant") continue;
const content = (m as { content?: unknown }).content;
if (!Array.isArray(content)) continue;
for (const block of content as Array<{ type?: string; id?: string; name?: string }>) {
if (block.type === "toolCall" && block.id === toolCallId) return block.name ?? null;
}
}
return null;
}
/**
* Has the record ABSORBED this optimistic tail entry? The write-ahead
* persists content-identical copies with server-stamped ids — match on the
* content, not the (local) id; only `c_`-prefixed local entries are ever
* absorbed (live turn content is never dropped).
*/
export function absorbed(m: LhMessage, record: readonly LhMessage[]): boolean {
if (typeof m.id !== "string" || !m.id.startsWith("c_")) return false;
const role = (m as { role?: string }).role;
if (role === "user") {
const content = (m as { content?: unknown }).content;
return record.some(
(r) => (r as { role?: string }).role === "user" && (r as { content?: unknown }).content === content,
);
}
if (role === "toolResult") {
const toolCallId = (m as ToolResultMessage).toolCallId;
return record.some(
(r) =>
(r as { role?: string }).role === "toolResult" &&
(r as ToolResultMessage).toolCallId === toolCallId,
);
}
if (role === "custom") {
const marker = m as { customType?: string; details?: { accepted?: unknown } };
if (marker.customType !== "verdict") return false;
return record.some(
(r) =>
(r as { role?: string }).role === "custom" &&
(r as { customType?: string }).customType === "verdict" &&
(r as { details?: { accepted?: unknown } }).details?.accepted === marker.details?.accepted,
);
}
return false;
}
export interface SessionState {
messages: LhViewMessage[];
nativeMessages: LhMessage[];
status: SessionStatus;
error: { message: string } | null;
}
export interface SessionModelOptions extends SessionModelDeps {
chatId: string;
initialMessages: LhMessage[];
/** Fires on every state change (the hook re-renders). */
onChange?: () => void;
/** The reattach settled terminal (turn-settled): no turn running, the
* record is current — the caller refetches it. */
onReattachSettled?: () => void;
}
export class SessionModel {
readonly chatId: string;
/** Injectable fetch (tests); defaults to the global. */
fetch: typeof globalThis.fetch;
private readonly pollMs: number;
private readonly waitMaxMs: number;
private onReattachSettled?: () => void;
/** The hook's re-render trigger (set by the adapter). */
onChange?: () => void;
// THE STATE: the record (server canon) + the live tail (this client's
// optimistic appends and the streaming turn's messages). The tail dies
// at every full sync; the write-ahead absorbs the optimistic entries.
private base: LhMessage[];
private tail: LhMessage[] = [];
// The live turn's execution layer (view model mid-turn states):
private executions = new Map<string, LiveExecution>();
private streamingIds = new Set<string>();
private statusValue: SessionStatus = "ready";
private errorValue: { message: string } | null = null;
// The single live consume (POST response or reattach stream): a newer
// consume cancels the older — one reader at a time.
private consumeAbort: AbortController | null = null;
/** Synchronous ready check for the gate (send/read mount logic). */
readyRef = true;
constructor(opts: SessionModelOptions) {
this.chatId = opts.chatId;
this.base = opts.initialMessages;
this.fetch = opts.fetch ?? globalThis.fetch;
this.pollMs = opts.pollMs ?? TURN_POLL_MS;
this.waitMaxMs = opts.waitMaxMs ?? TURN_WAIT_MAX_MS;
this.onChange = opts.onChange;
this.onReattachSettled = opts.onReattachSettled;
}
/** Injectable settle hook (the adapter wires its ref). */
setReattachSettled(fn: () => void): void {
this.onReattachSettled = fn;
}
getState(): SessionState {
const live =
this.statusValue === "submitted" || this.statusValue === "streaming"
? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds }
: undefined;
return {
messages: projectView([...this.base, ...this.tail], live),
nativeMessages: [...this.base, ...this.tail],
status: this.statusValue,
error: this.errorValue,
};
}
get status(): SessionStatus {
return this.statusValue;
}
/** Mutate model state + notify (the hook re-renders). */
private mutate(fn: () => void): void {
fn();
this.onChange?.();
}
private setStatus(status: SessionStatus): void {
this.statusValue = status;
this.readyRef = status === "ready";
this.onChange?.();
}
private setError(message: string | null): void {
this.errorValue = message === null ? null : { message };
this.onChange?.();
}
/** The 409 identity-retry fetch (the Wave B contract): the event was
* never persisted (write-ahead happens only on acceptance), so the
* retry waits out the running turn and re-POSTs the SAME body. */
async postInputEvent(input: ChatInput, signal?: AbortSignal): Promise<Response> {
const body = JSON.stringify({ chatId: this.chatId, input });
const init: RequestInit = {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
signal,
};
let response = await this.fetch("/api/chat", init);
let waited = 0;
while (response.status === 409) {
await response.body?.cancel().catch(() => {});
if (signal?.aborted || waited >= this.waitMaxMs) return response;
await delay(this.pollMs, signal);
waited += this.pollMs;
if (signal?.aborted) return response;
if ((await turnInFlight(this)) !== false) continue;
response = await this.fetch("/api/chat", init); // may 409 again → loop
}
return response;
}
/**
* ONE event application for both the POST stream and the reattach
* replay (pi's events carry the FULL partial message — a view REPLACES,
* never assembles deltas).
*/
applyWireEvent(event: Record<string, unknown>): void {
if (event.type === "error") {
// Loop-level failure (agent.continue threw outside the stream) —
// raw text (owner ruling, Wave D): the banner shows exactly this.
this.setError(String(event.errorText ?? "An error occurred."));
return;
}
const agent = event as unknown as AgentEvent;
switch (agent.type) {
case "message_start":
case "message_update":
case "message_end": {
const message = agent.message as LhMessage & {
role?: string;
id?: string;
};
if ((message as { role?: string }).role !== "assistant") return;
const id = message.id ?? localId();
const withId = { ...message, id } as LhMessage;
if (agent.type === "message_start") {
this.streamingIds.add(id);
this.mutate(() => {
this.tail = [...this.tail, withId];
});
} else {
this.mutate(() => {
const idx = this.tail.findIndex((m) => m.id === id);
this.tail = idx === -1 ? [...this.tail, withId] : this.tail.with(idx, withId);
});
}
if (agent.type === "message_end") {
const stop = (message as { stopReason?: string }).stopReason;
if (stop === "error") {
// Provider error, verbatim (owner ruling) — the record
// persists the same message; no masking anywhere.
this.setError(
String((message as { errorMessage?: string }).errorMessage ?? "An error occurred."),
);
}
}
return;
}
case "tool_execution_end": {
// The execution layer: clean question placeholders are IGNORED
// downstream (view.ts fork 2); errors keep their validation text.
const result = agent.result as
| { content?: Array<{ type?: string; text?: string }>; details?: unknown }
| null;
const text = result?.content?.find((c) => c.type === "text")?.text ?? "The tool call failed.";
this.mutate(() => {
this.executions = new Map(this.executions).set(agent.toolCallId, {
isError: agent.isError,
output: result?.details,
errorText: text,
});
});
return;
}
case "turn_end": {
// The results batch: append as native messages (client-stamped
// ids — the settle sync lands the server canon).
this.mutate(() => {
this.tail = [
...this.tail,
...(agent.toolResults as ToolResultMessage[]).map(
(r) => ({ ...r, id: localId() }) as unknown as LhMessage,
),
];
});
return;
}
default:
return; // agent_start/turn_start/tool_execution_start|update: no view work
}
}
/** 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): Promise<void> {
if (!res.body) throw new Error("The turn stream body is empty.");
const reader = res.body.getReader();
let sawEvent = false;
try {
for await (const event of sseEvents(reader)) {
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(() => {});
this.onReattachSettled?.();
return;
}
if (!sawEvent) {
sawEvent = true;
if (this.statusValue !== "error") this.setStatus("streaming");
}
this.applyWireEvent(event);
}
} finally {
this.streamingIds = new Set();
this.executions = new Map();
if (this.statusValue !== "error") this.setStatus("ready");
}
}
/**
* Reattach (the wake/handoff/mount path): a server-side turn with no
* live view of ours. The record is refetched FIRST (write-ahead
* adoption — the tail's optimistic entries are absorbed, its partial
* stream content is about to be fully replayed), then the stream
* replays the turn from its first event through the SAME consume path.
*/
async resumeStream(): Promise<void> {
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
this.setStatus("submitted");
try {
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
signal: controller.signal,
});
if (recordRes.ok) {
const record = (await recordRes.json()) as { messages?: LhMessage[] };
if (Array.isArray(record.messages)) {
this.mutate(() => {
this.base = record.messages!;
this.tail = [];
});
}
}
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
signal: controller.signal,
});
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
await this.consumeStream(res);
} catch (err) {
if ((err as Error).name === "AbortError") return; // a newer consume won
this.setError((err as Error).message);
this.setStatus("error");
}
}
/**
* SEND: one explicit input event. Optimistic appends first (the instant
* answer/note UX — the server's write-ahead persists the canon), then
* POST + consume. Ready-gated: one turn per client at a time (the
* server's 409 + identity retry covers the wake-race window).
*/
async send(input: ChatInput): Promise<void> {
if (!this.readyRef) return;
this.consumeAbort?.abort();
const controller = new AbortController();
this.consumeAbort = controller;
this.setError(null);
this.setStatus("submitted");
// 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.
const optimistic: LhMessage[] = [];
if (input.type === "text") optimistic.push({ ...newUserMessage(input.text), 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() });
} else if (input.type === "verdict") {
if (input.note) optimistic.push({ ...newUserMessage(input.note), id: localId() });
optimistic.push({
...newVerdictMessage(input.accepted, new Date().toISOString()),
id: localId(),
});
}
if (optimistic.length > 0) this.mutate(() => {
this.tail = [...this.tail, ...optimistic];
});
try {
const res = await this.postInputEvent(input, controller.signal);
if (controller.signal.aborted) return;
if (res.status === 409) {
this.setError("A turn is already running — try again in a moment.");
this.setStatus("error");
return;
}
if (!res.ok) {
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
// text rides the error message — the banner shows it, and the
// page's refusal flow JSON-parses the envelope out of it.
throw new Error((await res.text()) || `The send failed (${res.status}).`);
}
await this.consumeStream(res);
} catch (err) {
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
this.setError((err as Error).message);
this.setStatus("error");
}
}
/** The answer path (the dock): the answer IS the question call's tool
* result (fork 1), riding as its input event. */
submitAnswer(tool: string, toolCallId: string, output: QuestionAnswerData): Promise<void> {
void tool;
return this.send({ type: "answer", toolCallId, answer: output });
}
/** Local detach: cancel the consume, keep the partial tail. The page's
* settle watch adopts the record when the server turn finishes. */
stop(): void {
this.consumeAbort?.abort();
this.consumeAbort = null;
this.streamingIds = new Set();
this.executions = new Map();
this.setStatus("ready");
}
/** Full snapshot swap (the idle settle sync — server canon). */
setMessages(record: LhMessage[]): void {
this.mutate(() => {
this.base = record;
this.tail = [];
});
}
/**
* Append-only adoption (the mid-turn steer/artifact ground rule): swap
* in the record — every current base message must still be present
* (the server appends only) — keeping the live tail, minus the
* optimistic entries the record has absorbed (their server-stamped
* copies just landed; without this the input bubble would double).
*/
adoptRecord(record: LhMessage[]): void {
const recordIds = new Set(record.map((m) => (m as { id?: string }).id));
const baseAbsorbed = this.base.some((m) => !recordIds.has((m as { id?: string }).id));
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
this.mutate(() => {
this.base = record;
this.tail = this.tail.filter((m) => !absorbed(m, record));
});
}
/** The mount/sync continuation fold (native-messages.ts). */
static needsContinuation = needsContinuation;
clearError(): void {
this.setError(null);
}
}
+87
View File
@@ -0,0 +1,87 @@
"use client";
/**
* THE SESSION HOOK (Wave D, owner-approved 2026-09-26): the React adapter
* over the pure SessionModel (components/session-core.ts — the state
* machine, testable without a renderer). All mechanics live in the model;
* this hook re-renders on change and derives the view (core/view.ts
* projectView — the golden-master grouping, client-side).
*
* Every turn is a server-side background process (owner ruling
* 2026-09-25); the hook is a view. Sends are ONE explicit input event
* each ({chatId, input}); the turn stream is the VERBATIM AgentEvent
* passthrough. Status vocabulary (pre-Wave-D parity): ready | submitted
* | streaming | error.
*/
import { useCallback, useEffect, useMemo, useReducer, useRef } from "react";
import type { ChatInput, LhMessage, QuestionAnswerData } from "@/src/core";
import { SessionModel, type SessionState } from "@/components/session-core";
export interface UseChatSessionOptions {
chatId: string;
/** The native record seed (server-rendered page or a full sync). */
initialMessages: LhMessage[];
/** Reattach on mount: a turn is running server-side with no live view. */
initiallyInFlight?: boolean;
}
export function useChatSession({ chatId, initialMessages, 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 });
}
const model = modelRef.current;
const [, forceRender] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
model.onChange = forceRender;
return () => {
model.onChange = undefined;
};
}, [model, forceRender]);
// Reattach on mount (StrictMode-safe: a newer resume cancels the older).
useEffect(() => {
if (initiallyInFlight) void model.resumeStream();
}, [initiallyInFlight, model]);
// The view + state, derived per render from the model.
const state = useMemo<SessionState>(() => model.getState(), [model, forceRender] as const);
// The reattach settled terminal → the sync-and-continue seam (chat.tsx).
const setReattachSettled = useCallback(
(fn: () => void) => {
model.setReattachSettled(fn);
},
[model],
);
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
const submitAnswer = useCallback(
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
void model.submitAnswer(tool, toolCallId, output),
[model],
);
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
const stop = useCallback(() => model.stop(), [model]);
const clearError = useCallback(() => model.clearError(), [model]);
const resumeStream = useCallback(() => void model.resumeStream(), [model]);
return {
messages: state.messages,
nativeMessages: state.nativeMessages,
status: state.status,
error: state.error,
send,
submitAnswer,
setMessages,
adoptRecord,
stop,
clearError,
resumeStream,
setReattachSettled,
};
}