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:
@@ -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");
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user