Files
learnharness/tests/server.test.ts
T
Amos 296cc1b7b6 v2 web interface: fix delivery races, real URLs, optimistic echo, thinking stream, button-based quiz UI
Root causes of the v1 browser bugs (all fixed + covered by deterministic tests):
- duplicated messages: per-socket snapshot + global broadcast marking raced;
  replaced with a single append-only entry log (exactly-once per socket,
  structurally) — covered by tests/server.test.ts
- delayed/missing echo of own messages: optimistic append with sending/failed
  status, reconciled by text when the real entry arrives
- no thinking shown: thinking deltas streamed live (kind: thinking) and
  persisted in assistant entries so they survive reloads
- ask answers graded as wrong: AnsweredCard no longer applies quiz grading to
  ask answers (no ✓/✗, no verdict)
- broken option selection: native radio/checkbox inputs fighting React state
  replaced with button-based options (single source of truth, one-click select)
- no URLs: History API routing — / home, /s/<file> per session (deep links,
  refresh, back button); session view resumes its file on mount
- lost connection = dead UI: exponential-backoff reconnect with full resync
- stick-to-bottom transcript with jump-to-latest pill

Server: startServer() with injectable runtime factory (scripted model in
tests); createSession returns file for routing; scripted model gained thinking
turns. 51 deterministic tests green.
2026-08-22 00:57:16 +03:00

331 lines
12 KiB
TypeScript

