feat(tutor): the real endpoint, streamed, with the system prompt cached

POST /api/tutor takes the assembled system prompt, the transcript the
client owns, and the new message, and streams tokens back. It holds
nothing between requests, so a dropped connection costs one turn rather
than the conversation.

The seam moved first: Sample took the assembled prompt as messages[0] with
role user. It now has an explicit system field, which is what lets the
backend put it in the API's system parameter as a cached block. The gate
is ~12k characters and is byte-identical for as long as the learner stays
in one unit, so every turn after the first reads the prefix at a fraction
of the input price. That is the single biggest cost lever in the design,
and it was unreachable through the old shape.

prompt/tutor-system.md still ships unchanged; only where the string is
placed changed.

SSE has three rules that are silent when broken, and all three are
handled: every event ends with a blank line, payloads are JSON-encoded
because a raw newline in Korean text would break the framing, and a `:`
heartbeat every 15s keeps intermediaries from timing the stream out.
Cache-Control is set on the returned Response rather than inside
streamSSE, which writes its own and would overwrite it; `no-transform` is
there because compression, not buffering, is what usually makes SSE look
like it hangs behind a proxy.

The client uses fetch + getReader, not EventSource — EventSource cannot
POST, and the body is {system, history, message}. Aborting closes the
connection, the server aborts upstream, and a cancelled turn stops
billing. With no server configured the app falls back to the stub, so the
offline build is untouched.

backends/anthropic.ts is the default. backends/agent-sdk.ts is deliberately
unimplemented and documents why the plain API was chosen over PORT.md's
Agent SDK — chiefly that its prompt accepts only user-role messages, so
the transcript would have to be flattened into a single turn.

The endpoint's own tests use a mock backend and need no API key.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-08 19:58:01 +02:00
parent c2e1fc23fe
commit 66f92247d2
8 changed files with 582 additions and 6 deletions

View File

@@ -0,0 +1,160 @@
/* The tutor endpoint's wire behaviour, against a mock backend.
Testable with no API key, which matters: the framing and the abort path
are where this breaks, and neither needs a real model to exercise. A
raw newline inside a `data:` field would silently truncate every Korean
reply — that is the bug this file exists to catch. */
import { describe, it, expect } from "vitest";
import { tutorRoute } from "../../server/src/tutor.ts";
import type { TutorBackend, TutorEvent } from "../../server/src/backends/types.ts";
/** Emits the chunks it is given, then done. */
function mockBackend(chunks: string[], opts: { hang?: boolean } = {}): TutorBackend {
return {
name: "mock",
async *stream(req): AsyncIterable<TutorEvent> {
for (const text of chunks) {
if (req.signal.aborted) return;
yield { type: "delta", text };
}
if (opts.hang) {
// Stay open until aborted, so the abort path can be observed.
await new Promise<void>((resolve) => {
if (req.signal.aborted) return resolve();
req.signal.addEventListener("abort", () => resolve(), { once: true });
});
return;
}
yield { type: "done", stopReason: "end_turn" };
},
};
}
const post = (app: ReturnType<typeof tutorRoute>, body: unknown) =>
app.request("/", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
/** Parse an SSE body into its events, the way the client does. */
function parseSSE(text: string): { event: string; data: string }[] {
return text
.split("\n\n")
.filter((f) => f.trim())
.map((frame) => {
const lines = frame.split("\n");
return {
event: lines.find((l) => l.startsWith("event:"))?.slice(6).trim() ?? "",
data: lines
.filter((l) => l.startsWith("data:"))
.map((l) => l.slice(5).trim())
.join(""),
};
});
}
describe("tutor endpoint", () => {
it("rejects a request with no system prompt or message", async () => {
const app = tutorRoute(mockBackend(["hi"]));
expect((await post(app, { message: "안녕" })).status).toBe(400);
expect((await post(app, { system: "S" })).status).toBe(400);
});
it("streams deltas as SSE and closes with done", async () => {
const app = tutorRoute(mockBackend(["안녕", "하세요"]));
const res = await post(app, { system: "S", history: [], message: "안녕" });
expect(res.status).toBe(200);
expect(res.headers.get("content-type")).toContain("text/event-stream");
// Compression or a caching proxy would break the stream; both are
// refused explicitly.
expect(res.headers.get("cache-control")).toContain("no-transform");
const events = parseSSE(await res.text());
const deltas = events.filter((e) => e.event === "delta").map((e) => JSON.parse(e.data).text);
expect(deltas).toEqual(["안녕", "하세요"]);
expect(events.at(-1)?.event).toBe("done");
});
/* The framing bug this guards against: a tutor reply is full of newlines
— every ::words row, every task line — and a raw newline inside a
`data:` field ends the event early, truncating the turn. */
it("survives newlines in the payload, which every real reply has", async () => {
const reply = "좋아.\n\n::task translate\n밥 먹어\n::\n::words\n밥 | rice\n::";
const app = tutorRoute(mockBackend([reply]));
const res = await post(app, { system: "S", history: [], message: "네" });
const events = parseSSE(await res.text());
const delta = events.find((e) => e.event === "delta");
expect(JSON.parse(delta!.data).text).toBe(reply);
});
it("passes the transcript through and caps its length", async () => {
let seen: { history: unknown[]; system: string } | null = null;
const spy: TutorBackend = {
name: "spy",
async *stream(req) {
seen = { history: req.history, system: req.system };
yield { type: "done", stopReason: "end_turn" };
},
};
const app = tutorRoute(spy);
const history: { role: "user" | "assistant"; content: string }[] = Array.from(
{ length: 60 },
(_, i) => ({ role: i % 2 ? "assistant" : "user", content: `turn ${i}` }),
);
const res = await post(app, { system: "GATE", history, message: "다음" });
await res.text();
expect(seen!.system).toBe("GATE");
expect(seen!.history.length).toBe(40); // MAX_HISTORY
expect((seen!.history.at(-1) as { content: string }).content).toBe("turn 59");
});
it("reports a backend error as an error event rather than a dead stream", async () => {
const failing: TutorBackend = {
name: "failing",
// eslint-disable-next-line require-yield
async *stream() {
throw new Error("upstream exploded");
},
};
const res = await post(tutorRoute(failing), { system: "S", history: [], message: "x" });
const events = parseSSE(await res.text());
expect(events.at(-1)?.event).toBe("error");
expect(JSON.parse(events.at(-1)!.data).message).toContain("upstream exploded");
});
/* The abort path is what makes "Stop" stop billing: the client going away
has to reach the backend's signal, not just close the socket. */
it("aborts the backend when the client goes away", async () => {
let seen: AbortSignal | null = null;
const holding: TutorBackend = {
name: "holding",
async *stream(req) {
seen = req.signal;
yield { type: "delta", text: "부" };
await new Promise<void>((resolve) =>
req.signal.addEventListener("abort", () => resolve(), { once: true }),
);
},
};
const res = await post(tutorRoute(holding), { system: "S", history: [], message: "x" });
const reader = res.body!.getReader();
await reader.read(); // the turn is live and the backend holds the signal
expect(seen).not.toBeNull();
expect(seen!.aborted).toBe(false);
await reader.cancel(); // the client goes away
for (let i = 0; i < 50 && !seen!.aborted; i++) {
await new Promise((r) => setTimeout(r, 20));
}
expect(seen!.aborted, "the backend must see the client disconnect").toBe(true);
});
});