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
+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 });
}
}}
/>