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>
161 lines
6.1 KiB
TypeScript
161 lines
6.1 KiB
TypeScript
/* 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);
|
|
});
|
|
});
|