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:
+69
-53
@@ -33,7 +33,7 @@ import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||
import { danglingQuestionOf, newAnswerResult } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView } from "../src/core/view.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
@@ -108,6 +108,18 @@ function gradedAnswer(input: typeof QUIZ_INPUT, values: string[]) {
|
||||
return buildAnswer(qd, { values });
|
||||
}
|
||||
|
||||
/** Parse an SSE body into its wire events (verbatim pi AgentEvents +
|
||||
* the app-level error/turn-settled terminals). Structural beats substring:
|
||||
* key order never matters. */
|
||||
function eventsOf(body: string): Array<Record<string, any>> {
|
||||
const out: Array<Record<string, any>> = [];
|
||||
for (const line of body.split("\n")) {
|
||||
if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
|
||||
out.push(JSON.parse(line.slice(6)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function drain(res: Response): Promise<string> {
|
||||
if (res.status !== 200) {
|
||||
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
|
||||
@@ -213,7 +225,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
|
||||
// The projection pins the client view: reasoning part + a pending
|
||||
// (input-available) quiz part — the dock's mount state.
|
||||
const view1 = projectNative(saved1.messages);
|
||||
const view1 = projectView(saved1.messages);
|
||||
const reasoningPart = view1[1]!.parts.find((p) => p.type === "reasoning");
|
||||
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part projected");
|
||||
const quizPart = view1[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
@@ -241,7 +253,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// Native: user, assistant, answer toolResult, reaction assistant.
|
||||
assert.equal(saved2.messages.length, 4, "the answer + reaction append natively");
|
||||
assert.equal((saved2.messages[2] as { role?: string }).role, "toolResult", "the graded answer is the call's tool result");
|
||||
const view2 = projectNative(saved2.messages);
|
||||
const view2 = projectView(saved2.messages);
|
||||
assert.equal(view2.length, 2, "the reaction continues the same assistant message (one group)");
|
||||
const finalParts = view2[1]!.parts;
|
||||
const textPart = finalParts.find((p) => p.type === "text");
|
||||
@@ -298,20 +310,28 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
await drain(ok);
|
||||
});
|
||||
|
||||
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
|
||||
// The client's writing indicator keys off tool parts in state
|
||||
// 'input-streaming', which the stream processor sets from the
|
||||
// tool-input-start chunk. If a transport change ever stopped emitting
|
||||
// it, the indicator would silently die — pin the protocol.
|
||||
it("streams the toolCall partial before tool_execution_start/end (protocol pin for the 'Writing a question…' shimmer)", async () => {
|
||||
// Wave D: the wire is VERBATIM pi AgentEvents. The client's writing
|
||||
// indicator derives 'input-streaming' from a toolCall block seen in a
|
||||
// message_update with no execution yet; the dock mounts on a CLEAN
|
||||
// tool_execution_end. Pin the ordering that drives both — if the
|
||||
// passthrough ever stopped emitting one of these, the indicator or
|
||||
// the dock would silently die.
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: protocol pin");
|
||||
createdChats.push(chat.id);
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
const startIdx = body.indexOf('"type":"tool-input-start"');
|
||||
const availIdx = body.indexOf('"type":"tool-input-available"');
|
||||
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
|
||||
assert.ok(availIdx !== -1, "tool-input-available chunk present in the stream");
|
||||
assert.ok(startIdx < availIdx, "start precedes available");
|
||||
const events = eventsOf(body);
|
||||
const callSeen = events.findIndex(
|
||||
(e) => e.type === "message_update" && JSON.stringify(e.message?.content ?? []).includes('"type":"toolCall"'),
|
||||
);
|
||||
const execStart = events.findIndex((e) => e.type === "tool_execution_start" && e.toolCallId === "call-1");
|
||||
const execEnd = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-1");
|
||||
assert.ok(callSeen !== -1, "a message_update carries the toolCall block (the shimmer source)");
|
||||
assert.ok(execStart !== -1, "tool_execution_start present in the stream");
|
||||
assert.ok(execEnd !== -1, "tool_execution_end present in the stream");
|
||||
assert.ok(callSeen < execStart && execStart < execEnd, "partial → execution start → execution end");
|
||||
assert.equal(events[execEnd]!.isError, false, "the clean end is what mounts the dock");
|
||||
});
|
||||
|
||||
it("a cancelled view NEVER aborts the turn — it runs to completion (owner ruling 2026-09-25)", async () => {
|
||||
@@ -375,22 +395,17 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
createdChats.push(chat.id);
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
|
||||
// WIRE pin (the live modal-realism crash): the invalid call must NEVER
|
||||
// become input-available — the dock mounts on input-available parts and
|
||||
// crashed on a malformed quiz before tool-input-error could replace it.
|
||||
// The boundary gates this on clean tool_execution_end.
|
||||
assert.ok(
|
||||
!body.includes('"type":"tool-input-available","toolCallId":"call-bad"'),
|
||||
"the invalid call never reached input-available on the wire",
|
||||
);
|
||||
assert.ok(
|
||||
body.includes('"type":"tool-input-error","toolCallId":"call-bad"'),
|
||||
"the invalid call surfaced as tool-input-error",
|
||||
);
|
||||
assert.ok(
|
||||
body.includes('"type":"tool-input-available","toolCallId":"call-good"'),
|
||||
"the valid retry DID become input-available",
|
||||
);
|
||||
// WIRE pin (the live modal-realism crash): the invalid call's execution
|
||||
// end is an ERROR — the dock only mounts on a CLEAN tool_execution_end,
|
||||
// so a malformed quiz can never reach it. The retry's end is clean.
|
||||
const events = eventsOf(body);
|
||||
const badEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-bad");
|
||||
assert.ok(badEnd, "the invalid call's execution end is on the wire");
|
||||
assert.equal(badEnd.isError, true, "the invalid call surfaced as an error execution");
|
||||
assert.match(String(badEnd.result?.content?.[0]?.text ?? ""), /does not match any option value/);
|
||||
const goodEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-good");
|
||||
assert.ok(goodEnd, "the valid retry's execution end is on the wire");
|
||||
assert.equal(goodEnd.isError, false, "the valid retry's end is CLEAN — the dock mounts on it");
|
||||
|
||||
// The model was re-prompted (the error did not end the turn)…
|
||||
assert.equal(calls.length, 2, "the invalid call triggered a second model prompt");
|
||||
@@ -405,7 +420,7 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
// model's retry context — errored question calls DO persist), the
|
||||
// retry as the dangling quiz. The projection renders the error part.
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const view = projectNative(saved.messages);
|
||||
const view = projectView(saved.messages);
|
||||
const quizParts = view[1]!.parts.filter((p) => p.type === "tool-quiz");
|
||||
const failed = quizParts.find(
|
||||
(p) => p.type === "tool-quiz" && (p as { state?: string }).state === "output-error",
|
||||
@@ -446,17 +461,14 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
createdChats.push(chat.id);
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine)));
|
||||
|
||||
assert.ok(
|
||||
!body.includes('"type":"tool-input-available","toolCallId":"call-stringy"'),
|
||||
"the structurally invalid quiz never became input-available on the wire",
|
||||
);
|
||||
assert.ok(
|
||||
body.includes('"type":"tool-input-error","toolCallId":"call-stringy"'),
|
||||
"the invalid quiz surfaced as tool-input-error",
|
||||
);
|
||||
const events = eventsOf(body);
|
||||
const stringyEnd = events.find((e) => e.type === "tool_execution_end" && e.toolCallId === "call-stringy");
|
||||
assert.ok(stringyEnd, "the invalid quiz's execution end is on the wire");
|
||||
assert.equal(stringyEnd.isError, true, "the structural reject surfaced as an error execution");
|
||||
assert.match(String(stringyEnd.result?.content?.[0]?.text ?? ""), /options/);
|
||||
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const view = projectNative(saved.messages);
|
||||
const view = projectView(saved.messages);
|
||||
const failed = view[1]!.parts.find(
|
||||
(p) => (p as { toolCallId?: string }).toolCallId === "call-stringy",
|
||||
);
|
||||
@@ -488,15 +500,18 @@ describe("teacher chat flow (scripted model)", () => {
|
||||
createdChats.push(chat.id);
|
||||
const body = await drain(await handleChatRequest(post(chat.id, { type: "text", text: "quiz me twice" }), agentFactory(engine)));
|
||||
|
||||
// (B) wire determinism: the batches' input-available chunks in call order.
|
||||
const availA = body.indexOf('"type":"tool-input-available","toolCallId":"call-a"');
|
||||
const availB = body.indexOf('"type":"tool-input-available","toolCallId":"call-b"');
|
||||
assert.ok(availA !== -1 && availB !== -1, "both quizzes became available");
|
||||
assert.ok(availA < availB, "call A's input became available before call B's (sequential execution)");
|
||||
// (B) wire determinism: question tools execute sequentially, so the
|
||||
// quizzes' CLEAN execution ends arrive in call order (the dock mounts
|
||||
// on clean ends — the mount order is the wire order).
|
||||
const events = eventsOf(body);
|
||||
const endA = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-a" && e.isError === false);
|
||||
const endB = events.findIndex((e) => e.type === "tool_execution_end" && e.toolCallId === "call-b" && e.isError === false);
|
||||
assert.ok(endA !== -1 && endB !== -1, "both quizzes' clean ends are on the wire");
|
||||
assert.ok(endA < endB, "call A's end precedes call B's (sequential execution)");
|
||||
|
||||
// Saved in call order (the projection preserves the content order).
|
||||
const saved = chatStore.getChat(chat.id)!;
|
||||
const quizIds = projectNative(saved.messages)[1]!.parts
|
||||
const quizIds = projectView(saved.messages)[1]!.parts
|
||||
.filter((p) => p.type === "tool-quiz")
|
||||
.map((p) => (p as { toolCallId: string }).toolCallId);
|
||||
assert.deepEqual(quizIds, ["call-a", "call-b"], "parts persisted in call order");
|
||||
@@ -576,7 +591,7 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||
const midTurn = chatStore.getChat(chat.id)!;
|
||||
assert.equal(midTurn.messages.length, 3, "write-ahead appended the answer tool result before finish");
|
||||
assert.equal((midTurn.messages[2] as { role?: string }).role, "toolResult");
|
||||
const midQuiz = projectNative(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
const midQuiz = projectView(midTurn.messages)[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||
assert.ok(midQuiz && midQuiz.type === "tool-quiz");
|
||||
assert.equal((midQuiz as { state?: string }).state, "output-available", "write-ahead persisted the answer before finish");
|
||||
|
||||
@@ -585,7 +600,7 @@ describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||
// Native: user, assistant, answer result, reaction — one append-only
|
||||
// history; the projection still renders ONE assistant group.
|
||||
assert.equal(saved2.messages.length, 4, "full turn persisted as usual at finish");
|
||||
assert.equal(projectNative(saved2.messages).length, 2, "the projection groups the answered quiz + reaction");
|
||||
assert.equal(projectView(saved2.messages).length, 2, "the projection groups the answered quiz + reaction");
|
||||
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||
});
|
||||
});
|
||||
@@ -732,14 +747,15 @@ describe("background-turn runner (owner ruling 2026-09-25: every turn is a serve
|
||||
// Let the turn finish; both views close at settle.
|
||||
release();
|
||||
const text = await secondText;
|
||||
assert.ok(text.startsWith('data: {"type":"start"'), "replay starts from the very first chunk (the start event)");
|
||||
const replay = eventsOf(text);
|
||||
assert.equal(replay[0]!.type, "agent_start", "replay starts from the very first event");
|
||||
assert.ok(text.includes("Gated reply."), "replay carries the full streamed content");
|
||||
assert.ok(text.endsWith("data: [DONE]\n\n"), "live tail ends with the SSE terminal, same dialect as POST");
|
||||
// Wire honesty: a normal turn settles via agent_end with NO error chunk
|
||||
// (the live smoke caught a close()-throw masked into the error path
|
||||
// — unit asserts never looked; these pins would have).
|
||||
assert.ok(!text.includes('"type":"error"'), "a clean turn never emits an error chunk");
|
||||
assert.ok(text.includes('"finishReason":"stop"'), "the finish chunk carries the stop reason");
|
||||
// Wire honesty: a normal turn settles with NO error event (the live
|
||||
// smoke caught a close()-throw masked into the error path — unit
|
||||
// asserts never looked; these pins would have).
|
||||
assert.ok(!text.includes('"type":"error"'), "a clean turn never emits an error event");
|
||||
assert.ok(replay.some((e) => e.type === "message_end" && e.message?.stopReason === "stop"), "message_end carries the stop reason");
|
||||
// And the first view also closes cleanly (no hang, no drop): read its
|
||||
// remaining chunks through the reader we already hold.
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 409 identity-retry transport tests (components/chat-transport.ts).
|
||||
*
|
||||
* Wave B (input events): a 409 means a turn is in flight — the event was
|
||||
* NEVER persisted (write-ahead happens only on accept), so the retry waits
|
||||
* out the turn and re-POSTs the SAME event body. Nothing merges, nothing
|
||||
* derives: the event is the unit, and re-POSTing it is idempotent-safe.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { retryingFetch } from "../components/chat-transport.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
body: string | undefined;
|
||||
}
|
||||
|
||||
function installFetch(routes: {
|
||||
/** POST /api/chat → Response; receives every attempt. */
|
||||
chatPOST: (call: Call, attempt: number) => Response;
|
||||
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
||||
jobsGET?: (poll: number) => boolean;
|
||||
}): Call[] {
|
||||
const calls: Call[] = [];
|
||||
let poll = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, body: init?.body as string | undefined });
|
||||
if (url === "/api/chat" && init?.method === "POST") {
|
||||
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
|
||||
}
|
||||
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
/** One input event — the POST body's whole unit. */
|
||||
function outgoing(input: Record<string, unknown>): string {
|
||||
return JSON.stringify({ chatId: "chat_x", input });
|
||||
}
|
||||
|
||||
describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||
it("passes non-409 responses through untouched", async () => {
|
||||
const calls = installFetch({
|
||||
chatPOST: () => new Response("stream", { status: 200 }),
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "hi" }) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1, "no polling, no merge, single call");
|
||||
});
|
||||
|
||||
it("waits out the turn, then re-POSTs the SAME event body (identity retry)", async () => {
|
||||
const event = { type: "text", text: "my question while the wake turn ran" };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// The retry carries the IDENTICAL body — the event was never
|
||||
// persisted pre-accept, so re-sending it is the whole protocol.
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 2, "one 409, one retry");
|
||||
assert.equal(posts[1]!.body, posts[0]!.body, "the event body is byte-identical");
|
||||
assert.deepEqual(JSON.parse(posts[1]!.body!), { chatId: "chat_x", input: event });
|
||||
});
|
||||
|
||||
it("does not poll the record or merge — the event is the only payload", async () => {
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(
|
||||
calls.filter((c) => c.url.startsWith("/api/chats/")).length,
|
||||
0,
|
||||
"the retry never reads the record — the server owns it",
|
||||
);
|
||||
});
|
||||
|
||||
it("loops on repeated 409s, re-POSTing the same event each time", async () => {
|
||||
const event = { type: "answer", toolCallId: "call-1", answer: { correct: true } };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_call, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||
assert.equal(res.status, 200);
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 3);
|
||||
assert.ok(posts.every((c) => c.body === posts[0]!.body), "every attempt carries the same event");
|
||||
});
|
||||
|
||||
it("gives up after TURN_WAIT_MAX_MS and returns the last 409 (banner as before)", async () => {
|
||||
// turnInFlight stuck true; two polls (~1s) then force the deadline path
|
||||
// by asserting we eventually get the 409 back — bounded by poll count.
|
||||
let polls = 0;
|
||||
const calls = installFetch({
|
||||
chatPOST: () => new Response("busy", { status: 409 }),
|
||||
jobsGET: () => true,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||
assert.equal(res.status, 409);
|
||||
polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
||||
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
||||
});
|
||||
});
|
||||
+188
-47
@@ -1,7 +1,9 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import * as fs from "node:fs";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newAnswerResult, newPlanBlockMessage, newUserMessage } from "../src/core/native-messages.ts";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
|
||||
import {
|
||||
batchRuns,
|
||||
@@ -11,20 +13,47 @@ import {
|
||||
messageFingerprint,
|
||||
messageViewLines,
|
||||
} from "../src/core/coverage.ts";
|
||||
import type { CoverageEntry } from "../src/core/contracts.ts";
|
||||
import type { CoverageEntry } from "../src/core/contracts/graph.ts";
|
||||
|
||||
/**
|
||||
* Extraction coverage — the pure core (vision-extraction-coverage.md,
|
||||
* settled 2026-09-15). THE MESSAGE IS THE UNIT OF COVERAGE; the fingerprint
|
||||
* and the LLM prompt share ONE line builder (messageViewLines), so "covered"
|
||||
* is by construction exactly "what the extractor saw".
|
||||
* settled 2026-09-15; Wave D native rebase 2026-09-26). THE NATIVE MESSAGE
|
||||
* IS THE UNIT OF COVERAGE (owner ruling); the fingerprint and the LLM prompt
|
||||
* share ONE line builder (messageViewLines), so "covered" is by construction
|
||||
* exactly "what the extractor saw". Batches obey the ONE INSEPARABILITY RULE
|
||||
* (owner fork 3): a tool-call message and its tool-result messages always
|
||||
* appear in the same chunk.
|
||||
*/
|
||||
|
||||
function user(id: string, text: string): UIMessage {
|
||||
return { id, role: "user", parts: [{ type: "text", text }] } as unknown as UIMessage;
|
||||
function user(id: string, text: string): LhMessage {
|
||||
return { ...newUserMessage(text), id };
|
||||
}
|
||||
|
||||
function slot(messages: UIMessage[], id: string) {
|
||||
function teacher(id: string, text: string): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
/** An assistant message calling `calls` (each {id,name}) — the bond anchor. */
|
||||
function assistantWithCalls(
|
||||
id: string,
|
||||
calls: Array<{ id: string; name: string; args?: unknown }>,
|
||||
): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: calls.map((c) => ({ type: "toolCall", id: c.id, name: c.name, arguments: c.args ?? {} })),
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
function result(id: string, callId: string, name: string, details: unknown): LhMessage {
|
||||
return { ...newAnswerResult(callId, name, details), id } as LhMessage;
|
||||
}
|
||||
|
||||
function slot(messages: ReadonlyArray<LhMessage>, id: string) {
|
||||
const found = coverageSlots(messages).find((s) => s.message.id === id);
|
||||
assert.ok(found, `slot ${id}`);
|
||||
return found;
|
||||
@@ -37,9 +66,8 @@ describe("messageFingerprint — what changes it, what never does", () => {
|
||||
const same = messageFingerprint(user("m1", "hello world"));
|
||||
assert.notEqual(a, b);
|
||||
assert.equal(a, same);
|
||||
// Role is hashed too — a user and an assistant line never collide.
|
||||
const teacher = { id: "m1", role: "assistant", parts: [{ type: "text", text: "hello world" }] } as unknown as UIMessage;
|
||||
assert.notEqual(a, messageFingerprint(teacher));
|
||||
// Role is hashed too — a learner and a teacher line never collide.
|
||||
assert.notEqual(a, messageFingerprint(teacher("m1", "hello world")));
|
||||
});
|
||||
|
||||
it("the message id is the provenance tag — part of exactly what the extractor saw", () => {
|
||||
@@ -48,39 +76,61 @@ describe("messageFingerprint — what changes it, what never does", () => {
|
||||
// extraction view byte-for-byte. (Ids are stable in practice — the
|
||||
// write-ahead persist owns them.)
|
||||
assert.notEqual(messageFingerprint(user("m1", "same")), messageFingerprint(user("m2", "same")));
|
||||
// The id is the ONLY tag: the same id + content re-fingerprints equal.
|
||||
// The same id + content re-fingerprints equal.
|
||||
assert.equal(messageFingerprint(user("m1", "same")), messageFingerprint(user("m1", "same")));
|
||||
});
|
||||
|
||||
it("data parts are invisible — plan-block attach never re-opens a covered message", () => {
|
||||
const plain = user("m1", "teach me the chain rule");
|
||||
const withData = {
|
||||
...plain,
|
||||
parts: [...plain.parts, { type: "data-plan-blocks", planBlocks: [] }],
|
||||
} as unknown as UIMessage;
|
||||
assert.equal(messageFingerprint(plain), messageFingerprint(withData));
|
||||
it("marker messages are invisible — a plan-block attach never re-opens anything", () => {
|
||||
// Wave D: the marker is its OWN message (not a part on another one);
|
||||
// its view is empty, so it neither re-opens others nor itself
|
||||
// carries any learning evidence.
|
||||
const marker = newPlanBlockMessage("call_p1", {
|
||||
kind: "full",
|
||||
at: "2026-09-26T00:00:00.000Z",
|
||||
current: null,
|
||||
nodes: [],
|
||||
edges: [],
|
||||
}) as LhMessage;
|
||||
assert.deepEqual(messageViewLines(marker, marker.id), []);
|
||||
// And the message it rides beside keeps its fingerprint untouched:
|
||||
const before = messageFingerprint(teacher("t1", "the chain rule"));
|
||||
const withMarker = [teacher("t1", "the chain rule"), marker];
|
||||
assert.equal(
|
||||
messageFingerprint(withMarker[0]!),
|
||||
before,
|
||||
"the neighbour's fingerprint is unchanged by the attach",
|
||||
);
|
||||
});
|
||||
|
||||
it("a quiz answer merging in changes the fingerprint — and only that message's", () => {
|
||||
const asking = {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [{ type: "tool-quiz", toolCallId: "q1", state: "input-available", input: { question: "Q?" } }],
|
||||
} as unknown as UIMessage;
|
||||
const answered = {
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
parts: [
|
||||
{ type: "tool-quiz", toolCallId: "q1", state: "output-available", input: { question: "Q?" }, output: { questionText: "Q?", correct: true } },
|
||||
],
|
||||
} as unknown as UIMessage;
|
||||
const fpBefore = messageFingerprint(asking);
|
||||
assert.notEqual(fpBefore, messageFingerprint(answered));
|
||||
// An output-bearing line's mass counts toward coverage tokens.
|
||||
assert.ok(
|
||||
messageViewLines(answered, answered.id).join("").length >
|
||||
messageViewLines(asking, asking.id).join("").length,
|
||||
);
|
||||
it("an answered quiz is its OWN message — the asking message never re-opens", () => {
|
||||
// Wave D: the answer arrives as the call's toolResult message; the
|
||||
// asking assistant message's fingerprint is STABLE (the old wire
|
||||
// merged the answer into the asking group and re-opened it).
|
||||
const asking = assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }]);
|
||||
const answer = result("r1", "q1", "quiz", { questionText: "Q?", correct: true });
|
||||
const fpAsking = messageFingerprint(asking);
|
||||
assert.equal(fpAsking, messageFingerprint(assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }])));
|
||||
// The answer's own line carries the graded outcome:
|
||||
const lines = messageViewLines(answer, "r1");
|
||||
assert.deepEqual(lines, ["QUIZ [q1] Q? — the learner answered correctly"]);
|
||||
assert.ok(slot([asking, answer], "r1").tokens > 0, "the quiz outcome counts toward coverage tokens");
|
||||
});
|
||||
|
||||
it("quiz outcome wording — dontKnow and incorrect each get their line", () => {
|
||||
const dk = result("r1", "q1", "quiz", { questionText: "Q?", dontKnow: true });
|
||||
assert.deepEqual(messageViewLines(dk, "r1"), ["QUIZ [q1] Q? — the learner did not know"]);
|
||||
const wrong = result("r2", "q2", "quiz", { questionText: "W?", correct: false, note: "mixed up signs" });
|
||||
assert.deepEqual(messageViewLines(wrong, "r2"), ["QUIZ [q2] W? — the learner answered incorrectly (note: mixed up signs)"]);
|
||||
});
|
||||
|
||||
it("other clean tool results get one generic line; errors are invisible", () => {
|
||||
const demo = result("r1", "j1", "demo", { title: "T" });
|
||||
assert.deepEqual(messageViewLines(demo, "r1"), ["(demo tool result delivered)"]);
|
||||
const failed = {
|
||||
...newAnswerResult("j2", "visual", { oops: true }),
|
||||
isError: true,
|
||||
} as LhMessage;
|
||||
assert.deepEqual(messageViewLines(failed, "r9"), [], "a failed call is retry context, not learning evidence");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -96,7 +146,9 @@ describe("coverageOf — uncovered is DERIVED, never dual-tracked", () => {
|
||||
{ chatId: "c2", fp: slot(messages, "m3").fp, tokens: 1, foldedAt: "2026-09-15T00:00:00.000Z" },
|
||||
];
|
||||
const cov = coverageOf(messages, entries, "c1");
|
||||
assert.deepEqual(cov.uncovered.map((s) => s.message.id), ["m3"]);
|
||||
// `uncovered` is INDEXES (the batcher needs positions for bonding):
|
||||
assert.deepEqual(cov.uncovered, [2]);
|
||||
assert.deepEqual(cov.slots[cov.uncovered[0]!]!.message.id, "m3");
|
||||
assert.equal(cov.coveredCount, 2);
|
||||
assert.equal(cov.uncoveredCount, 1);
|
||||
assert.equal(cov.uncoveredTokens, slot(messages, "m3").tokens);
|
||||
@@ -114,7 +166,8 @@ describe("coverageOf — uncovered is DERIVED, never dual-tracked", () => {
|
||||
assert.equal(coverageOf(messages, entries, "c1").uncoveredCount, 0);
|
||||
const mutated = [user("m1", "aaaa!"), messages[1]!];
|
||||
const cov = coverageOf(mutated, entries, "c1");
|
||||
assert.deepEqual(cov.uncovered.map((s) => s.message.id), ["m1"]);
|
||||
assert.deepEqual(cov.uncovered, [0]);
|
||||
assert.equal(cov.slots[0]!.message.id, "m1");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -128,19 +181,106 @@ describe("batchRuns — token-budgeted, consecutive, never splits a message", ()
|
||||
user("m3", "c".repeat(40)),
|
||||
user("m4", "d".repeat(40)),
|
||||
]);
|
||||
const runs = batchRuns(slots, 45);
|
||||
assert.deepEqual(runs.map((r) => r.map((s) => s.message.id)), [["m1", "m2", "m3"], ["m4"]]);
|
||||
const runs = batchRuns(slots, [0, 1, 2, 3], 45);
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.map((s) => s.message.id)),
|
||||
[["m1", "m2", "m3"], ["m4"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("a single message larger than the budget becomes its own over-budget run", () => {
|
||||
const slots = coverageSlots([user("m1", "x".repeat(400)), user("m2", "y".repeat(20))]);
|
||||
const runs = batchRuns(slots, 10);
|
||||
assert.deepEqual(runs.map((r) => r.map((s) => s.message.id)), [["m1"], ["m2"]]);
|
||||
assert.deepEqual(
|
||||
batchRuns(slots, [0, 1], 10).map((r) => r.map((s) => s.message.id)),
|
||||
[["m1"], ["m2"]],
|
||||
);
|
||||
});
|
||||
|
||||
it("an empty or non-positive budget never drops messages into a void", () => {
|
||||
assert.deepEqual(batchRuns(coverageSlots([user("m1", "hi")]), 0), []);
|
||||
assert.deepEqual(batchRuns([], 4096), []);
|
||||
assert.deepEqual(batchRuns(coverageSlots([user("m1", "hi")]), [0], 0), []);
|
||||
assert.deepEqual(batchRuns([], [], 4096), []);
|
||||
});
|
||||
|
||||
it("THE BOND RULE (owner fork 3): a call and its results never split, even under a tiny budget", () => {
|
||||
// The call's assistant message is COVERED; only the (new) answer is
|
||||
// uncovered — the asking message rides into the chunk anyway, and
|
||||
// the two NEVER split despite a budget of 1.
|
||||
const messages = [
|
||||
user("u1", "topic"),
|
||||
assistantWithCalls("a1", [{ id: "q1", name: "quiz", args: { question: "Q?" } }]),
|
||||
result("r1", "q1", "quiz", { questionText: "Q?", correct: true }),
|
||||
];
|
||||
const slots = coverageSlots(messages);
|
||||
const cov = coverageOf(
|
||||
messages,
|
||||
coverageSlots([messages[0]!, messages[1]!]).map((s) => ({
|
||||
chatId: "c1",
|
||||
fp: s.fp,
|
||||
tokens: s.tokens,
|
||||
foldedAt: "2026-09-26T00:00:00.000Z",
|
||||
})),
|
||||
"c1",
|
||||
);
|
||||
assert.deepEqual(cov.uncovered, [2], "only the answer is uncovered");
|
||||
const runs = batchRuns(slots, cov.uncovered, 1);
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.map((s) => s.message.id)),
|
||||
[["a1", "r1"]],
|
||||
"the covered asking message rides along as context — one chunk, over budget, never split",
|
||||
);
|
||||
});
|
||||
|
||||
it("the reverse bond: an uncovered CALL pulls its covered results into the chunk", () => {
|
||||
const messages = [
|
||||
{
|
||||
id: "a1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Let me research that and build a demo." },
|
||||
{ type: "toolCall", id: "j1", name: "research", arguments: { topic: "x" } },
|
||||
{ type: "toolCall", id: "j2", name: "demo", arguments: { title: "T" } },
|
||||
],
|
||||
} as unknown as LhMessage,
|
||||
result("r1", "j1", "research", { summary: "s" }),
|
||||
result("r2", "j2", "demo", { done: true }),
|
||||
];
|
||||
const slots = coverageSlots(messages);
|
||||
const cov = coverageOf(
|
||||
messages,
|
||||
coverageSlots([messages[1]!, messages[2]!]).map((s) => ({
|
||||
chatId: "c1",
|
||||
fp: s.fp,
|
||||
tokens: s.tokens,
|
||||
foldedAt: "2026-09-26T00:00:00.000Z",
|
||||
})),
|
||||
"c1",
|
||||
);
|
||||
assert.deepEqual(cov.uncovered, [0], "only the calling message is uncovered");
|
||||
const runs = batchRuns(slots, cov.uncovered, 10_000);
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.map((s) => s.message.id)),
|
||||
[["a1", "r1", "r2"]],
|
||||
"all three travel together (checkpoint idempotency makes re-marking the results a no-op)",
|
||||
);
|
||||
});
|
||||
|
||||
it("adjacent messages WITHOUT a bond still batch greedily (only calls bond)", () => {
|
||||
// Two plain teacher messages after a bonded pair: the pair is one
|
||||
// range, the plain messages group by budget alone.
|
||||
const messages = [
|
||||
assistantWithCalls("a1", [{ id: "q1", name: "quiz" }]),
|
||||
result("r1", "q1", "quiz", { questionText: "Q?", correct: true }),
|
||||
teacher("t1", "x".repeat(40)),
|
||||
teacher("t2", "y".repeat(40)),
|
||||
];
|
||||
const slots = coverageSlots(messages);
|
||||
assert.equal(slots[0]!.tokens, 0, "a call-only assistant message has no view lines");
|
||||
// budget 30: the pair (~12) + t1 (~14) = 26 fits; t2 overflows.
|
||||
const runs = batchRuns(slots, [0, 2, 3], 30);
|
||||
assert.deepEqual(
|
||||
runs.map((r) => r.map((s) => s.message.id)),
|
||||
[["a1", "r1", "t1"], ["t2"]],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -158,6 +298,7 @@ describe("batchTag — deterministic, ref-stable, mutation-sensitive", () => {
|
||||
assert.notEqual(batchTag(slots), batchTag(mutated));
|
||||
});
|
||||
});
|
||||
|
||||
describe("the all-sessions coverage GET is dead (extraction-UX close-out, 2026-09-24)", () => {
|
||||
it("the route file is gone — the only coverage endpoint is the Extract POST", () => {
|
||||
// UI confirmation (ff1ec7a): nothing client-side consumes the
|
||||
|
||||
@@ -57,7 +57,7 @@ import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView as projectNative } from "../src/core/view.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
|
||||
/**
|
||||
|
||||
+16
-10
@@ -26,7 +26,7 @@ import { createFauxCore, fauxAssistantMessage, fauxToolCall, type FauxResponseSt
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newPlanBlockMessage } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView as projectNative } from "../src/core/view.ts";
|
||||
import type {
|
||||
AuditResult,
|
||||
CoverageEntry,
|
||||
@@ -42,7 +42,7 @@ import type {
|
||||
import type { GraphStore } from "../src/core/ports.ts";
|
||||
import { abortExtraction, createExtractionPipeline, extractionStatuses, transcriptView } from "../src/agent/subagents/extraction.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError, providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts";
|
||||
import { batchTag, coverageOf, coverageSlots } from "../src/core/coverage.ts";
|
||||
import { batchTag, coverageOf, coverageSlots, messageViewLines } from "../src/core/coverage.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
@@ -399,7 +399,7 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// a new event (vision-extraction-coverage.md).
|
||||
const events = ledgers.readEvents();
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
const expectedTag = batchTag(coverageSlots(projectNative(record.messages)));
|
||||
const expectedTag = batchTag(coverageSlots(record.messages));
|
||||
assert.deepEqual(
|
||||
events.map((e) => [e.kind, e.ref, e.nodeId]),
|
||||
[
|
||||
@@ -415,12 +415,18 @@ describe("the extraction pipeline (extractor → auditor → merger)", () => {
|
||||
// one entry per transcript message, fingerprinted content each —
|
||||
// the same turns are never extracted twice.
|
||||
const covered = ledgers.readCoverage();
|
||||
assert.equal(covered.length, projectNative(record.messages).length);
|
||||
// Wave D: the coverage unit is the VISIBLE native message — bonded
|
||||
// riders with no view lines (a textless call message) are context,
|
||||
// never checkpointed.
|
||||
const visible = record.messages.filter(
|
||||
(m) => messageViewLines(m, m.id).length > 0,
|
||||
).length;
|
||||
assert.equal(covered.length, visible);
|
||||
assert.ok(covered.every((c) => c.chatId === chatId && c.fp.length === 64 && c.tokens > 0));
|
||||
assert.equal(ledgers.readCheckpoints().length, 0, "the chat path writes coverage, not the legacy checkpoint");
|
||||
|
||||
assert.equal(
|
||||
coverageOf(projectNative(record.messages), covered, chatId).uncoveredCount,
|
||||
coverageOf(record.messages, covered, chatId).uncoveredCount,
|
||||
0,
|
||||
"every message is now covered",
|
||||
);
|
||||
@@ -1020,9 +1026,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
|
||||
// Per-batch coverage: every message checkpointed, nothing legacy.
|
||||
const coverage = ledgers.readCoverage();
|
||||
assert.equal(coverage.length, projectNative(record.messages).length);
|
||||
assert.equal(coverage.length, record.messages.length);
|
||||
assert.equal(ledgers.readCheckpoints().length, 0);
|
||||
assert.equal(coverageOf(projectNative(record.messages), coverage, chatId).uncoveredCount, 0);
|
||||
assert.equal(coverageOf(record.messages, coverage, chatId).uncoveredCount, 0);
|
||||
|
||||
// A second run is a NO-OP: nothing uncovered → no model calls, no
|
||||
// new usage entry (no money spent on covered ground).
|
||||
@@ -1075,7 +1081,7 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
// Batch 1's emissions are kept and its messages stay covered.
|
||||
assert.deepEqual(stats, { nodes: 1, edges: 0, events: 1 });
|
||||
assert.equal(ledgers.readCoverage().length, 3, "batch 1 checkpointed its three messages");
|
||||
const cov = coverageOf(projectNative(record.messages), ledgers.readCoverage(), chatId);
|
||||
const cov = coverageOf(record.messages, ledgers.readCoverage(), chatId);
|
||||
assert.equal(cov.uncoveredCount, 2, "the in-flight batch's messages stay uncovered");
|
||||
|
||||
// The re-run — the manual extract button — picks up ONLY the
|
||||
@@ -1109,9 +1115,9 @@ describe("the chunked pipeline (extraction coverage)", () => {
|
||||
auditor: fauxEngine([passThroughAuditStep()]),
|
||||
});
|
||||
await pipeline.runExtraction(chatId);
|
||||
// Wave B: the record is native — coverage reads the projection.
|
||||
// Wave D: the record IS native history — coverage reads it directly.
|
||||
const record = () => chatStore.getChat(chatId)!.messages;
|
||||
const messages = () => projectNative(record());
|
||||
const messages = () => record();
|
||||
assert.equal(coverageOf(messages(), ledgers.readCoverage(), chatId).uncoveredCount, 0);
|
||||
|
||||
// Data parts are invisible to the extraction view — a plan-block
|
||||
|
||||
+4
-4
@@ -20,7 +20,7 @@ import { after, describe, it } from "node:test";
|
||||
import type { AuditResult } from "../src/core/contracts.ts";
|
||||
import type { QuestionAnswerData } from "../src/core/question-core.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView as projectNative } from "../src/core/view.ts";
|
||||
import { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||
import { coverageSlots } from "../src/core/coverage.ts";
|
||||
import { graphStore } from "../src/store/graph.ts";
|
||||
@@ -33,12 +33,12 @@ const graphDeps = { chatStore, graphStore } as const satisfies GraphDeps;
|
||||
/** The session merge source for a chat — what the pipeline passes: the live
|
||||
* transcript + its updatedAt checkpoint stamp. (Notes imports pass
|
||||
* messages: null — see the notes-merger test below.) */
|
||||
function sessionSource(chatId: string): { messages: ReturnType<typeof projectNative>; foldedAt: string } {
|
||||
function sessionSource(chatId: string): { messages: LhMessage[]; foldedAt: string } {
|
||||
// Wave B: the record is native history — the merger's transcript view
|
||||
// reads through the one-way projection (same shape the extraction
|
||||
// pipeline passes: its batch slots are projected messages).
|
||||
const record = chatStore.getChat(chatId)!;
|
||||
return { messages: projectNative(record.messages), foldedAt: record.updatedAt };
|
||||
return { messages: record.messages, foldedAt: record.updatedAt };
|
||||
}
|
||||
|
||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||
@@ -1036,7 +1036,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
|
||||
// Fold the first two messages: the read derives the split + the
|
||||
// newest fold timestamp from the ledger rows.
|
||||
const slots = coverageSlots(projectNative(chatStore.getChat(chat.id)!.messages));
|
||||
const slots = coverageSlots(chatStore.getChat(chat.id)!.messages);
|
||||
graphStore.appendCoverage({ chatId: chat.id, fp: slots[0]!.fp, tokens: slots[0]!.tokens, foldedAt: "2026-09-24T10:00:00.000Z" });
|
||||
graphStore.appendCoverage({ chatId: chat.id, fp: slots[1]!.fp, tokens: slots[1]!.tokens, foldedAt: "2026-09-24T11:30:00.000Z" });
|
||||
// Another session's row must not leak into this read.
|
||||
|
||||
+1
-1
@@ -58,7 +58,7 @@ import { providerKeyMissing, type ModelSlotName } from "../src/core/settings.ts"
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { SearchProvider } from "../src/core/search.ts";
|
||||
import { PATHS } from "../config.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView as projectNative } from "../src/core/view.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import type { SteerInput, TurnInput } from "../src/core/input-events.ts";
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
/**
|
||||
* mergeAdopted (core/ui-message.ts) — the append-only record-adoption seam
|
||||
* (owner ground rules, 2026-09-15): server-added steer messages must become
|
||||
* visible mid-turn without the full snapshot swap that breaks the SDK's
|
||||
* continuation merge. Pure, no React.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { mergeAdopted, type TeacherUIMessage } from "../src/core/index.ts";
|
||||
|
||||
function userText(id: string, text: string): TeacherUIMessage {
|
||||
return { id, role: "user", parts: [{ type: "text", text }] };
|
||||
}
|
||||
function steer(id: string): TeacherUIMessage {
|
||||
// Wave 7 killed the graph-context injection; its marker part is gone.
|
||||
// The steer-shaped user message here exercises mergeAdopted's
|
||||
// non-text user parts path with the handoff marker instead.
|
||||
return { id, role: "user", parts: [{ type: "data-probe-handoff", data: { chatId: "c" } }] };
|
||||
}
|
||||
function assistant(id: string): TeacherUIMessage {
|
||||
return { id, role: "assistant", parts: [{ type: "text", text: "reply" }] };
|
||||
}
|
||||
|
||||
describe("mergeAdopted — append-only adoption", () => {
|
||||
it("inserts a server-added steer between the persisted core and the in-flight tail", () => {
|
||||
const local = [userText("u1", "topic"), userText("u2", "answer"), assistant("a_inflight")];
|
||||
const record = [userText("u1", "topic"), userText("u2", "answer"), steer("steer_ctx_c")];
|
||||
const merged = mergeAdopted(local, record);
|
||||
assert.ok(merged);
|
||||
assert.deepEqual(
|
||||
merged.map((m) => m.id),
|
||||
["u1", "u2", "steer_ctx_c", "a_inflight"],
|
||||
"marker between the answer and the streaming bubble",
|
||||
);
|
||||
});
|
||||
|
||||
it("adopts into an idle client (no in-flight tail)", () => {
|
||||
const local = [userText("u1", "topic")];
|
||||
const record = [userText("u1", "topic"), steer("s1"), assistant("a1")];
|
||||
const merged = mergeAdopted(local, record);
|
||||
assert.deepEqual(
|
||||
merged?.map((m) => m.id),
|
||||
["u1", "s1", "a1"],
|
||||
);
|
||||
});
|
||||
|
||||
it("nothing new → null (no needless re-render)", () => {
|
||||
const local = [userText("u1", "topic")];
|
||||
assert.equal(mergeAdopted(local, [userText("u1", "topic")]), null);
|
||||
// The tail alone is not 'new' — nothing to adopt.
|
||||
assert.equal(mergeAdopted([assistant("a_x")], []), null);
|
||||
});
|
||||
|
||||
it("a missing core id (record rewrite) → null: defer to the full sync at idle", () => {
|
||||
// A local message vanished from the record — mid-turn this means a
|
||||
// rewrite, and the append-only shortcut must not paper over it.
|
||||
const local = [userText("u1", "a"), userText("u2", "b"), assistant("tail")];
|
||||
const record = [userText("u2", "b"), steer("s1")];
|
||||
assert.equal(mergeAdopted(local, record), null);
|
||||
});
|
||||
|
||||
it("only the LAST local message may be the absent in-flight tail", () => {
|
||||
// Two absent local messages: not a simple append — bail.
|
||||
const local = [userText("u1", "a"), userText("u2", "gone"), assistant("tail")];
|
||||
const record = [userText("u1", "a"), steer("s1")];
|
||||
assert.equal(mergeAdopted(local, record), null);
|
||||
});
|
||||
|
||||
it("an empty local list adopts the whole record", () => {
|
||||
const record = [steer("s1"), assistant("a1")];
|
||||
assert.deepEqual(mergeAdopted([], record)?.map((m) => m.id), ["s1", "a1"]);
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,11 @@
|
||||
*/
|
||||
import assert from "node:assert";
|
||||
import test from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { LhViewMessage } from "../src/core/view.ts";
|
||||
import { phaseAt3, phaseTimeline, type Phase3 } from "../src/core/phases.ts";
|
||||
|
||||
/** Text-only user message. */
|
||||
function user(text: string): UIMessage {
|
||||
function user(text: string): LhViewMessage {
|
||||
return { id: `u_${text}`, role: "user", parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ function assistantTool(
|
||||
name: string,
|
||||
state: "input-available" | "output-available" | "output-error",
|
||||
output?: unknown,
|
||||
): UIMessage {
|
||||
): LhViewMessage {
|
||||
return {
|
||||
id: `a_${name}_${state}_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "assistant",
|
||||
@@ -37,7 +37,7 @@ const PLAN_OK = { ok: true, tree: { nodes: [{ id: "n1", title: "N" }], edges: []
|
||||
/** The verdict button's user message (the custom-message scheme): its own
|
||||
* message carrying ONLY the data-verdict marker part — no human-authored
|
||||
* text; the model-facing wording is toLlmMessages' (native-messages.ts). */
|
||||
function verdict(accepted: boolean): UIMessage {
|
||||
function verdict(accepted: boolean): LhViewMessage {
|
||||
return {
|
||||
id: `u_verdict_${Math.random().toString(36).slice(2, 6)}`,
|
||||
role: "user",
|
||||
@@ -48,7 +48,7 @@ function verdict(accepted: boolean): UIMessage {
|
||||
}
|
||||
|
||||
/** The accept button's message. */
|
||||
function planAccepted(): UIMessage {
|
||||
function planAccepted(): LhViewMessage {
|
||||
return verdict(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import test from "node:test";
|
||||
import type { PlanTree } from "../src/core/contracts.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { planBlocksFor } from "../src/core/plan-blocks.ts";
|
||||
import { projectNative } from "../src/core/projection.ts";
|
||||
import { projectView as projectNative } from "../src/core/view.ts";
|
||||
import type { PlanBlockData } from "../src/core/contracts.ts";
|
||||
|
||||
// 5-node fixture: a → b → c, b → d, d → e.
|
||||
@@ -83,7 +83,8 @@ function resultOf(callId: string, toolName: string, details: unknown, isError =
|
||||
* new messages are `turn`, run over `record`. Returns the projection of
|
||||
* the whole record after the write. */
|
||||
function projectAfter(record: LhMessage[], turn: LhMessage[], tree: PlanTree | null) {
|
||||
const blocks = planBlocksFor(record, turn, tree, projectNative([...record, ...turn]));
|
||||
// Wave D: the status derivation reads the NATIVE transcript.
|
||||
const blocks = planBlocksFor(record, turn, tree, [...record, ...turn]);
|
||||
return projectNative([...record, ...turn, ...blocks]);
|
||||
}
|
||||
|
||||
@@ -128,8 +129,8 @@ test("idempotent — a call whose block the record already carries is never re-e
|
||||
assert.equal(blocksOf(first).length, 1);
|
||||
// Persist replay: the same turn's messages land again over a record
|
||||
// that already emitted the marker → no duplicate.
|
||||
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, projectNative(turn))];
|
||||
const blocks2 = planBlocksFor(record, turn, TREE, projectNative(record));
|
||||
const record: LhMessage[] = [...turn, ...planBlocksFor([], turn, TREE, turn)];
|
||||
const blocks2 = planBlocksFor(record, turn, TREE, record);
|
||||
assert.equal(blocks2.length, 0, "no duplicate block on persist replay");
|
||||
});
|
||||
|
||||
@@ -138,7 +139,7 @@ test("a plan REWRITE (a later turn's successful plan_init) gets its own full blo
|
||||
assistantWithCall("m1", "plan_init", { tree: TREE }),
|
||||
resultOf("m1", "plan_init", { ok: true, tree: TREE }),
|
||||
];
|
||||
const record = [...first, ...planBlocksFor([], first, TREE, projectNative(first))];
|
||||
const record = [...first, ...planBlocksFor([], first, TREE, first)];
|
||||
const second = [
|
||||
assistantWithCall("m2", "plan_init", { tree: TREE2 }),
|
||||
resultOf("m2", "plan_init", { ok: true, tree: TREE2 }),
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* Reattach transport tests (components/chat-transport.ts, the
|
||||
* ReattachingChatTransport half): the SDK's native resume path —
|
||||
* reconnectToStream — repointed at GET /api/chats/[id]/stream with the
|
||||
* platform contract's two adapters:
|
||||
*
|
||||
* - turn-settled (the "no turn running" TERMINAL EVENT, not a 204) →
|
||||
* cancel + onSettled + null (the SDK's no-active-stream signal);
|
||||
* - the continuation seam — a start chunk whose messageId matches the
|
||||
* seeded last message is rewritten to a fresh id (the SDK seeds its
|
||||
* resume streaming message EMPTY; a replace-last would drop the seeded
|
||||
* message's parts for the whole stream).
|
||||
*
|
||||
* Plus the framing seam: the first SSE event is peeked byte-wise (a body
|
||||
* that arrives split across reads must still resolve its boundary, and
|
||||
* the leftover bytes must ride the head of the returned stream).
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { createReattachingTransport } from "../components/chat-transport.ts";
|
||||
import type { ChatInput } from "../src/core/input-events.ts";
|
||||
|
||||
/** A no-op input-events stash (the reattach tests never send). */
|
||||
const NO_INPUT = { takeInput: () => undefined };
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** SSE-frame each object the way the server's subscriber stream does. */
|
||||
function sse(...events: unknown[]): string {
|
||||
return events.map((e) => `data: ${JSON.stringify(e)}\n\n`).join("");
|
||||
}
|
||||
|
||||
/** A Response whose body drips `text` in `slice`-byte reads. */
|
||||
function dripped(text: string, slice: number, status = 200): Response {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < bytes.length; i += slice) {
|
||||
controller.enqueue(bytes.slice(i, i + slice));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, { status });
|
||||
}
|
||||
|
||||
/** Collect a UIMessageChunk stream to an array. */
|
||||
async function collect(stream: ReadableStream): Promise<unknown[]> {
|
||||
const out: unknown[] = [];
|
||||
const reader = stream.getReader();
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return out;
|
||||
out.push(value);
|
||||
}
|
||||
}
|
||||
|
||||
const RECONNECT_OPTS = {
|
||||
chatId: "chat_x",
|
||||
trigger: "resume-stream" as const,
|
||||
messageId: undefined,
|
||||
};
|
||||
|
||||
describe("reattach transport (reconnectToStream)", () => {
|
||||
it("GETs the stream endpoint for the chat", async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
urls.push(String(input));
|
||||
return dripped(sse({ type: "start", messageId: "msg_run_1" }), 8);
|
||||
}) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
await collect((await transport.reconnectToStream(RECONNECT_OPTS))!);
|
||||
assert.deepEqual(urls, ["/api/chats/chat_x/stream"]);
|
||||
});
|
||||
|
||||
it("turn-settled: fires onSettled, resolves null (the body is released)", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(sse({ type: "turn-settled" }, "[DONE]"), 8)) as typeof globalThis.fetch;
|
||||
let settled = 0;
|
||||
const transport = createReattachingTransport("chat_x", { onSettled: () => settled++ }, NO_INPUT);
|
||||
const stream = await transport.reconnectToStream(RECONNECT_OPTS);
|
||||
assert.equal(stream, null);
|
||||
assert.equal(settled, 1);
|
||||
// (The reader.cancel hygiene is best-effort: undici wraps Response
|
||||
// bodies, so the source-level cancel is not observable here.)
|
||||
});
|
||||
|
||||
it("204 (the SDK's native no-stream signal) resolves null untouched", async () => {
|
||||
globalThis.fetch = (async () => new Response(null, { status: 204 })) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
assert.equal(await transport.reconnectToStream(RECONNECT_OPTS), null);
|
||||
});
|
||||
|
||||
it("a fresh start chunk passes through the SDK parse verbatim", async () => {
|
||||
// slice 13: the boundary lands MID-read, so the peek's read-past
|
||||
// remainder must ride the head (the bug this pins: dropped leftovers).
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(
|
||||
sse(
|
||||
{ type: "start", messageId: "msg_run_1" },
|
||||
{ type: "text-start", id: "msg_run_1-t0" },
|
||||
{ type: "text-delta", id: "msg_run_1-t0", delta: "hi" },
|
||||
),
|
||||
13,
|
||||
)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(chunks[0]!.type, "start");
|
||||
assert.equal(chunks[0]!.messageId, "msg_run_1", "no rewrite when the id is fresh");
|
||||
assert.equal(chunks[1]!.type, "text-start");
|
||||
assert.equal(chunks[2]!.delta, "hi", "the leftover bytes after the peek ride the head");
|
||||
});
|
||||
|
||||
it("a CONTINUATION start chunk (id === seeded last message) is rewritten fresh", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(
|
||||
sse({ type: "start", messageId: "msg_seed" }, { type: "text-start", id: "msg_seed-t3" }),
|
||||
64,
|
||||
)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {
|
||||
lastMessageId: () => "msg_seed",
|
||||
}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.equal(chunks[0]!.type, "start");
|
||||
assert.notEqual(chunks[0]!.messageId, "msg_seed");
|
||||
assert.match(String(chunks[0]!.messageId), /^msg_[a-z0-9]+_[a-f0-9]{8}$/, "the server's id shape");
|
||||
// Part ids stay server-prefixed — nothing keys parts by message id.
|
||||
assert.equal(chunks[1]!.id, "msg_seed-t3");
|
||||
});
|
||||
|
||||
it("survives a body split byte-by-byte across reads", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
dripped(sse({ type: "start", messageId: "msg_run_1" }, { type: "finish", finishReason: "stop" }), 1)) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
const chunks = (await collect((await transport.reconnectToStream(RECONNECT_OPTS))!)) as Array<
|
||||
Record<string, unknown>
|
||||
>;
|
||||
assert.deepEqual(
|
||||
chunks.map((c) => c.type),
|
||||
["start", "finish"],
|
||||
);
|
||||
assert.equal(chunks[0]!.messageId, "msg_run_1");
|
||||
});
|
||||
|
||||
it("an unhappy endpoint throws (the SDK surfaces the error banner)", async () => {
|
||||
globalThis.fetch = (async () => new Response("boom", { status: 500 })) as typeof globalThis.fetch;
|
||||
const transport = createReattachingTransport("chat_x", {}, NO_INPUT);
|
||||
await assert.rejects(() => transport.reconnectToStream(RECONNECT_OPTS), /boom/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,552 @@
|
||||
/**
|
||||
* SESSION MODEL tests (components/session-core.ts — Wave D): the pure state
|
||||
* machine that replaced useChat + the AI-SDK transport. Everything the old
|
||||
* transport/merge suites pinned survives here on the native wire:
|
||||
*
|
||||
* - the 409 IDENTITY-RETRY (postInputEvent): the event was never persisted
|
||||
* pre-acceptance, so the retry waits out the racing turn and re-POSTs
|
||||
* the SAME body — no merge, no record read;
|
||||
* - the reattach path (resumeStream): record refetch, then the SAME
|
||||
* verbatim-AgentEvent consume as a POST; turn-settled is the terminal;
|
||||
* byte-split SSE frames never corrupt (the framing pin);
|
||||
* - append-only ADOPTION (adoptRecord/absorbed): server-added steers show
|
||||
* mid-turn, the record rewrite bails to the idle full sync, and the
|
||||
* optimistic entries the write-ahead absorbed never double their
|
||||
* bubbles;
|
||||
* - the view application (applyWireEvent → projectView): full-partial
|
||||
* replaces, the fork-2 question-placeholder ignore, execution states,
|
||||
* raw error display (owner ruling);
|
||||
* - the continuation fold (needsContinuation): the mount/sync seam's
|
||||
* native replacement for lastAssistantMessageIsCompleteWithToolCalls.
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
import { absorbed, pendingCallTool, SessionModel } from "../components/session-core.ts";
|
||||
import type { ChatInput } from "../src/core/input-events.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
import { newUserMessage, newAnswerResult, newVerdictMessage, needsContinuation } from "../src/core/native-messages.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
/** SSE-frame each object the way the server's subscriber stream does. */
|
||||
function sse(...events: unknown[]): string {
|
||||
return events.map((e) => (e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(e)}\n\n`)).join("");
|
||||
}
|
||||
|
||||
/** A Response whose body drips `text` in `slice`-byte reads (the framing pin). */
|
||||
function dripped(text: string, slice: number, status = 200): Response {
|
||||
const bytes = new TextEncoder().encode(text);
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
for (let i = 0; i < bytes.length; i += slice) {
|
||||
controller.enqueue(bytes.slice(i, i + slice));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new Response(body, { status });
|
||||
}
|
||||
|
||||
/** A plain single-read 200 body. */
|
||||
function sseResponse(...events: unknown[]): Response {
|
||||
return new Response(sse(...events), { status: 200 });
|
||||
}
|
||||
|
||||
function assistantMsg(id: string, text: string): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text }],
|
||||
stopReason: "stop",
|
||||
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||
timestamp: 0,
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
/** A streaming assistant partial (pi events carry the FULL partial). */
|
||||
function partial(id: string, blocks: unknown[], stopReason?: string): Record<string, unknown> {
|
||||
return {
|
||||
type: "message_update",
|
||||
message: {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: blocks,
|
||||
...(stopReason ? { stopReason } : {}),
|
||||
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
},
|
||||
assistantMessageEvent: { type: "text", textDelta: "" },
|
||||
};
|
||||
}
|
||||
|
||||
function model(opts: Partial<ConstructorParameters<typeof SessionModel>[0]> = {}): SessionModel {
|
||||
return new SessionModel({ chatId: "chat_x", initialMessages: [], ...opts });
|
||||
}
|
||||
|
||||
// ─── the 409 identity-retry ────────────────────────────────────────────────
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
body: string | undefined;
|
||||
}
|
||||
|
||||
function installFetch(routes: {
|
||||
chatPOST: (call: Call, attempt: number) => Response;
|
||||
jobsGET?: (poll: number) => boolean;
|
||||
}): Call[] {
|
||||
const calls: Call[] = [];
|
||||
let poll = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
calls.push({ url, body: init?.body as string | undefined });
|
||||
if (url === "/api/chat" && init?.method === "POST") {
|
||||
return routes.chatPOST(calls.at(-1)!, calls.filter((c) => c.url === "/api/chat").length);
|
||||
}
|
||||
if (url.startsWith("/api/jobs")) return Response.json({ turnInFlight: routes.jobsGET?.(++poll) ?? false });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
describe("postInputEvent (the 409 identity-retry)", { skip: process.env.NO_SLOW ? true : undefined }, () => {
|
||||
it("passes non-409 responses through untouched", async () => {
|
||||
const calls = installFetch({ chatPOST: () => new Response("stream", { status: 200 }) });
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await m.postInputEvent({ type: "text", text: "hi" });
|
||||
assert.equal(res.status, 200);
|
||||
assert.equal(calls.length, 1, "no polling, single call");
|
||||
assert.deepEqual(JSON.parse(calls[0]!.body!), { chatId: "chat_x", input: { type: "text", text: "hi" } });
|
||||
});
|
||||
|
||||
it("waits out the turn, then re-POSTs the SAME event body (identity retry)", async () => {
|
||||
const event: ChatInput = { type: "text", text: "my question while the wake turn ran" };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_c, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
jobsGET: (poll) => poll <= 1, // first poll: still running; second: done
|
||||
});
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await m.postInputEvent(event);
|
||||
assert.equal(res.status, 200);
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 2, "one 409, one retry");
|
||||
assert.equal(posts[1]!.body, posts[0]!.body, "the event body is byte-identical");
|
||||
});
|
||||
|
||||
it("never reads the record — the event is the only payload", async () => {
|
||||
const calls = installFetch({
|
||||
chatPOST: (_c, n) => (n === 1 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
});
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
assert.equal((await m.postInputEvent({ type: "text", text: "q" })).status, 200);
|
||||
assert.equal(
|
||||
calls.filter((c) => c.url.startsWith("/api/chats/")).length,
|
||||
0,
|
||||
"the retry never reads the record — the server owns it",
|
||||
);
|
||||
});
|
||||
|
||||
it("loops on repeated 409s, re-POSTing the same event each time", async () => {
|
||||
const event: ChatInput = { type: "answer", toolCallId: "call-1", answer: { correct: true } as never };
|
||||
const calls = installFetch({
|
||||
chatPOST: (_c, n) => (n <= 2 ? new Response("busy", { status: 409 }) : new Response("stream", { status: 200 })),
|
||||
});
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
assert.equal((await m.postInputEvent(event)).status, 200);
|
||||
const posts = calls.filter((c) => c.url === "/api/chat");
|
||||
assert.equal(posts.length, 3);
|
||||
assert.ok(posts.every((c) => c.body === posts[0]!.body), "every attempt carries the same event");
|
||||
});
|
||||
|
||||
it("gives up after the deadline and surfaces the last 409", async () => {
|
||||
const calls = installFetch({
|
||||
chatPOST: () => new Response("busy", { status: 409 }),
|
||||
jobsGET: () => true,
|
||||
});
|
||||
const m = model({ pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await m.postInputEvent({ type: "text", text: "q" });
|
||||
assert.equal(res.status, 409);
|
||||
const polls = calls.filter((c) => c.url.startsWith("/api/jobs")).length;
|
||||
assert.ok(polls <= 15, `expected bounded polling (~10×10ms), got ${polls}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── send: optimistic appends + wire consumption ────────────────────────────
|
||||
|
||||
describe("send (one input event, verbatim consumption)", () => {
|
||||
it("optimistically appends the text, POSTs {chatId, input}, consumes to [DONE]", async () => {
|
||||
const posts: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/chat" && init?.method === "POST") {
|
||||
posts.push(init.body as string);
|
||||
return sseResponse(
|
||||
{ type: "agent_start" },
|
||||
{ type: "message_start", message: { role: "assistant", id: "a1", content: [] } },
|
||||
partial("a1", [{ type: "text", text: "hello there" }]),
|
||||
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hello there" }], stopReason: "stop" } },
|
||||
{ type: "agent_end", messages: [] },
|
||||
"[DONE]",
|
||||
);
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
|
||||
const m = model();
|
||||
const pending = m.send({ type: "text", text: "my topic" });
|
||||
// The optimistic bubble is INSTANT (the instant-answer UX seam):
|
||||
assert.equal(m.getState().status, "submitted");
|
||||
assert.match((m.getState().nativeMessages.at(-1) as { id?: string }).id ?? "", /^c_/);
|
||||
assert.equal(m.getState().messages.at(-1)!.parts[0]!.text, "my topic");
|
||||
await pending;
|
||||
assert.deepEqual(JSON.parse(posts[0]!), { chatId: "chat_x", input: { type: "text", text: "my topic" } });
|
||||
assert.equal(m.getState().status, "ready");
|
||||
// The streamed assistant REPLACED through full partials (never deltas):
|
||||
const view = m.getState().messages;
|
||||
assert.equal(view.at(-1)!.role, "assistant");
|
||||
assert.equal(view.at(-1)!.parts.at(-1)!.text, "hello there");
|
||||
});
|
||||
|
||||
it("a 503 refusal surfaces the RAW body text (parseModelsRefusal's contract)", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
new Response(JSON.stringify({ code: "models_not_configured", missing: ["strong"] }), {
|
||||
status: 503,
|
||||
})) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
await m.send({ type: "text", text: "q" });
|
||||
const state = m.getState();
|
||||
assert.equal(state.status, "error");
|
||||
assert.equal(
|
||||
state.error!.message,
|
||||
JSON.stringify({ code: "models_not_configured", missing: ["strong"] }),
|
||||
"the banner carries the raw envelope — the refusal flow JSON-parses it",
|
||||
);
|
||||
});
|
||||
|
||||
it("the optimistic answer result attaches to the pending quiz part at once", async () => {
|
||||
const quizCall = {
|
||||
id: "call-1",
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "text", text: "Q?" },
|
||||
{ type: "toolCall", id: "call-1", name: "quiz", arguments: { question: "Q?", choices: ["a", "b"] } },
|
||||
],
|
||||
stopReason: "toolUse",
|
||||
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
timestamp: 0,
|
||||
} as unknown as LhMessage;
|
||||
let postBody = "";
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input) === "/api/chat") {
|
||||
postBody = init?.body as string;
|
||||
return sseResponse("[DONE]");
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${String(input)}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model({ initialMessages: [newUserMessage("hi"), quizCall] });
|
||||
const pending = m.submitAnswer("quiz", "call-1", { correct: true } as never);
|
||||
// The dock's AnsweredCard state — before any server round-trip:
|
||||
const state = m.getState();
|
||||
const quizPart = state.messages
|
||||
.flatMap((v) => v.parts)
|
||||
.find((p) => p.type === "tool-quiz")!;
|
||||
assert.equal(quizPart.state, "output-available");
|
||||
assert.deepEqual(quizPart.output, { correct: true });
|
||||
await pending;
|
||||
assert.deepEqual(JSON.parse(postBody), {
|
||||
chatId: "chat_x",
|
||||
input: { type: "answer", toolCallId: "call-1", answer: { correct: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores the question placeholder's clean execution end (fork 2) and keeps errors", async () => {
|
||||
// The dock's mount gate keys on the part STATE the live execution
|
||||
// layer drives MID-STREAM: a clean question execution stays
|
||||
// input-available (the in-memory "Question posed" placeholder
|
||||
// never shows); an INVALID call is output-error and NEVER mounts
|
||||
// (the modal-realism crash pin).
|
||||
const enc = new TextEncoder();
|
||||
let push: (e: unknown) => void = () => {};
|
||||
const body = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
push = (e) => controller.enqueue(enc.encode(e === "[DONE]" ? "data: [DONE]\n\n" : `data: ${JSON.stringify(e)}\n\n`));
|
||||
},
|
||||
});
|
||||
globalThis.fetch = (async () => new Response(body, { status: 200 })) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
const done = m.send({ type: "text", text: "q" });
|
||||
const settle = () => new Promise((r) => setTimeout(r, 0));
|
||||
push({
|
||||
type: "message_start",
|
||||
message: {
|
||||
role: "assistant",
|
||||
id: "a1",
|
||||
content: [
|
||||
{ type: "toolCall", id: "q1", name: "quiz", arguments: {} },
|
||||
{ type: "toolCall", id: "bad1", name: "quiz", arguments: {} },
|
||||
],
|
||||
},
|
||||
});
|
||||
await settle();
|
||||
push({ type: "tool_execution_end", toolCallId: "q1", toolName: "quiz", isError: false, result: { content: [{ type: "text", text: "Question posed" }], details: { placeholder: true } } });
|
||||
push({ type: "tool_execution_end", toolCallId: "bad1", toolName: "quiz", isError: true, result: { content: [{ type: "text", text: "correctAnswer does not match any option value" }], details: undefined } });
|
||||
await settle();
|
||||
const parts = m.getState().messages.flatMap((v) => v.parts).filter((p) => p.type === "tool-quiz");
|
||||
assert.equal(parts.length, 2);
|
||||
assert.equal(parts[0]!.state, "input-available", "the clean placeholder is IGNORED — the dock mounts on it");
|
||||
assert.equal(parts[1]!.state, "output-error");
|
||||
assert.equal(parts[1]!.errorText, "correctAnswer does not match any option value");
|
||||
push({ type: "agent_end", messages: [] });
|
||||
push("[DONE]");
|
||||
await done;
|
||||
});
|
||||
|
||||
it("a provider error rides VERBATIM (owner ruling) — no masking anywhere", async () => {
|
||||
globalThis.fetch = (async () =>
|
||||
sseResponse(
|
||||
partial("a1", [{ type: "text", text: "" }]),
|
||||
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "" }], stopReason: "error", errorMessage: "OpenRouter 429: rate limited" } },
|
||||
{ type: "agent_end", messages: [] },
|
||||
"[DONE]",
|
||||
)) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
await m.send({ type: "text", text: "q" });
|
||||
assert.equal(m.getState().status, "ready");
|
||||
assert.equal(m.getState().error!.message, "OpenRouter 429: rate limited");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||
|
||||
describe("resumeStream (the reattach path)", () => {
|
||||
it("refetches the record, then GETs the stream endpoint", async () => {
|
||||
const urls: string[] = [];
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
urls.push(url);
|
||||
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||
if (url === "/api/chats/chat_x/stream")
|
||||
return dripped(sse({ type: "message_start", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hi" }] } }, "[DONE]"), 8);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
await m.resumeStream();
|
||||
assert.deepEqual(urls, ["/api/chats/chat_x", "/api/chats/chat_x/stream"]);
|
||||
assert.equal(m.getState().status, "ready");
|
||||
});
|
||||
|
||||
it("turn-settled fires the settle hook and ends cleanly", async () => {
|
||||
let settled = 0;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||
if (url === "/api/chats/chat_x/stream") return dripped(sse({ type: "turn-settled" }, "[DONE]"), 8);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model({ onReattachSettled: () => settled++ });
|
||||
await m.resumeStream();
|
||||
assert.equal(settled, 1);
|
||||
assert.equal(m.getState().status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("a replayed CONTINUATION mounts BESIDE the seeded message (the SDK replace-last seam is dead)", async () => {
|
||||
// The seeded record ends with an assistant message; the replayed
|
||||
// turn continues with its OWN assistant message — the view groups
|
||||
// them as one document with a step separator (pixel-identical to a
|
||||
// live continuation; the settle sync lands the canon).
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/chats/chat_x") return Response.json({ messages: [assistantMsg("seed", "the question")] });
|
||||
if (url === "/api/chats/chat_x/stream")
|
||||
return sseResponse(
|
||||
{ type: "message_start", message: { role: "assistant", id: "run1", content: [{ type: "text", text: "the reaction" }] } },
|
||||
{ type: "message_end", message: { role: "assistant", id: "run1", content: [{ type: "text", text: "the reaction" }], stopReason: "stop" } },
|
||||
{ type: "agent_end", messages: [] },
|
||||
"[DONE]",
|
||||
);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model({ initialMessages: [assistantMsg("seed", "the question")] });
|
||||
await m.resumeStream();
|
||||
const view = m.getState().messages;
|
||||
assert.equal(view.length, 1, "one continuous assistant document");
|
||||
assert.deepEqual(
|
||||
view[0]!.parts.map((p) => p.type),
|
||||
["step-start", "text", "step-start", "text"],
|
||||
"seeded message keeps rendering; the run mounts with a step separator",
|
||||
);
|
||||
});
|
||||
|
||||
it("survives a body split byte-by-byte across reads (the framing pin)", async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||
if (url === "/api/chats/chat_x/stream")
|
||||
return dripped(
|
||||
sse(
|
||||
{ type: "message_start", message: { role: "assistant", id: "a1", content: [] } },
|
||||
partial("a1", [{ type: "text", text: "hi" }]),
|
||||
{ type: "message_end", message: { role: "assistant", id: "a1", content: [{ type: "text", text: "hi" }], stopReason: "stop" } },
|
||||
) + sse("[DONE]"),
|
||||
1,
|
||||
);
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
await m.resumeStream();
|
||||
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "hi");
|
||||
});
|
||||
|
||||
it("an unhappy endpoint surfaces the error banner", async () => {
|
||||
globalThis.fetch = (async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/chats/chat_x") return Response.json({ messages: [] });
|
||||
if (url === "/api/chats/chat_x/stream") return new Response("boom", { status: 500 });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
const m = model();
|
||||
await m.resumeStream();
|
||||
const state = m.getState();
|
||||
assert.equal(state.status, "error");
|
||||
assert.match(state.error!.message, /boom/);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── adoption (append-only ground rules, owner 2026-09-15) ──────────────────
|
||||
|
||||
function userText(id: string, text: string): LhMessage {
|
||||
return { ...newUserMessage(text), id };
|
||||
}
|
||||
|
||||
describe("adoptRecord — append-only adoption", () => {
|
||||
it("splices a server-added steer between the record and the live tail", () => {
|
||||
const m = model({ initialMessages: [userText("u1", "topic")] });
|
||||
m.applyWireEvent({ type: "message_start", message: { role: "assistant", id: "a_live", content: [{ type: "text", text: "streaming" }] } });
|
||||
const steer = { ...newUserMessage("steered content"), id: "steer_1" } as LhMessage & { role: "user" };
|
||||
const record = [userText("u1", "topic"), steer];
|
||||
m.adoptRecord(record);
|
||||
assert.deepEqual(
|
||||
m.getState().nativeMessages.map((x) => x.id),
|
||||
["u1", "steer_1", "a_live"],
|
||||
"the steer shows NOW, the live tail stays last",
|
||||
);
|
||||
});
|
||||
|
||||
it("a missing base id (record rewrite) bails — the idle full sync is the net", () => {
|
||||
const m = model({ initialMessages: [userText("u1", "a"), userText("u2", "b")] });
|
||||
m.adoptRecord([userText("u2", "b")]); // u1 vanished: a rewrite, not an append
|
||||
assert.deepEqual(
|
||||
m.getState().nativeMessages.map((x) => x.id),
|
||||
["u1", "u2"],
|
||||
"the adoption is a no-op on a non-append",
|
||||
);
|
||||
});
|
||||
|
||||
it("absorbs the optimistic entries the record has taken over (no doubled bubbles)", () => {
|
||||
// A text send's optimistic bubble (local c_ id): the write-ahead's
|
||||
// server copy absorbs it by CONTENT — the bubble never doubles.
|
||||
const optimistic = { ...newUserMessage("my question"), id: "c_local_1" } as LhMessage;
|
||||
const serverCopy = { ...newUserMessage("my question"), id: "srv_9" } as LhMessage;
|
||||
assert.ok(absorbed(optimistic, [userText("u1", "topic"), serverCopy]), "content match absorbs the local copy");
|
||||
assert.ok(!absorbed(serverCopy, [optimistic]), "server stamps are never absorbed");
|
||||
assert.ok(!absorbed({ ...newUserMessage("my question") }, [serverCopy]), "an id-less message is never absorbed");
|
||||
// The answer result absorbs by toolCallId (ids differ, content may too —
|
||||
// the toolCallId is the identity of an answer).
|
||||
const answer = { ...newAnswerResult("call-1", "quiz", { correct: true }), id: "c_local_2" } as LhMessage;
|
||||
assert.ok(
|
||||
absorbed(answer, [{ ...newAnswerResult("call-1", "quiz", { correct: true }), id: "srv_10" } as LhMessage]),
|
||||
"the answer result absorbs by toolCallId",
|
||||
);
|
||||
// A verdict marker absorbs by accepted (the timestamps differ by design).
|
||||
const verdict = { ...newVerdictMessage(true, "2026-09-26T00:00:00.000Z"), id: "c_local_3" } as LhMessage;
|
||||
const serverVerdict = { ...newVerdictMessage(true, "2026-09-26T00:00:01.000Z"), id: "srv_11" } as LhMessage;
|
||||
assert.ok(absorbed(verdict, [serverVerdict]), "the verdict absorbs by accepted");
|
||||
});
|
||||
|
||||
it("setMessages is the full snapshot swap (the idle settle sync)", () => {
|
||||
const m = model({ initialMessages: [userText("u1", "old")] });
|
||||
m.setMessages([userText("u1", "old"), userText("u2", "new"), assistantMsg("a1", "reply")]);
|
||||
assert.deepEqual(
|
||||
m.getState().nativeMessages.map((x) => (x as { id?: string }).id),
|
||||
["u1", "u2", "a1"],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the continuation fold (mount/sync seam) ────────────────────────────────
|
||||
|
||||
function assistantWithCall(id: string, callId: string, name = "research"): LhMessage {
|
||||
return {
|
||||
id,
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: callId, name, arguments: { topic: "x" } }],
|
||||
stopReason: "toolUse",
|
||||
usage: { input: 0, output: 0, total: 0, cacheReads: 0, cacheWrites: 0 },
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
timestamp: 0,
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
function resultMsg(callId: string, name = "research"): LhMessage {
|
||||
return {
|
||||
...newAnswerResult(callId, name, { ok: true }),
|
||||
id: `r_${callId}`,
|
||||
} as unknown as LhMessage;
|
||||
}
|
||||
|
||||
describe("needsContinuation (the native lastAssistantMessageIsCompleteWithToolCalls)", () => {
|
||||
it("a record ending on a fully-answered tool call continues", () => {
|
||||
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-r1"), resultMsg("call-r1")];
|
||||
assert.equal(needsContinuation(messages), true);
|
||||
});
|
||||
|
||||
it("text-tailed records never continue", () => {
|
||||
const messages = [userText("u1", "topic"), assistantMsg("a1", "done talking")];
|
||||
assert.equal(needsContinuation(messages), false);
|
||||
});
|
||||
|
||||
it("a dangling question never fires a turn (the dock owns it)", () => {
|
||||
const messages = [userText("u1", "topic"), assistantWithCall("a1", "call-q1", "quiz")];
|
||||
assert.equal(needsContinuation(messages), false);
|
||||
});
|
||||
|
||||
it("parallel quizzes: only ALL-answered continues (the dock still owns the open one)", () => {
|
||||
const messages = [
|
||||
userText("u1", "topic"),
|
||||
{
|
||||
...assistantWithCall("a1", "call-a"),
|
||||
content: [
|
||||
{ type: "toolCall", id: "call-a", name: "quiz", arguments: {} },
|
||||
{ type: "toolCall", id: "call-b", name: "quiz", arguments: {} },
|
||||
],
|
||||
} as unknown as LhMessage,
|
||||
resultMsg("call-a", "quiz"),
|
||||
];
|
||||
assert.equal(needsContinuation(messages), false);
|
||||
const both = [...messages, resultMsg("call-b", "quiz")];
|
||||
assert.equal(needsContinuation(both), true, "once the dock answers B, the agent owes the reaction");
|
||||
});
|
||||
|
||||
it("an orphan result (no asking assistant) never continues", () => {
|
||||
assert.equal(needsContinuation([userText("u1", "topic"), resultMsg("call-x")]), false);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── the pending-call tool scan ─────────────────────────────────────────────
|
||||
|
||||
describe("pendingCallTool", () => {
|
||||
it("finds the dangling call's tool name (the optimistic answer's stamp)", () => {
|
||||
const messages = [assistantWithCall("a1", "call-q1", "ask_user_question")];
|
||||
assert.equal(pendingCallTool(messages, "call-q1"), "ask_user_question");
|
||||
assert.equal(pendingCallTool(messages, "call-zz"), null);
|
||||
});
|
||||
});
|
||||
@@ -7,11 +7,11 @@
|
||||
*/
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
import type { LhViewMessage } from "../src/core/view.ts";
|
||||
import { streamingToolActivity } from "../src/core/pending.ts";
|
||||
|
||||
function msg(role: string, parts: unknown[]): UIMessage {
|
||||
return { id: "m1", role, parts } as unknown as UIMessage;
|
||||
function msg(role: string, parts: unknown[]): LhViewMessage {
|
||||
return { id: "m1", role, parts } as unknown as LhViewMessage;
|
||||
}
|
||||
|
||||
function toolPart(tool: string, state: string) {
|
||||
|
||||
Reference in New Issue
Block a user