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:
@@ -1,25 +1,20 @@
|
||||
/**
|
||||
* 409 merge-retry transport tests (components/chat-transport.ts).
|
||||
* 409 identity-retry transport tests (components/chat-transport.ts).
|
||||
*
|
||||
* Every 409 means the server record is AHEAD (a wake turn persisted the
|
||||
* steer): the retry must wait out the turn, merge the outgoing messages onto
|
||||
* the fresh record, and re-POST the merged list — a bare retry would clobber
|
||||
* the steer/wake messages via the write-ahead persist.
|
||||
* 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";
|
||||
import type { TeacherUIMessage } from "../src/core/ui-message.ts";
|
||||
|
||||
const realFetch = globalThis.fetch;
|
||||
afterEach(() => {
|
||||
globalThis.fetch = realFetch;
|
||||
});
|
||||
|
||||
function msg(id: string, role: "user" | "assistant", text: string): TeacherUIMessage {
|
||||
return { id, role, metadata: {}, parts: [{ type: "text", text }] };
|
||||
}
|
||||
|
||||
interface Call {
|
||||
url: string;
|
||||
body: string | undefined;
|
||||
@@ -30,8 +25,6 @@ function installFetch(routes: {
|
||||
chatPOST: (call: Call, attempt: number) => Response;
|
||||
/** GET /api/jobs?chatId=… → turnInFlight value per poll (poll index). */
|
||||
jobsGET?: (poll: number) => boolean;
|
||||
/** GET /api/chats/<id> → messages array. */
|
||||
chatGET?: () => TeacherUIMessage[];
|
||||
}): Call[] {
|
||||
const calls: Call[] = [];
|
||||
let poll = 0;
|
||||
@@ -42,14 +35,14 @@ function installFetch(routes: {
|
||||
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 });
|
||||
if (url.startsWith("/api/chats/")) return Response.json({ messages: routes.chatGET?.() ?? [] });
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
}) as typeof globalThis.fetch;
|
||||
return calls;
|
||||
}
|
||||
|
||||
function outgoing(clientOnly: TeacherUIMessage[]): string {
|
||||
return JSON.stringify({ chatId: "chat_x", messages: [...clientOnly] });
|
||||
/** 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 }, () => {
|
||||
@@ -58,66 +51,54 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
||||
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([msg("c1", "user", "hi")]) });
|
||||
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, merges onto the fresh record, re-POSTs the merged list", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete"), msg("s1", "assistant", "wake turn")];
|
||||
const fresh = [msg("c1", "user", "my question while the wake turn ran")];
|
||||
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
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(fresh) });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing(event) });
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
// second POST carries server truth + the client's unsent message, in order
|
||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
||||
assert.deepEqual(
|
||||
retry.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "s1", "c1"],
|
||||
);
|
||||
// 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("drops client messages the server already has (no duplicates in the merge)", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")];
|
||||
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 })),
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
body: outgoing([msg("steer_1", "user", "Research complete"), msg("c1", "user", "my question")]),
|
||||
});
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing({ type: "text", text: "q" }) });
|
||||
assert.equal(res.status, 200);
|
||||
const retry = JSON.parse(calls.filter((c) => c.url === "/api/chat")[1]!.body!);
|
||||
assert.deepEqual(
|
||||
retry.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "c1"],
|
||||
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-merging fresh each time", async () => {
|
||||
const server = [msg("steer_1", "user", "Research complete")];
|
||||
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 })),
|
||||
chatGET: () => server,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
||||
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);
|
||||
const last = JSON.parse(posts[2]!.body!);
|
||||
assert.deepEqual(
|
||||
last.messages.map((m: { id: string }) => m.id),
|
||||
["steer_1", "c1"],
|
||||
);
|
||||
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 () => {
|
||||
@@ -129,7 +110,7 @@ describe("retryingFetch (409 merge-retry)", { skip: process.env.NO_SLOW ? true :
|
||||
jobsGET: () => true,
|
||||
});
|
||||
const fetch = retryingFetch("chat_x", { pollMs: 10, waitMaxMs: 100 });
|
||||
const res = await fetch("/api/chat", { method: "POST", body: outgoing([msg("c1", "user", "q")]) });
|
||||
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}`);
|
||||
|
||||
Reference in New Issue
Block a user