Files
learnharness/tests/chat-transport.test.ts
T
amosblomqvist 7ab47d4fcb 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.
2026-09-26 00:37:46 +03:00

118 lines
5.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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}`);
});
});