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:
amosblomqvist
2026-09-26 00:37:46 +03:00
parent b87a6925af
commit 7ab47d4fcb
36 changed files with 2410 additions and 1447 deletions
+78 -40
View File
@@ -50,16 +50,15 @@ import { runDemoBuilder } from "../src/agent/subagents/demo-builder.ts";
import { headlessOverride, type DemoRunResult, type HeadlessApi } from "../src/agent/subagents/headless.ts";
import { createJobRunner, demoModelOverride, visualModelOverride } from "../src/agent/subagents/runner.ts";
import { runVisualMaker } from "../src/agent/subagents/visual-maker.ts";
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SubagentSteerMessage } from "../src/core/contracts.ts";
import { buildAbortSteer, buildSubagentSteer, isSubagentFailure, type SteerMessage } from "../src/core/contracts.ts";
import type { JobRecord } from "../src/core/contracts.ts";
import { createTeacherTools } from "../src/agent/tools.ts";
import { chatStore } from "../src/store/chats.ts";
import { jobStore } from "../src/store/jobs.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { createTeacherAgent, type TeachingAgent } from "../src/agent/teacher-agent.ts";
import { recordToPiMessages } from "../src/server/pi/record-adapter.ts";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { projectNative } from "../src/core/projection.ts";
import type { PiEngine } from "../src/agent/models.ts";
import type { UIMessage } from "ai";
/**
* A fake deliver seam + a runner wired like the chat-handler's, per test:
@@ -67,13 +66,13 @@ import type { UIMessage } from "ai";
* record reads exactly as production does after the wake turn completes.
*/
function testRunner() {
const steers: SubagentSteerMessage[] = [];
const steers: SteerMessage[] = [];
const runner = createJobRunner({
chatStore,
jobStore,
deliver: async (steer) => {
steers.push(steer);
const jobId = steer.metadata.subagentResult.jobId;
const jobId = (steer.details as { jobId: string }).jobId;
const job = jobStore.getJob(jobId);
if (job && job.status === "running") {
const failed = job.result ? isSubagentFailure(job.kind, job.result as never) : true;
@@ -349,7 +348,7 @@ describe("steer card rendering (the delivered artifact)", () => {
{ id: "jd", kind: "demo", toolCallId: "cd" },
{ title: "Damped oscillator", html: DEMO_HTML, tested: true },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /sandbox="allow-scripts"/);
assert.ok(!html.includes("allow-same-origin"), "never allow-same-origin");
assert.match(html, /demo-resize/);
@@ -365,7 +364,7 @@ describe("steer card rendering (the delivered artifact)", () => {
{ id: "jd", kind: "demo", toolCallId: "cd" },
{ title: "T", html: "", tested: true, notes: "FAILED: never passed verification" },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /FAILED: never passed verification/);
assert.ok(!html.includes("<iframe"), "no iframe for a failed build");
});
@@ -376,7 +375,7 @@ describe("steer card rendering (the delivered artifact)", () => {
{ id: "jv", kind: "visual", toolCallId: "cv" },
{ svg, notes: "Looking at the render, the diagram is clean and accurate." },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.ok(html.includes("circle"), "svg content rendered inline");
assert.match(html, /Figure/);
assert.match(html, /svg\]:w-full/, "svg scales to the display area's full width");
@@ -385,16 +384,19 @@ describe("steer card rendering (the delivered artifact)", () => {
it("renders the abort chip for an aborted job's steer (2026-09 dock semantics; the message type is the marker PART)", () => {
const steer = buildAbortSteer({ id: "jc", kind: "research", toolCallId: "cc" });
assert.equal(steer.metadata.subagentResult.aborted, true);
assert.equal(steer.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
// Wave B: the steer is a native marker — the card renders its
// PROJECTION (the same view the transcript renderer hands it).
const view = projectNative([steer])[0]!;
assert.equal((steer.details as { aborted: boolean }).aborted, true);
assert.equal(view.parts.length, 2, "text (teacher-facing) + the data-subagent-aborted marker part");
const html = renderToStaticMarkup(<SubagentSteerCard message={view as never} />);
assert.match(html, /job-aborted-badge/);
assert.match(html, /research/, "the job's kind, lower-cased like the complete-badge idiom");
assert.match(html, /aborted by the learner/);
// the teacher-facing protocol text never renders for the learner
assert.ok(!html.includes("no brief is coming"), "no protocol glue in the chip");
// the badge renders off the PART, not metadata (the custom-message scheme)
const noMeta = { ...steer, metadata: {} } as typeof steer;
const noMeta = { ...view, metadata: {} };
const htmlNoMeta = renderToStaticMarkup(<SubagentSteerCard message={noMeta as never} />);
assert.match(htmlNoMeta, /job-aborted-badge/, "metadata is pump mechanics, never the render seam");
});
@@ -410,7 +412,7 @@ describe("steer card rendering (the delivered artifact)", () => {
],
},
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /Research/);
assert.match(html, /complete/);
assert.match(html, /2 sources/);
@@ -435,7 +437,7 @@ describe("steer card rendering (the delivered artifact)", () => {
pagesConsulted: [1, 2],
},
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /PDF/);
assert.match(html, /reading complete/);
assert.match(html, /read 1 page/, "the badge counts READ pages, not search hits");
@@ -451,7 +453,7 @@ describe("steer card rendering (the delivered artifact)", () => {
{ id: "jpf2", kind: "pdf", toolCallId: "cpf2" },
{ path: "/tmp/doc.pdf", question: "q", brief: "PDF failed: the file could not be opened", pagesRead: [], pagesConsulted: [] },
);
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /pdf-brief-card/);
assert.match(html, /failed/);
assert.match(html, /could not be opened/);
@@ -460,7 +462,7 @@ describe("steer card rendering (the delivered artifact)", () => {
it("renders the visual failure note with no svg when the build FAILED", () => {
const steer = buildSubagentSteer({ id: "jv", kind: "visual", toolCallId: "cv" }, { svg: "", notes: "FAILED: iteration limit" });
const html = renderToStaticMarkup(<SubagentSteerCard message={steer as never} />);
const html = renderToStaticMarkup(<SubagentSteerCard message={projectNative([steer])[0] as never} />);
assert.match(html, /FAILED: iteration limit/);
assert.ok(!html.includes("circle") && !html.includes("<svg"), "no svg for a failed build");
});
@@ -675,14 +677,32 @@ describe("demo/visual job runners", () => {
it("runJob completes a demo: result on the job, steer via deliver, ack stays on the part", async () => {
const chat = chatStore.createChat("Test: demo runner");
createdChats.push(chat.id);
// Wave B: a NATIVE fixture (the record holds agent history) — the
// runner's transcript scan reads it through the projection.
chatStore.saveMessages(chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "show me compounding" }] },
{ id: "u1", role: "user", content: "show me compounding", timestamp: 1 } as never,
{
id: "a1",
role: "assistant",
parts: [{ type: "tool-demo", toolCallId: "call-d1", state: "output-available", input: DEMO_INPUT, output: "Dispatched." }],
},
] as never);
content: [{ type: "toolCall", id: "call-d1", name: "demo", arguments: DEMO_INPUT as never }],
api: "openai-completions",
provider: "openrouter",
model: "test",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
stopReason: "stop",
timestamp: 1,
} as never,
{
id: "r1",
role: "toolResult",
toolCallId: "call-d1",
toolName: "demo",
content: [{ type: "text", text: "Dispatched." }],
details: "Dispatched.",
isError: false,
timestamp: 1,
} as never,
]);
const job = jobStore.createJob({ kind: "demo", chatId: chat.id, toolCallId: "call-d1" });
createdJobs.push(job.id);
@@ -712,15 +732,14 @@ describe("demo/visual job runners", () => {
// The steer carries the artifact for the renderer.
assert.equal(steers.length, 1, "exactly one steer");
const data = steers[0]!.parts[1];
assert.ok(data.type === "data-subagent-artifact");
assert.equal(data.data.kind, "demo");
assert.deepEqual(data.data.result, result);
const data = steers[0]!.details as { kind: string; result: unknown };
assert.equal(data.kind, "demo");
assert.deepEqual(data.result, result);
// The tool part keeps the DISPATCH ACK forever — the result never
// rewrites it (the teacher gets the result once, via the steer).
const saved = chatStore.getChat(chat.id)!;
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
assert.equal(typeof (part as { output?: unknown }).output, "string", "part output is the ack, not the result");
});
@@ -729,13 +748,29 @@ describe("demo/visual job runners", () => {
const chat = chatStore.createChat("Test: visual runner failure");
createdChats.push(chat.id);
chatStore.saveMessages(chat.id, [
{ id: "u1", role: "user", parts: [{ type: "text", text: "draw it" }] },
{ id: "u1", role: "user", content: "draw it", timestamp: 1 } as never,
{
id: "a1",
role: "assistant",
parts: [{ type: "tool-visual", toolCallId: "call-v1", state: "output-available", input: { brief: "b" }, output: "Dispatched." }],
},
] as never);
content: [{ type: "toolCall", id: "call-v1", name: "visual", arguments: { brief: "b" } as never }],
api: "openai-completions",
provider: "openrouter",
model: "test",
usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
stopReason: "stop",
timestamp: 1,
} as never,
{
id: "r1",
role: "toolResult",
toolCallId: "call-v1",
toolName: "visual",
content: [{ type: "text", text: "Dispatched." }],
details: "Dispatched.",
isError: false,
timestamp: 1,
} as never,
]);
const job = jobStore.createJob({ kind: "visual", chatId: chat.id, toolCallId: "call-v1" });
createdJobs.push(job.id);
@@ -753,9 +788,7 @@ describe("demo/visual job runners", () => {
assert.match(errored.error ?? "", /never wrote an SVG/);
assert.ok(steers[0], "failure still delivers a steer — the teacher learns the outcome");
const data = steers[0]!.parts[1];
assert.ok(data.type === "data-subagent-artifact");
const result = data.data.result as { svg: string; notes?: string };
const result = (steers[0]!.details as { result: unknown }).result as { svg: string; notes?: string };
assert.equal(result.svg, "");
assert.match(result.notes ?? "", /^FAILED:/);
});
@@ -791,13 +824,18 @@ describe("demo/visual job runners", () => {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
chatId: chat.id,
messages: [{ id: "u1", role: "user", parts: [{ type: "text", text: "teach me compounding" }] }],
// Wave B: the input-events contract — one event,
// never a client transcript.
input: { type: "text", text: "teach me compounding" },
}),
}),
(outgoing: UIMessage[]): TeachingAgent =>
// The handler hands the factory the native build options
// (messages are the merged record; the record IS the agent
// state — no adapter conversion).
(opts) =>
createTeacherAgent(undefined, {
engine: teacher,
messages: recordToPiMessages(outgoing),
messages: opts.messages,
tools: createTeacherTools({
chatId: chat.id,
chatStore,
@@ -818,14 +856,14 @@ describe("demo/visual job runners", () => {
const done = jobStore.getJob(job!.id)!;
assert.equal(done.status, "done");
const saved = chatStore.getChat(chat.id)!;
const part = saved.messages[1]!.parts.find((p) => p.type === "tool-demo");
const saved = projectNative(chatStore.getChat(chat.id)!.messages);
const part = saved[1]!.parts.find((p) => p.type === "tool-demo");
assert.ok(part && part.type === "tool-demo", "demo tool part persisted");
assert.equal(part.state, "output-available", "the part resolved with the ack at dispatch time");
assert.match(String((part as { output?: unknown }).output), /fire-and-forget/);
assert.equal(steers.length, 1, "one job, one steer");
assert.equal(steers[0]!.metadata.subagentResult.jobId, job!.id);
assert.equal((steers[0]!.details as { jobId: string }).jobId, job!.id);
// Idempotency: dispatch dedupes by toolCallId — no second job.
assert.equal(jobStore.listJobs(chat.id).length, 1, "no duplicate job for the same tool part");