Sessions keep running across navigation: turn liveness + write-ahead persist
The bug: answering a quiz and navigating away mid-turn, then returning, re-prompted the same question. Root causes: (1) the answer was durable only at onFinish — every mid-turn view read the stale pre-answer record; (2) the server had no notion of a turn in flight; (3) remount treated the stale record as current, with no re-attach path. Worse, the stale dock let the quiz be answered again, racing two turns into a last-writer-wins saveMessages clobber. Fix (four pieces): - Write-ahead persist in chat-handler: the request body IS the client's canonical state — saved to disk BEFORE the stream starts. The answer is durable the instant the request arrives; crashes recover via the existing mount continuation. - Turn liveness registry (src/server/turn-liveness.ts): in-memory chatId → in-flight map with a TTL leak guard; beginTurn rejects concurrent turns (409) so one turn ever owns a chat. endTurn runs in onFinish AFTER persistTurn, so pollers that see not-in-flight always read a finished record; a turn-start failure clears the registry (no lockout). - GET /api/chats/[id] reports turnInFlight; the chat page passes initiallyInFlight to the client. - Client re-attach (components/chat.tsx): while in flight, busy shimmer + composer gated + mount continuation suppressed; poll /api/chats/[id] until the turn finishes, then syncFromServer adopts the completed transcript. Stop stays local-stream only (a server turn has no client request to abort). Verified by scripted-model tests: concurrent turn 409 + registry lifecycle, write-ahead durability mid-turn, failed-turn lockout guard.
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
import { getChat } from "@/src/server";
|
import { getChat, isTurnRunning } from "@/src/server";
|
||||||
|
|
||||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const chat = getChat(id);
|
const chat = getChat(id);
|
||||||
if (!chat) return Response.json({ error: "Not found" }, { status: 404 });
|
if (!chat) return Response.json({ error: "Not found" }, { status: 404 });
|
||||||
return Response.json(chat);
|
// turnInFlight lets a returning client re-attach to a running turn
|
||||||
|
// (chat-handler write-ahead + registry — see src/server/turn-liveness.ts).
|
||||||
|
return Response.json({ ...chat, turnInFlight: isTurnRunning(id) });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import type { TeacherUIMessage } from "@/src/core";
|
import type { TeacherUIMessage } from "@/src/core";
|
||||||
import { getChat } from "@/src/server";
|
import { getChat, isTurnRunning } from "@/src/server";
|
||||||
|
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
@@ -22,6 +22,9 @@ export default async function ChatPage({
|
|||||||
title={chat.title}
|
title={chat.title}
|
||||||
initialMessages={chat.messages as TeacherUIMessage[]}
|
initialMessages={chat.messages as TeacherUIMessage[]}
|
||||||
initialPrompt={prompt}
|
initialPrompt={prompt}
|
||||||
|
// A teacher turn that outlived its client: the remounted view must
|
||||||
|
// attach (busy shimmer + poll), never resurrect the stale record.
|
||||||
|
initiallyInFlight={isTurnRunning(chat.id)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+54
-8
@@ -57,11 +57,15 @@ export function Chat({
|
|||||||
title,
|
title,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
|
initiallyInFlight,
|
||||||
}: {
|
}: {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialMessages: TeacherUIMessage[];
|
initialMessages: TeacherUIMessage[];
|
||||||
initialPrompt?: string;
|
initialPrompt?: string;
|
||||||
|
/** A teacher turn is running server-side but attached to no client
|
||||||
|
* (navigated away mid-turn, returned while in flight). */
|
||||||
|
initiallyInFlight?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } =
|
const { messages, sendMessage, addToolOutput, setMessages, status, error, stop } =
|
||||||
useChat<TeacherUIMessage>({
|
useChat<TeacherUIMessage>({
|
||||||
@@ -82,27 +86,37 @@ export function Chat({
|
|||||||
}
|
}
|
||||||
}, [initialPrompt, initialMessages.length, sendMessage]);
|
}, [initialPrompt, initialMessages.length, sendMessage]);
|
||||||
|
|
||||||
|
// A turn running server-side but attached to no client of ours (navigated
|
||||||
|
// away mid-turn, returned while in flight): the record is write-ahead
|
||||||
|
// persisted (answer included), but the teacher's reaction only lands when
|
||||||
|
// the orphaned turn finishes — poll for that, don't resurrect.
|
||||||
|
const [turnInFlight, setTurnInFlight] = useState(initiallyInFlight ?? false);
|
||||||
|
|
||||||
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
// Mount continuation: if the stored transcript ends with a fulfilled tool
|
||||||
// (a job result landed while no client was open), resume the lesson — the
|
// (a job result landed while no client was open), resume the lesson — the
|
||||||
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
// SDK's sendAutomaticallyWhen only fires inside addToolOutput, so reloads
|
||||||
// need this nudge. Once per mount; the turn that follows ends in text (or
|
// need this nudge. Once per mount; the turn that follows ends in text (or
|
||||||
// a pending question), so no loop.
|
// a pending question), so no loop. NEVER while a turn is already running
|
||||||
|
// server-side — that would double-run the teacher (the server would 409
|
||||||
|
// it; the poll below resumes instead).
|
||||||
const mountContinuation = useRef(false);
|
const mountContinuation = useRef(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountContinuation.current) return;
|
if (mountContinuation.current) return;
|
||||||
mountContinuation.current = true;
|
mountContinuation.current = true;
|
||||||
if (
|
if (
|
||||||
|
!turnInFlight &&
|
||||||
status === "ready" &&
|
status === "ready" &&
|
||||||
messages.length > 0 &&
|
messages.length > 0 &&
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages })
|
lastAssistantMessageIsCompleteWithToolCalls({ messages })
|
||||||
) {
|
) {
|
||||||
void sendMessage();
|
void sendMessage();
|
||||||
}
|
}
|
||||||
// mount-only by design
|
// mount-only by design (turnInFlight read at initial value)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const pending = findPendingToolCall(messages);
|
const pending = findPendingToolCall(messages);
|
||||||
const busy = status === "submitted" || status === "streaming";
|
const localStream = status === "submitted" || status === "streaming";
|
||||||
|
const busy = localStream || turnInFlight;
|
||||||
|
|
||||||
// Teach-phase check (W5, vision §2 "two phases"): a pending question that
|
// Teach-phase check (W5, vision §2 "two phases"): a pending question that
|
||||||
// lands after plan_init starts collapsed behind a "Check your
|
// lands after plan_init starts collapsed behind a "Check your
|
||||||
@@ -124,7 +138,7 @@ export function Chat({
|
|||||||
// break provider conversion — contracts.ts "THE ASYNC PROTOCOL" §5), but
|
// break provider conversion — contracts.ts "THE ASYNC PROTOCOL" §5), but
|
||||||
// they don't dock: the running job's activity card sits in the transcript.
|
// they don't dock: the running job's activity card sits in the transcript.
|
||||||
const pendingJob = findPendingJobCall(messages);
|
const pendingJob = findPendingJobCall(messages);
|
||||||
const canSend = text.trim().length > 0 && !pending && !pendingJob;
|
const canSend = text.trim().length > 0 && !pending && !pendingJob && !turnInFlight;
|
||||||
|
|
||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
@@ -134,8 +148,8 @@ export function Chat({
|
|||||||
setMessages(record.messages);
|
setMessages(record.messages);
|
||||||
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
// setMessages does NOT fire sendAutomaticallyWhen (the SDK only checks
|
||||||
// it inside addToolOutput) — without this the teacher would stay silent
|
// it inside addToolOutput) — without this the teacher would stay silent
|
||||||
// after a job result lands. sendMessage() with no args re-requests with
|
// after a job result lands. sendMessage() with no args re-requests with
|
||||||
// the current list: the continuation. (contracts.ts protocol §4.)
|
// the current list: the continuation. (contracts.ts protocol §4.)
|
||||||
if (
|
if (
|
||||||
status === "ready" &&
|
status === "ready" &&
|
||||||
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
|
lastAssistantMessageIsCompleteWithToolCalls({ messages: record.messages })
|
||||||
@@ -144,6 +158,36 @@ export function Chat({
|
|||||||
}
|
}
|
||||||
}, [chatId, setMessages, sendMessage, status]);
|
}, [chatId, setMessages, sendMessage, status]);
|
||||||
|
|
||||||
|
// Re-attach after mid-turn navigation (the key fix): poll the record until
|
||||||
|
// the orphaned turn finishes (turnInFlight flips false server-side in
|
||||||
|
// onFinish, AFTER persist), then adopt the completed transcript — the
|
||||||
|
// syncFromServer above already runs the job-continuation check. Until then
|
||||||
|
// the session reads as busy: thinking shimmer, composer gated.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!turnInFlight) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
const poll = async () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
|
if (res.ok && !((await res.json()) as { turnInFlight?: boolean }).turnInFlight) {
|
||||||
|
setTurnInFlight(false);
|
||||||
|
void syncFromServer();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* transient network hiccup — keep polling */
|
||||||
|
}
|
||||||
|
if (!cancelled) timer = setTimeout(poll, 1500);
|
||||||
|
};
|
||||||
|
timer = setTimeout(poll, 1000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [turnInFlight, chatId, syncFromServer]);
|
||||||
|
|
||||||
const onSend = () => {
|
const onSend = () => {
|
||||||
const t = text.trim();
|
const t = text.trim();
|
||||||
if (!t || busy || pending || pendingJob) return;
|
if (!t || busy || pending || pendingJob) return;
|
||||||
@@ -223,7 +267,7 @@ export function Chat({
|
|||||||
</Message>
|
</Message>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{status === "submitted" && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
{(status === "submitted" || turnInFlight) && <Shimmer className="text-sm">Thinking…</Shimmer>}
|
||||||
{questionStreaming && <Shimmer className="text-sm">Writing a question…</Shimmer>}
|
{questionStreaming && <Shimmer className="text-sm">Writing a question…</Shimmer>}
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
|
||||||
@@ -237,7 +281,9 @@ export function Chat({
|
|||||||
requiresReveal={requiresReveal}
|
requiresReveal={requiresReveal}
|
||||||
composer={
|
composer={
|
||||||
<Composer
|
<Composer
|
||||||
busy={busy}
|
// Stop applies only to a LOCAL stream: a server-side turn has no
|
||||||
|
// client request to abort (stop() would be a no-op lie).
|
||||||
|
busy={localStream}
|
||||||
value={text}
|
value={text}
|
||||||
onChange={setText}
|
onChange={setText}
|
||||||
onSend={onSend}
|
onSend={onSend}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { createJobRunner } from "../agent/subagents/runner.ts";
|
|||||||
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
import { createExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import { createTeacherTools } from "../agent/tools.ts";
|
import { createTeacherTools } from "../agent/tools.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
|
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { buildTeacherContext, persistTurn, spawnTurnJobs, triggerExtraction } from "./turn-service.ts";
|
import { buildTeacherContext, persistTurn, spawnTurnJobs, triggerExtraction } from "./turn-service.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -36,7 +37,29 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
|
|||||||
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
return Response.json({ error: "messages must be an array" }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
return createAgentUIStreamResponse({
|
// One turn per chat, ever. A second request while a turn runs means a
|
||||||
|
// stale/remounted client raced a live one — its message list is outdated,
|
||||||
|
// and letting it through would double-run the teacher and clobber the
|
||||||
|
// first turn's persist (saveMessages is last-writer-wins).
|
||||||
|
if (!beginTurn(chatId)) {
|
||||||
|
console.info(`[chat ${chatId}] turn already in flight — request rejected`);
|
||||||
|
return Response.json({ error: "A turn is already running for this session" }, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write-ahead persistence: the request body IS the client's canonical
|
||||||
|
// state, so it goes on disk BEFORE the stream starts. The learner's answer
|
||||||
|
// is durable the instant the request arrives — any remount, second tab, or
|
||||||
|
// server crash sees truth instead of the pre-answer record. (A client that
|
||||||
|
// returns mid-turn reads turnInFlight from the registry and re-attaches by
|
||||||
|
// polling instead of resurrecting the stale transcript.)
|
||||||
|
try {
|
||||||
|
stores.chatStore.saveMessages(chatId, body.messages as UIMessage[]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await createAgentUIStreamResponse({
|
||||||
agent:
|
agent:
|
||||||
agent ??
|
agent ??
|
||||||
createTeacherAgent(
|
createTeacherAgent(
|
||||||
@@ -56,10 +79,20 @@ export async function handleChatRequest(req: Request, agent?: TeacherAgent): Pro
|
|||||||
if (InvalidToolInputError.isInstance(error) || NoSuchToolError.isInstance(error)) return error.message;
|
if (InvalidToolInputError.isInstance(error) || NoSuchToolError.isInstance(error)) return error.message;
|
||||||
return "An error occurred.";
|
return "An error occurred.";
|
||||||
},
|
},
|
||||||
onFinish: ({ messages, isAborted }) => {
|
onFinish: ({ messages, isAborted }) => {
|
||||||
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
|
persistTurn(stores.chatStore, chatId, messages as UIMessage[], isAborted);
|
||||||
spawnTurnJobs(jobRunner, chatId, messages as UIMessage[]);
|
spawnTurnJobs(jobRunner, chatId, messages as UIMessage[]);
|
||||||
triggerExtraction(stores, extraction, chatId);
|
triggerExtraction(stores, extraction, chatId);
|
||||||
},
|
// LAST: a poller that sees not-in-flight must read an already-persisted
|
||||||
});
|
// record — endTurn only after persistTurn has done its job.
|
||||||
|
endTurn(chatId);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
// The stream never started, so no onFinish will fire — clear the registry
|
||||||
|
// or a malformed request would lock the chat until the TTL eviction.
|
||||||
|
endTurn(chatId);
|
||||||
|
console.error(`[chat ${chatId}] turn failed to start:`, err);
|
||||||
|
return Response.json({ error: "Failed to start the turn" }, { status: 500 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,3 +9,4 @@ export * from "./chats.ts";
|
|||||||
export * from "./graph.ts";
|
export * from "./graph.ts";
|
||||||
export * from "./jobs.ts";
|
export * from "./jobs.ts";
|
||||||
export * from "./stores.ts";
|
export * from "./stores.ts";
|
||||||
|
export * from "./turn-liveness.ts";
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
/**
|
||||||
|
* Server-side turn liveness: which chats currently have a teacher turn in
|
||||||
|
* flight. The missing half of "sessions keep running in the background" —
|
||||||
|
* together with the write-ahead persist in chat-handler, a returning client
|
||||||
|
* can tell "the teacher is mid-turn — don't resurrect, don't start anything"
|
||||||
|
* apart from "the record IS current".
|
||||||
|
*
|
||||||
|
* In-memory BY DESIGN: a process restart clears it, so there are no stale
|
||||||
|
* flags to clean up — a crashed turn recovers via the write-ahead record +
|
||||||
|
* mount continuation on the next visit. The TTL is a leak guard for the
|
||||||
|
* pathological case where onFinish never fires (it runs even on aborts);
|
||||||
|
* chat turns are minutes at most, so 30 minutes is far on the safe side.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const inFlight = new Map<string, number>(); // chatId → startedAt (epoch ms)
|
||||||
|
const MAX_TURN_MS = 30 * 60_000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mark a turn as starting. Returns false if one is already running for the
|
||||||
|
* chat — the caller rejects the request (only one turn may own a chat).
|
||||||
|
*/
|
||||||
|
export function beginTurn(chatId: string): boolean {
|
||||||
|
if (isTurnRunning(chatId)) return false;
|
||||||
|
inFlight.set(chatId, Date.now());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mark the chat's turn as finished (runs in onFinish, aborted or not). */
|
||||||
|
export function endTurn(chatId: string): void {
|
||||||
|
inFlight.delete(chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True while the chat's turn is in flight; lazily evicts stale entries. */
|
||||||
|
export function isTurnRunning(chatId: string): boolean {
|
||||||
|
const since = inFlight.get(chatId);
|
||||||
|
if (since === undefined) return false;
|
||||||
|
if (Date.now() - since > MAX_TURN_MS) {
|
||||||
|
inFlight.delete(chatId);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import { MockLanguageModelV4, simulateReadableStream } from "ai/test";
|
|||||||
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
import type { LanguageModelV4StreamPart } from "@ai-sdk/provider";
|
||||||
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
|
||||||
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
import { handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
|
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
import { buildAnswer, coerceCorrectAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||||
import { findPendingToolCall } from "../src/core/pending.ts";
|
import { findPendingToolCall } from "../src/core/pending.ts";
|
||||||
@@ -326,3 +327,97 @@ describe("chat store", () => {
|
|||||||
assert.equal(loaded.title, "Store test");
|
assert.equal(loaded.title, "Store test");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||||
|
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const agent = createTeacherAgent(scriptedModel(calls));
|
||||||
|
const chat = chatStore.createChat("Test: turn guard");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
const firstUser = { id: "u1", role: "user", parts: [{ type: "text", text: "teach me the solar system" }] };
|
||||||
|
const res1 = await handleChatRequest(post(chat.id, [firstUser]), agent); // NOT drained → in flight
|
||||||
|
assert.equal(isTurnRunning(chat.id), true, "turn registered as in flight");
|
||||||
|
|
||||||
|
// A stale client (remount racing a live turn) tries to start a second turn.
|
||||||
|
const res2 = await handleChatRequest(post(chat.id, [firstUser]), agent);
|
||||||
|
assert.equal(res2.status, 409, "concurrent turn rejected — last-writer-wins persist would clobber it");
|
||||||
|
|
||||||
|
await drain(res1); // finish → onFinish → endTurn
|
||||||
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||||
|
|
||||||
|
const res3 = await handleChatRequest(post(chat.id, [firstUser]), agent);
|
||||||
|
assert.equal(res3.status, 200, "the next turn is accepted after the previous finished");
|
||||||
|
await drain(res3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("write-ahead: the learner's answer is durable BEFORE the turn finishes", async () => {
|
||||||
|
const calls: string[][] = [];
|
||||||
|
const agent = createTeacherAgent(scriptedModel(calls));
|
||||||
|
const chat = chatStore.createChat("Test: write-ahead");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
|
||||||
|
// Turn 1: the teacher asks the quiz (pending part in the record).
|
||||||
|
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 = chatStore.getChat(chat.id)!;
|
||||||
|
const assistantMsg = saved1.messages[1]!;
|
||||||
|
|
||||||
|
// The learner answers; the client posts the graded transcript back.
|
||||||
|
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"] });
|
||||||
|
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,
|
||||||
|
);
|
||||||
|
|
||||||
|
// NOT drained: the teacher turn is streaming, but the answer is already
|
||||||
|
// on disk — a remount (or crash) mid-turn sees the answered quiz, never
|
||||||
|
// the stale pre-answer question.
|
||||||
|
const res = await handleChatRequest(post(chat.id, messages2), agent);
|
||||||
|
const midTurn = chatStore.getChat(chat.id)!;
|
||||||
|
const midQuiz = midTurn.messages[1]!.parts.find((p) => p.type === "tool-quiz");
|
||||||
|
assert.ok(midQuiz && midQuiz.type === "tool-quiz");
|
||||||
|
assert.equal(midQuiz.state, "output-available", "write-ahead persisted the answer before finish");
|
||||||
|
|
||||||
|
await drain(res); // finish → persist the full turn → endTurn
|
||||||
|
const saved2 = chatStore.getChat(chat.id)!;
|
||||||
|
assert.equal(saved2.messages.length, 2, "full turn persisted as usual at finish");
|
||||||
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared at finish");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a turn that fails to start (malformed messages) clears the registry — no lockout", async () => {
|
||||||
|
const chat = chatStore.createChat("Test: turn failed to start");
|
||||||
|
createdChats.push(chat.id);
|
||||||
|
// A text part with no text: fails UIMessage validation before the stream
|
||||||
|
// exists — the path where no onFinish will ever run.
|
||||||
|
const bad = [{ id: "m1", role: "user", parts: [{ type: "text" }] }];
|
||||||
|
const res = await handleChatRequest(post(chat.id, bad), createTeacherAgent(scriptedModel([])));
|
||||||
|
assert.equal(res.status, 500, "turn-start failure is a 500, not a hang");
|
||||||
|
assert.equal(isTurnRunning(chat.id), false, "registry cleared when the stream never started");
|
||||||
|
|
||||||
|
// And the chat is NOT locked: the next (valid) turn is accepted.
|
||||||
|
const ok = await handleChatRequest(
|
||||||
|
post(chat.id, [{ id: "m1", role: "user", parts: [{ type: "text", text: "teach me" }] }]),
|
||||||
|
createTeacherAgent(scriptedModel([])),
|
||||||
|
);
|
||||||
|
assert.equal(ok.status, 200);
|
||||||
|
await drain(ok);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
/**
|
||||||
|
* Turn liveness registry unit tests (src/server/turn-liveness.ts): one turn
|
||||||
|
* per chat, ever — begin/end round trip, double-begin rejection, restart
|
||||||
|
* after end. The 30-minute stale-eviction TTL is not tested (it is a leak
|
||||||
|
* guard, not a behavior).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { describe, it } from "node:test";
|
||||||
|
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
|
|
||||||
|
describe("turn liveness registry", () => {
|
||||||
|
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
|
||||||
|
const id = "chat_liveness_unit";
|
||||||
|
assert.equal(isTurnRunning(id), false, "initially idle");
|
||||||
|
assert.equal(beginTurn(id), true, "first turn accepted");
|
||||||
|
assert.equal(isTurnRunning(id), true, "in flight");
|
||||||
|
assert.equal(beginTurn(id), false, "no two turns for one chat");
|
||||||
|
endTurn(id);
|
||||||
|
assert.equal(isTurnRunning(id), false, "cleared at finish");
|
||||||
|
assert.equal(beginTurn(id), true, "a new turn may start after the last ended");
|
||||||
|
endTurn(id);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user