/**
* Integration test — the web server with a SCRIPTED model runtime.
*
* Real Fastify server, real WebSocket protocol, real web extensions and
* bridge, real session files — the only fake is the LLM. This is the test
* layer that catches exactly the class of bugs reported from the browser:
*
* - exactly-once entry delivery (no duplicated user messages, ever —
* including sockets that connect after the turn already happened)
* - thinking + text deltas streamed live
* - quiz answer flow over WS
* - multiple sockets seeing the same history
*
* Deterministic: zero tokens, zero variance, runs in milliseconds.
*/
import assert from "node:assert/strict";
import fs from "node:fs/promises";
import path from "node:path";
import { after, before, describe, it } from "node:test";
import { createAgentSessionRuntime } from "@earendil-works/pi-coding-agent";
import { PATHS } from "../config.ts";
import { buildTeacherRuntimeFactory } from "../src/teacher/create-session.ts";
import { createScriptedRuntime } from "../src/testing/scripted-model.ts";
import { startServer } from "../src/server/index.ts";
import type { ServerMessage } from "../src/shared/protocol.ts";
const QUIZ_ARGS = {
question: "Which is closest to the Sun?",
options: [
{ label: "Mercury", value: "mercury" },
{ label: "Venus", value: "venus" },
],
correctAnswer: "mercury",
explanation: "Mercury is the innermost planet.",
};
let port: number;
let closeServer: () => Promise<void>;
/** The script for the NEXT session the server activates (tests swap it). */
let currentScript: import("../src/testing/scripted-model.ts").ScriptedTurn[] = [
// Turn 1: think, then call quiz.
{
thinking: "I should probe the learner's prior knowledge first.",
toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }],
},
// Turn 2 (after the tool result): end turn.
{ text: "Take a shot." },
// Turn 3 (after the [Quiz answer] user message): wrap up.
{ text: "Right — Mercury it is. Lesson over." },
];
before(async () => {
const runtimeFactory = async (options: {
cwd: string;
agentDir: string;
sessionManager: import("@earendil-works/pi-coding-agent").SessionManager;
}) => {
const scripted = createScriptedRuntime(currentScript);
return createAgentSessionRuntime(
buildTeacherRuntimeFactory("web", { model: scripted.model, modelRuntime: scripted.modelRuntime }),
options,
) as never;
};
const server = await startServer({ port: 0, runtimeFactory });
port = server.port;
closeServer = server.close;
});
after(async () => {
await closeServer();
// The tests created real session files — remove them so the learner's
// data/sessions stays clean.
for (const file of createdFiles) {
try {
await fs.rm(path.join(PATHS.sessions, file), { force: true });
} catch {
// best effort
}
}
});
/** Session files this test created (cleaned up in after()). */
const createdFiles: string[] = [];
async function createTestSession(topic: string): Promise<{ id: string; file: string }> {
const res = await fetch(`http://127.0.0.1:${port}/api/sessions`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ topic }),
});
assert.ok(res.ok, `create failed: ${res.status}`);
const created = (await res.json()) as { id: string; file: string };
assert.ok(created.file.endsWith(".jsonl"), "create returns the session file");
createdFiles.push(created.file);
return created;
}
// ─── WS test helper ─────────────────────────────────────────────────────────
interface Collected {
ws: WebSocket;
msgs: ServerMessage[];
}
async function openSocket(): Promise<Collected> {
const ws = new WebSocket(`ws://127.0.0.1:${port}/ws`);
const msgs: ServerMessage[] = [];
const opened = new Promise<void>((resolve, reject) => {
ws.onopen = () => resolve();
ws.onerror = () => reject(new Error("ws connect failed"));
});
ws.onmessage = (ev) => {
msgs.push(JSON.parse(String(ev.data)) as ServerMessage);
};
await opened;
return { ws, msgs };
}
function waitMsg(
c: Collected,
pred: (m: ServerMessage) => boolean,
what: string,
timeoutMs = 5000,
): Promise<ServerMessage> {
const found = c.msgs.find(pred);
if (found) return Promise.resolve(found);
return new Promise((resolve, reject) => {
const interval = setInterval(() => {
const m = c.msgs.find(pred);
if (m) {
clearInterval(interval);
clearTimeout(timer);
resolve(m);
}
}, 15);
const timer = setTimeout(() => {
clearInterval(interval);
reject(new Error(`timeout waiting for ${what}`));
}, timeoutMs);
});
}
function waitSilence(c: Collected, settleMs = 300): Promise<void> {
return new Promise((resolve) => {
let last = c.msgs.length;
const interval = setInterval(() => {
if (c.msgs.length === last) {
clearInterval(interval);
resolve();
}
last = c.msgs.length;
}, settleMs / 3);
});
}
/** Every entry id a socket has seen (backfill + live), in order. */
function seenEntryIds(msgs: ServerMessage[]): string[] {
const ids: string[] = [];
for (const m of msgs) {
if (m.type === "backfill") ids.push(...m.entries.map((e) => e.id));
if (m.type === "entry") ids.push(m.entry.id);
}
return ids;
}
function seenUserTexts(msgs: ServerMessage[]): string[] {
const texts: string[] = [];
for (const m of msgs) {
if (m.type === "backfill") texts.push(...m.entries.filter((e) => e.type === "user").map((e) => e.text));
if (m.type === "entry" && m.entry.type === "user") texts.push(m.entry.text);
}
return texts;
}
// ─── the test ───────────────────────────────────────────────────────────────
describe("web server protocol (scripted model)", () => {
it("delivers every entry exactly once, streams thinking, and handles the answer flow", async () => {
const TOPIC = "Teach me the solar system.";
// Connect BEFORE creating the session so we see every live event.
const c1 = await openSocket();
// ── create the session ────────────────────────────────────────────
const created = await createTestSession(TOPIC);
assert.ok(created.file.endsWith(".jsonl"));
// ── the first turn: thinking delta, quiz presented, agent settles ──
const thinkingDelta = await waitMsg(
c1,
(m) => m.type === "delta" && m.kind === "thinking",
"thinking delta",
);
assert.equal((thinkingDelta as { delta: string }).delta.length > 0, true);
const pendingMsg = await waitMsg(
c1,
(m) => m.type === "pending" && m.question !== null,
"pending question",
);
const question = (pendingMsg as { question: { id: string; question: string; kind: string } }).question;
assert.equal(question.kind, "quiz");
// No leak in the client payload.
const pendingJson = JSON.stringify(pendingMsg);
assert.ok(!pendingJson.includes("Mercury is the innermost"), "no explanation leak to client");
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after quiz turn");
// The topic user message must have arrived EXACTLY once.
await waitSilence(c1);
const topicCount = seenUserTexts(c1.msgs).filter((t) => t === TOPIC).length;
assert.equal(topicCount, 1, "topic message delivered exactly once (the v1 duplication bug)");
const ids1 = seenEntryIds(c1.msgs);
assert.equal(ids1.length, new Set(ids1).size, "no duplicate entry ids on socket 1");
// ── answer the quiz ───────────────────────────────────────────────
c1.ws.send(JSON.stringify({ type: "answer", questionId: question.id, values: ["mercury"] }));
await waitMsg(
c1,
(m) => m.type === "entry" && m.entry.type === "questionAnswered",
"questionAnswered entry",
);
const answerUserMsg = await waitMsg(
c1,
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.startsWith("[Quiz answer]"),
"[Quiz answer] user entry",
);
assert.match((answerUserMsg as { entry: { text: string } }).entry.text, /Result: CORRECT/);
// Pending cleared after answering.
await waitMsg(c1, (m) => m.type === "pending" && m.question === null, "pending cleared");
// Agent continues after the answer and settles.
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after answer");
await waitSilence(c1);
const ids1b = seenEntryIds(c1.msgs);
assert.equal(ids1b.length, new Set(ids1b).size, "still no duplicate entry ids after answering");
// ── a SECOND socket connects late: same history, exactly once ─────
const c2 = await openSocket();
await waitMsg(c2, (m) => m.type === "backfill", "socket 2 backfill");
await waitSilence(c2);
const ids2 = seenEntryIds(c2.msgs);
assert.equal(ids2.length, new Set(ids2).size, "no duplicate entry ids on socket 2");
assert.deepEqual([...ids2].sort(), [...ids1b].sort(), "late socket sees the same history");
// Thinking survives in the persisted assistant entry.
const assistantEntry = c2.msgs
.flatMap((m) => (m.type === "backfill" ? m.entries : []))
.find((e) => e.type === "assistant" && (e as { thinking?: string }).thinking);
assert.ok(assistantEntry, "persisted assistant entry carries thinking");
assert.match((assistantEntry as { thinking: string }).thinking, /probe the learner/);
// ── follow-up prompt: both sockets get it exactly once ────────────
c1.ws.send(JSON.stringify({ type: "prompt", text: "thanks, that's enough for now" }));
await waitMsg(
c1,
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"),
"follow-up user entry on socket 1",
);
await waitMsg(
c2,
(m) => m.type === "entry" && m.entry.type === "user" && m.entry.text.includes("enough for now"),
"follow-up user entry on socket 2",
);
await waitMsg(c1, (m) => m.type === "streaming" && m.value === false, "settle after follow-up");
await waitSilence(c1);
await waitSilence(c2);
for (const [name, c] of [
["socket1", c1],
["socket2", c2],
] as const) {
const ids = seenEntryIds(c.msgs);
assert.equal(ids.length, new Set(ids).size, `no duplicate entry ids on ${name}`);
const texts = seenUserTexts(c.msgs);
assert.equal(texts.filter((t) => t === TOPIC).length, 1, `${name}: topic once`);
assert.equal(texts.filter((t) => t.includes("enough for now")).length, 1, `${name}: follow-up once`);
}
// ── the pending-question guard ────────────────────────────────────
// (No pending question now, so a prompt must NOT be rejected:)
// verified implicitly above — the follow-up prompt went through.
c1.ws.close();
c2.ws.close();
});
it("rejects prompts while a question is pending", async () => {
currentScript = [
{ toolCalls: [{ name: "quiz", arguments: QUIZ_ARGS }] },
{ text: "Take a shot." },
];
await createTestSession("Teach me something with a quiz.");
const c = await openSocket();
const pendingMsg = await waitMsg(
c,
(m) => m.type === "pending" && m.question !== null,
"pending question",
);
const question = (pendingMsg as { question: { id: string } }).question;
c.ws.send(JSON.stringify({ type: "prompt", text: "hello?" }));
const err = await waitMsg(c, (m) => m.type === "error", "guard rejection");
assert.match((err as { message: string }).message, /Answer the pending question/);
// Clean up: dismiss so subsequent state is clean.
c.ws.send(JSON.stringify({ type: "dismiss", questionId: question.id }));
await waitMsg(
c,
(m) => m.type === "entry" && m.entry.type === "questionAnswered",
"dismissal recorded",
);
c.ws.close();
});
it("lists sessions including the created ones", async () => {
const res = await fetch(`http://127.0.0.1:${port}/api/sessions`);
assert.ok(res.ok);
const data = (await res.json()) as { sessions: Array<{ file: string; name?: string }> };
assert.ok(data.sessions.length >= 2, "at least the two sessions from this test");
assert.ok(
data.sessions.some((s) => s.name === "Teach me the solar system."),
"session named after its topic",
);
});
});