Wave B: the native message record — input events, projection, adapter death
The chat record is now pi-native agent history (LhMessage[]), the AI SDK's
UIMessage transcript contract is gone from the server, and every client turn
arrives as ONE explicit input event.
Core (new):
- src/core/native-messages.ts — the LhMessage taxonomy (user/assistant/
toolResult + lhMarker customs: verdict, probe-handoff, subagent-steer,
subagent-abort, plan-block), constructors, and toLlmMessages — the single
model-facing seam (convertToLlm): strips ids, flattens markers to user
text, drops UI-only plan-blocks, synthesizes INTERRUPTED results for
dangling aborted calls, never throws.
- src/core/projection.ts — projectNative: the one-way native → UIMessage
projection (consecutive assistant+toolResult runs group into one
assistant view message; toolResults attach backward by toolCallId;
markers project to data parts; plan-blocks splice after the last
successful plan_init). Every existing consumer reads through it.
- src/core/input-events.ts — the ChatInput union (text/answer/verdict/
continue), parseChatInput validation, and inputAppends (answer targets
the dangling question call or 400s; verdict = optional note user
message + native verdict marker).
Server:
- chat-handler.ts — the turn root takes {chatId, input}; write-ahead
persists input appends + claimed steers before the turn; claimSteers
coalesces the whole queue with the handler's steerDelivered filter as
the dedupe seam; teacher-vs-prober branch on hasProbeDone.
- turn-service.ts — persistTurn stamps ids, drops clean question
placeholder toolResults (the record ends at the unanswered call),
emits plan-block markers idempotently (plan-blocks.ts rewrite), and
persists abort partials.
- ui-stream.ts — PiTurnSpec takes the native base; the wire dialect is
unchanged. jobs.ts steers are native markers (steer_<jobId> dedup ids);
waitForIdle reads danglingQuestionOf. teacher-agent.ts wires
convertToLlm: toLlmMessages. record-adapter.ts DELETED wholesale.
- GET /api/chats/[id] serves messages: projectNative(record.messages).
Client:
- chat-transport.ts — the 409 retry is IDENTITY (re-POST the same event
body after the turn settles — the event was never persisted
pre-acceptance; the merge logic is gone). The transport consumes the
stashed input event via prepareSendMessagesRequest and throws loudly on
a missing stash.
- chat.tsx — every send call site stashes exactly one ChatInput event
(text, answer, verdict, continue) before invoking the SDK send.
Tests: full native migration (chat-flow rewritten to input events and
native record pins; jobs/extraction/demos/graph/usage/plan-blocks/
probe-done/reattach-transport converted; record-adapter.test.ts deleted;
chat-transport tests pin the identity retry). 982/982, typecheck, lint,
build all green.
This commit is contained in:
+73
-26
@@ -17,9 +17,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import * as fs from "node:fs";
|
||||
import { after, describe, it } from "node:test";
|
||||
import type { UIMessage } from "ai";
|
||||
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 { getGraph, graphSnapshot, mergeFindings, type GraphDeps } from "../src/core/graph.ts";
|
||||
import { coverageSlots } from "../src/core/coverage.ts";
|
||||
import { graphStore } from "../src/store/graph.ts";
|
||||
@@ -32,9 +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: UIMessage[]; foldedAt: string } {
|
||||
function sessionSource(chatId: string): { messages: ReturnType<typeof projectNative>; 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: record.messages as UIMessage[], foldedAt: record.updatedAt };
|
||||
return { messages: projectNative(record.messages), foldedAt: record.updatedAt };
|
||||
}
|
||||
|
||||
// ─── ledger isolation (backup-and-clear of data/graph/*.jsonl) ──────────────
|
||||
@@ -72,20 +76,65 @@ after(() => {
|
||||
|
||||
// ─── fixture builders ────────────────────────────────────────────────────────
|
||||
|
||||
function assistant(id: string, parts: unknown[]): UIMessage {
|
||||
return { id, role: "assistant", parts } as unknown as UIMessage;
|
||||
// Native fixture builders (Wave B): the record holds pi-native history —
|
||||
// an assistant message carrying tool calls + one tool result per call. The
|
||||
// projection reproduces the old part shapes (state output-available, input
|
||||
// = arguments, output = result details).
|
||||
interface NativeCall {
|
||||
name: string;
|
||||
toolCallId: string;
|
||||
input: Record<string, unknown>;
|
||||
output: QuestionAnswerData;
|
||||
}
|
||||
|
||||
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 [{ id, role: "user", content: text, timestamp: Date.now() } as never];
|
||||
}
|
||||
|
||||
function assistant(id: string, calls: NativeCall[]): LhMessage[] {
|
||||
const content = calls.map((c) => ({ type: "toolCall", id: c.toolCallId, name: c.name, arguments: c.input as never }));
|
||||
const out: LhMessage[] = [
|
||||
{
|
||||
id,
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "openai-completions",
|
||||
provider: "openrouter",
|
||||
model: "test",
|
||||
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
} as never,
|
||||
];
|
||||
for (const c of calls) {
|
||||
out.push({
|
||||
id: `r_${c.toolCallId}`,
|
||||
role: "toolResult",
|
||||
toolCallId: c.toolCallId,
|
||||
toolName: c.name,
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
details: c.output,
|
||||
isError: false,
|
||||
timestamp: Date.now(),
|
||||
} as never);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function quizPart(
|
||||
toolCallId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: QuestionAnswerData,
|
||||
): unknown {
|
||||
return { type: "tool-quiz", toolCallId, state: "output-available", input, output };
|
||||
): NativeCall {
|
||||
return { name: "quiz", toolCallId, input, output };
|
||||
}
|
||||
|
||||
function askPart(
|
||||
toolCallId: string,
|
||||
input: Record<string, unknown>,
|
||||
output: QuestionAnswerData,
|
||||
): NativeCall {
|
||||
return { name: "ask_user_question", toolCallId, input, output };
|
||||
}
|
||||
|
||||
function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||
@@ -102,7 +151,7 @@ function answer(over: Partial<QuestionAnswerData>): QuestionAnswerData {
|
||||
|
||||
/** Create a chat in the real store with messages; returns the record.
|
||||
* Tree-blind world: the plan tree is irrelevant to the merger — no tree. */
|
||||
function fixtureChat(title: string, messages: UIMessage[]) {
|
||||
function fixtureChat(title: string, messages: LhMessage[]) {
|
||||
const chat = chatStore.createChat(title);
|
||||
createdChats.push(chat.id);
|
||||
chatStore.saveMessages(chat.id, messages);
|
||||
@@ -110,7 +159,7 @@ function fixtureChat(title: string, messages: UIMessage[]) {
|
||||
}
|
||||
|
||||
/** The full fixture transcript: five graded quizzes + a dismissed one + an ask. */
|
||||
function fixtureMessages(): UIMessage[] {
|
||||
function fixtureMessages(): LhMessage[] {
|
||||
return [
|
||||
user("u1", "teach me the fixture topic"),
|
||||
assistant("a1", [
|
||||
@@ -157,15 +206,13 @@ function fixtureMessages(): UIMessage[] {
|
||||
{ question: "Q6", options: [{ label: "a" }, { label: "b" }], correctAnswer: "a", explanation: "e" },
|
||||
answer({ questionId: "call-6", questionText: "Q6 text", dismissed: true }),
|
||||
),
|
||||
{
|
||||
type: "tool-ask_user_question",
|
||||
toolCallId: "call-7",
|
||||
state: "output-available",
|
||||
input: { question: "free text?" },
|
||||
output: answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||
},
|
||||
askPart(
|
||||
"call-7",
|
||||
{ question: "free text?" },
|
||||
answer({ questionId: "call-7", kind: "ask", questionText: "free text?", text: "because" }),
|
||||
),
|
||||
]),
|
||||
];
|
||||
].flat();
|
||||
}
|
||||
|
||||
/** The auditor's verdict for the fixture chat: three verified propositions
|
||||
@@ -332,7 +379,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
answer({ questionId: "call-9", questionText: "Q9 text", correct: true, answeredAt: "2026-08-22T03:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
chat2Id = chat2.id;
|
||||
|
||||
// The auditor's identity verdict: this proposition IS the alpha node.
|
||||
@@ -473,7 +520,7 @@ describe("the merger (mergeFindings)", () => {
|
||||
});
|
||||
|
||||
it("never string-matches without a verdict — an identical statement without linkTo is a NEW node", () => {
|
||||
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")]);
|
||||
const chat3 = fixtureChat("Graph fixture 3", [user("u1", "third")].flat());
|
||||
chat3Id = chat3.id;
|
||||
const stats = mergeFindings(
|
||||
chat3.id,
|
||||
@@ -623,7 +670,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
answer({ questionId: "d-call-1", questionText: "DQ1 text", correct: true, answeredAt: "2026-09-23T00:00:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
const auditA: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "p1", name: "Shared core", statement: "Shared core statement", aliases: ["shared feel"], messages: ["du1"], evidence: ["e"], understanding: "solid" },
|
||||
@@ -651,7 +698,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
answer({ questionId: "e-call-1", questionText: "EQ1 text", correct: false, answeredAt: "2026-09-23T00:01:00.000Z" }),
|
||||
),
|
||||
]),
|
||||
]);
|
||||
].flat());
|
||||
const auditB: AuditResult = {
|
||||
propositions: [
|
||||
{ id: "q1", name: "Shared again", statement: "Shared core, rephrased", aliases: ["B phrasing"], messages: ["eu1"], evidence: ["e"], linkTo: xId, understanding: "shaky" },
|
||||
@@ -976,7 +1023,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
user("cov_u1", "tell me about coverage reads"),
|
||||
user("cov_u2", "and about per-session blocks"),
|
||||
user("cov_u3", "a third distinct message"),
|
||||
]);
|
||||
].flat());
|
||||
const empty = sessionCoverage(chat.id)!;
|
||||
assert.ok(empty);
|
||||
assert.equal(empty.totalMessages, 3);
|
||||
@@ -989,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(chatStore.getChat(chat.id)!.messages as UIMessage[]);
|
||||
const slots = coverageSlots(projectNative(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.
|
||||
@@ -1005,7 +1052,7 @@ describe("session coverage read (extraction-UX redesign, 2026-09-24)", () => {
|
||||
});
|
||||
|
||||
it("running: true only for THIS session's chat-kind run (the registry pin)", () => {
|
||||
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")]);
|
||||
const chat = fixtureChat("coverage running", [user("cov_r1", "running pin")].flat());
|
||||
const g = globalThis as { __lhExtractionRuns?: unknown };
|
||||
const realRuns = g.__lhExtractionRuns;
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user