Files
learnharness/tests/chat-flow.test.ts
T
Amos aeef28d4d9 UI iteration 4: dock morph animation, sticky-in-flow dock, scrollbar, indicators
- Animation: the blob's shell morphs height (WAAPI, 180ms) on every
  variant change — chat ↔ question ↔ collapsed bar — plus a quick
  fade/rise (160ms) on the swapped content. Gotcha pinned in code: never
  measure the shell while a morph is in flight (getBoundingClientRect
  reads the ANIMATED value and corrupts the next morph's start height);
  cancel running morphs before measuring, refresh settled height only
  when no morph is running.
- Gradient-over-scrollbar fixed structurally: the dock is no longer an
  absolute overlay on the conversation root — it's sticky bottom-0 INSIDE
  the scroller (in flow). The transcript scrolls fully clear of the dock,
  the fade never touches the scrollbar lane, and the pb-hack clearances
  are gone.
- New question while at the bottom scrolls to reveal the dock; scrolled
  up → collapsed bar, no yank.
- Thin dark native scrollbar app-wide (8px webkit + scrollbar-width:thin,
  dark thumb, transparent track). Note: use-stick-to-bottom sets
  scrollbar-gutter: stable both-edges on its scroller — the lane is
  outside the content flow by design.
- 'Writing a question…' transcript shimmer while a tool call streams its
  input (same slot as 'Thinking…'). Protocol pinned by a test asserting
  tool-input-start precedes tool-input-available; scripted mock now
  streams tool input as deltas like real providers do. (OpenRouter/
  Anthropic often delivers the whole input in ONE delta — then the dock
  appears instantly and no indicator is needed or shown.)

Verified live: grow 54→349px and shrink 349→54px fire with correct
from/to; collapse/expand intact; gradient stops before the gutter;
Thinking… observed in-page; 58/58 tests.
2026-08-22 05:56:52 +03:00

244 lines
10 KiB
TypeScript

/**
* Scripted integration test — the full teacher loop with zero tokens:
*
* learner opens chat → mock model calls `quiz` → server streams + persists
* → client grades and sends the answer back as the tool output
* → mock model reacts → transcript persisted.
*
* Drives handleChatRequest directly (plain Request/Response — no Next server
* needed) with a MockLanguageModelV4. This is the AI SDK equivalent of the
* old pi dispatch contract test: the quiz bridge is gone, the tool part IS
* the pending state, and the persisted transcript is the resume record.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as path from "node:path";
import { after, describe, it } from "node:test";
import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
import { handleChatRequest } from "../src/server/chat-handler.ts";
import { createChat, getChat, saveMessages } from "../src/store/chats.ts";
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
import { findPendingToolCall } from "../src/core/pending.ts";
import { PATHS } from "../config.ts";
const QUIZ_INPUT = {
question: "Which planet is closest to the sun?",
options: [
{ label: "Venus" },
{ label: "Mercury" },
{ label: "Mars" },
],
correctAnswer: "Mercury",
explanation: "Mercury is the innermost planet.",
};
function usage() {
return {
inputTokens: { total: 10, noCache: 10, cacheRead: 0, cacheWrite: 0 },
outputTokens: { total: 5, text: 5, reasoning: 0 },
};
}
/** Turn 1: a bit of reasoning, then the quiz tool call. Turn 2: plain text. */
function scriptedModel(calls: string[][]) {
let turn = 0;
return new MockLanguageModelV4({
doStream: async (options) => {
const promptTexts = options.prompt.map((m) => JSON.stringify(m.content));
calls.push(promptTexts);
turn++;
const chunks: LanguageModelV4StreamPart[] =
turn === 1
? [
{ type: "stream-start", warnings: [] },
{ type: "reasoning-start", id: "r1" },
{ type: "reasoning-delta", id: "r1", delta: "Probe the basics first." },
{ type: "reasoning-end", id: "r1" },
// Same shape real providers use: input streams as deltas,
// then the completed call. (This is what makes the client's
// 'Writing a question…' indicator possible.)
{ type: "tool-input-start", id: "call-1", toolName: "quiz" },
{ type: "tool-input-delta", id: "call-1", delta: JSON.stringify(QUIZ_INPUT) },
{ type: "tool-input-end", id: "call-1" },
{
type: "tool-call",
toolCallId: "call-1",
toolName: "quiz",
input: JSON.stringify(QUIZ_INPUT),
},
{ type: "finish", finishReason: { unified: "tool-calls", raw: "tool_calls" }, usage: usage() },
]
: [
{ type: "stream-start", warnings: [] },
{ type: "text-start", id: "t1" },
{ type: "text-delta", id: "t1", delta: "Right — Mercury it is." },
{ type: "text-end", id: "t1" },
{ type: "finish", finishReason: { unified: "stop", raw: "stop" }, usage: usage() },
];
return { stream: simulateReadableStream({ chunks, initialDelayInMs: 0, chunkDelayInMs: 0 }) };
},
});
}
async function drain(res: Response): Promise<string> {
if (res.status !== 200) {
assert.fail(`expected 200, got ${res.status}: ${await res.text()}`);
}
return res.text(); // consumes the SSE stream to completion (fires onFinish)
}
function post(chatId: string, messages: unknown[]): Request {
return new Request("http://test/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ chatId, messages }),
});
}
const createdChats: string[] = [];
after(() => {
for (const id of createdChats) {
try {
fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
} catch {
/* already gone */
}
}
});
describe("teacher chat flow (scripted model)", () => {
it("quiz tool call persists as a pending part; the answer continues the lesson", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(scriptedModel(calls));
const chat = createChat("Test: solar system");
createdChats.push(chat.id);
// ── Turn 1: learner opens with a topic; teacher probes with a quiz ──
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
const saved1 = getChat(chat.id)!;
assert.equal(saved1.messages.length, 2, "user message + assistant response persisted");
const assistantMsg = saved1.messages[1]!;
assert.equal(assistantMsg.role, "assistant");
const reasoningPart = assistantMsg.parts.find((p) => p.type === "reasoning");
assert.ok(reasoningPart && reasoningPart.type === "reasoning", "reasoning part streamed + persisted");
assert.match(reasoningPart.text, /Probe the basics/);
const quizPart = assistantMsg.parts.find((p) => p.type === "tool-quiz");
assert.ok(quizPart && quizPart.type === "tool-quiz", "quiz tool part persisted");
assert.equal(quizPart.state, "input-available", "unanswered quiz = pending state (resume record)");
assert.deepEqual(quizPart.input, QUIZ_INPUT);
// The pending-question gate sees it.
const pending = findPendingToolCall(saved1.messages);
assert.equal(pending?.toolCallId, "call-1");
assert.equal(pending?.tool, "quiz");
assert.deepEqual(pending?.input, QUIZ_INPUT);
// ── The learner answers: client grades, output goes back on the part ──
const options = normalizeOptions(QUIZ_INPUT.options);
const qd: QuestionData = {
id: "call-1",
kind: "quiz",
question: QUIZ_INPUT.question,
mode: "single-select",
options,
correctIndices: resolveCorrect(coerceCorrectAnswer(QUIZ_INPUT.correctAnswer), options).indices,
explanation: QUIZ_INPUT.explanation,
createdAt: new Date().toISOString(),
};
const answer = buildAnswer(qd, { values: ["Mercury"] });
assert.equal(answer.correct, true);
const messages2 = saved1.messages.map((m) =>
m.id === assistantMsg.id
? {
...m,
parts: m.parts.map((p) =>
p.type === "tool-quiz" ? { ...p, state: "output-available" as const, output: answer } : p,
),
}
: m,
);
await drain(await handleChatRequest(post(chat.id, messages2), agent));
// ── The teacher reacts — as a CONTINUATION of the same assistant message
// (the last client message is the assistant message carrying the tool
// output, so the new text appends to that message's parts). ──
const saved2 = getChat(chat.id)!;
assert.equal(saved2.messages.length, 2, "the reaction continues the same assistant message");
const finalParts = saved2.messages[1]!.parts;
const textPart = finalParts.find((p) => p.type === "text");
assert.ok(textPart && textPart.type === "text", "reaction text appended after the tool part");
assert.match(textPart.text, /Mercury it is/);
// The answered quiz part is still there, now with its output.
const answeredQuiz = finalParts.find((p) => p.type === "tool-quiz");
assert.ok(answeredQuiz && answeredQuiz.type === "tool-quiz" && answeredQuiz.state === "output-available");
// The model actually received the graded answer as a tool result.
assert.equal(calls.length, 2, "model saw exactly two prompts");
const toolResultSent = calls[1]!.some((c) => c.includes("tool-result") || c.includes("Mercury"));
assert.ok(toolResultSent, "continuation prompt carries the tool result to the model");
// No pending question remains.
assert.equal(findPendingToolCall(saved2.messages), null);
});
it("rejects unknown chats", async () => {
const res = await handleChatRequest(post("chat_nonexistent", []));
assert.equal(res.status, 404);
});
it("streams tool-input-start before tool-input-available (protocol pin for the 'Writing a question…' indicator)", async () => {
// The client's writing indicator keys off tool parts in state
// 'input-streaming', which the stream processor sets from the
// tool-input-start chunk. If a transport change ever stopped emitting
// it, the indicator would silently die — pin the protocol.
const agent = createTeacherAgent(scriptedModel([]));
const chat = createChat("Test: protocol pin");
createdChats.push(chat.id);
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] };
const body = await drain(await handleChatRequest(post(chat.id, [firstUser]), agent));
const startIdx = body.indexOf('"type":"tool-input-start"');
const availIdx = body.indexOf('"type":"tool-input-available"');
assert.ok(startIdx !== -1, "tool-input-start chunk present in the stream");
assert.ok(availIdx !== -1, "tool-input-available chunk present in the stream");
assert.ok(startIdx < availIdx, "start precedes available");
});
it("persists even when the stream is aborted (resume keeps partial state)", async () => {
const calls: string[][] = [];
const agent = createTeacherAgent(scriptedModel(calls));
const chat = createChat("Test: abort");
createdChats.push(chat.id);
const req = post(chat.id, [{ id: "u1", role: "user", parts: [{ type: "text", text: "quiz me" }] }]);
const res = await handleChatRequest(req, agent);
assert.equal(res.status, 200);
// Read one chunk then cancel — simulates the browser closing mid-stream.
const reader = res.body!.getReader();
await reader.read();
await reader.cancel();
// Give onFinish a tick to fire.
await new Promise((r) => setTimeout(r, 50));
const saved = getChat(chat.id)!;
assert.ok(saved.messages.length >= 1, "partial transcript persisted");
});
});
describe("chat store", () => {
it("round-trips and lists chats", async () => {
const chat = createChat("Store test");
createdChats.push(chat.id);
saveMessages(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "hi" }] }]);
const loaded = getChat(chat.id)!;
assert.equal(loaded.messages.length, 1);
assert.equal(loaded.title, "Store test");
});
});