fix: recover session submissions and failed turn settlement
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, type ChatDeleteMode } from "@/src/server";
|
||||
import { compactionInFlight, deleteChatSession, getChat, isTurnRunning, sessionCoverage, sessionCost, stores, turnFailure, type ChatDeleteMode } from "@/src/server";
|
||||
|
||||
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = await params;
|
||||
@@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
||||
messages: chat.messages,
|
||||
pricing: sessionCost(chat),
|
||||
turnInFlight: isTurnRunning(id),
|
||||
// Also reaches idle views that missed the failing turn's stream.
|
||||
turnError: turnFailure(id),
|
||||
compacting: compactionInFlight(id),
|
||||
coverage: sessionCoverage(id),
|
||||
});
|
||||
|
||||
@@ -5,8 +5,9 @@
|
||||
* submitter too attaches here. One SSE stream per chat: a running turn →
|
||||
* subscribe to the live turn object: FULL replay of the event log, then
|
||||
* live chunks until settle ([DONE]). No running turn → the cheap terminal
|
||||
* ({"type":"turn-settled"} + [DONE]): the record is current, the client
|
||||
* refetches it.
|
||||
* ({"type":"turn-settled"} + [DONE]): the client refetches the record.
|
||||
* A retained settlement-failure notice precedes that terminal when a
|
||||
* reply could not be saved before the submitter attached.
|
||||
*
|
||||
* 404 mirrors the POST handler's unknown-chat. Framework route is a thin
|
||||
* wrapper over the server barrel (the import law); tests drive the seams
|
||||
@@ -18,6 +19,6 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
|
||||
const { id } = await ctx.params;
|
||||
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||
const handle = runningTurn(id);
|
||||
if (!handle) return settledTurnResponse();
|
||||
if (!handle) return settledTurnResponse(id);
|
||||
return piTurnResponse(handle);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { Chat } from "@/components/chat";
|
||||
import { slotsConfigured } from "@/src/core";
|
||||
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord } from "@/src/server";
|
||||
import { compactionInFlight, getChat, isTurnRunning, readSettingsRecord, turnFailure } from "@/src/server";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
@@ -29,6 +29,7 @@ export default async function ChatPage({
|
||||
// derived app-side (core/view.ts projectView; GET serves the same
|
||||
// native messages).
|
||||
initialMessages={chat.messages}
|
||||
initialError={turnFailure(chat.id)}
|
||||
initialConfigured={slotsConfigured(record?.models)}
|
||||
initialPrompt={prompt}
|
||||
// A teacher turn that outlived its client: the remounted view must
|
||||
|
||||
+32
-20
@@ -38,7 +38,6 @@ import {
|
||||
messageAnchorId,
|
||||
textPartMessageId,
|
||||
kbdEventForAction as kbdEventForChat,
|
||||
needsContinuation,
|
||||
type ChatInput,
|
||||
type ChatSettings,
|
||||
type LhMessage,
|
||||
@@ -57,7 +56,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||
import { TopBar } from "@/components/top-bar";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, TurnUsage, SessionPricing } from "@/src/core";
|
||||
import type { ArtifactPartProps, JobRecord, Phase3, QuestionAnswerData, QuestionFormDraft, TurnUsage, SessionPricing } from "@/src/core";
|
||||
import type { LhViewMessage } from "@/src/core";
|
||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||
import {
|
||||
@@ -198,6 +197,7 @@ export function Chat({
|
||||
chatId,
|
||||
title,
|
||||
initialMessages,
|
||||
initialError,
|
||||
initialPrompt,
|
||||
initiallyInFlight,
|
||||
initiallyCompacting = false,
|
||||
@@ -207,6 +207,8 @@ export function Chat({
|
||||
chatId: string;
|
||||
title: string;
|
||||
initialMessages: LhMessage[];
|
||||
/** Memory-only settlement notice supplied on reopening. */
|
||||
initialError?: string;
|
||||
/** The session kind (practice sessions, owner 2026-10-04): practice
|
||||
* renders no phase chip (no probe→teach arc applies) and a Practice
|
||||
* tag rides the header instead. */
|
||||
@@ -236,8 +238,8 @@ export function Chat({
|
||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
||||
const { messages, nativeMessages, contextTokens, send, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
||||
const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
|
||||
|
||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
||||
@@ -434,9 +436,7 @@ export function Chat({
|
||||
useEffect(() => {
|
||||
if (mountContinuation.current) return;
|
||||
mountContinuation.current = true;
|
||||
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
|
||||
void send({ type: "continue" });
|
||||
}
|
||||
if (!turnInFlight) continueIfOwed(initialMessages);
|
||||
// mount-only by design (turnInFlight read at initial value)
|
||||
}, []);
|
||||
|
||||
@@ -532,10 +532,12 @@ export function Chat({
|
||||
}, []);
|
||||
const pendingQuestionId = pending?.toolCallId ?? null;
|
||||
useEffect(() => {
|
||||
// A new pending question never inherits the previous one's draft.
|
||||
readingThinkingRef.current = "";
|
||||
setReadingThinking("");
|
||||
}, [pendingQuestionId]);
|
||||
// Different calls never share a draft; a failed submission of THIS
|
||||
// call restores the reading channel alongside the form's snapshot.
|
||||
const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : "";
|
||||
readingThinkingRef.current = reading;
|
||||
setReadingThinking(reading);
|
||||
}, [pendingQuestionId, questionDraft]);
|
||||
|
||||
// The phase at every message (one linear fold): the transcript draws a
|
||||
// color-coded dotted divider at each boundary; the header chip reads the
|
||||
@@ -717,20 +719,20 @@ export function Chat({
|
||||
const syncFromServer = useCallback(async () => {
|
||||
const res = await fetch(`/api/chats/${chatId}`);
|
||||
if (!res.ok) return;
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing };
|
||||
const record = (await res.json()) as { messages?: LhMessage[]; turns?: TurnUsage[]; pricing?: SessionPricing; turnError?: string };
|
||||
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||
setPricing(record.pricing);
|
||||
if (!Array.isArray(record.messages)) return;
|
||||
setMessages(record.messages);
|
||||
setMessages(record.messages, record.turnError);
|
||||
// The continuation seam (contracts.ts protocol §4): a job result
|
||||
// landing in the record leaves the agent owing a continuation — the
|
||||
// record ENDS on un-consumed results, the dock owns no question, and
|
||||
// nothing runs. The native fold (needsContinuation) is the old SDK
|
||||
// lastAssistantMessageIsCompleteWithToolCalls check.
|
||||
if (status === "ready" && needsContinuation(record.messages)) {
|
||||
void send({ type: "continue" });
|
||||
}
|
||||
}, [chatId, setMessages, send, status]);
|
||||
// Current model truth also guards retained/live failures: canonical
|
||||
// sync must never automatically replace a reply that failed to save.
|
||||
continueIfOwed(record.messages);
|
||||
}, [chatId, setMessages, continueIfOwed]);
|
||||
|
||||
// The settled terminal's other half (stream-reattach wave): the
|
||||
// transport resolved turn-settled — no turn running, the record is
|
||||
@@ -905,8 +907,8 @@ export function Chat({
|
||||
// (endTurn emits from onFinish before the response terminates, so the
|
||||
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
||||
// there, it flags instead, and THIS flip consumes the flag: the record
|
||||
// is guaranteed durable (endTurn fires only after persistTurn, the S2
|
||||
// ordering law), so replacing the view onto it absorbs the tail's
|
||||
// reflects durable truth (a failed settlement retains its notice,
|
||||
// not its unsaved reply), so replacing the view absorbs the tail's
|
||||
// streamed c_-id copies into base under their server ids. Without
|
||||
// this the tail kept those copies forever and the next send's
|
||||
// steer-adoption window rendered them a second time. The wake handoff
|
||||
@@ -1596,6 +1598,8 @@ export function Chat({
|
||||
fold) and NEVER overlaps the composer. */}
|
||||
<DockOverlay
|
||||
pending={pending}
|
||||
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
|
||||
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
|
||||
practice={activeProblem}
|
||||
compactionRunning={compacting}
|
||||
onSubmitThinking={submitThinking}
|
||||
@@ -1663,8 +1667,10 @@ export function Chat({
|
||||
// ride the same callback (QuestionAnswerData.dismissed). The
|
||||
// reading-thinking draft rides along (owner 2026-10-03) — the
|
||||
// check answer is its only carrier.
|
||||
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
|
||||
const reading = readingThinkingRef.current.trim() || undefined;
|
||||
clearReadingThinking();
|
||||
// The pending-id effect clears this only when the question
|
||||
// actually leaves. An offline preflight keeps the draft visible.
|
||||
submitAnswer(pending.tool, pending.toolCallId, reading ? { ...output, readingThinking: reading } : output);
|
||||
}}
|
||||
/>
|
||||
@@ -1821,6 +1827,8 @@ export function shouldStampGeometryMute(nextH: number, prevH: number, pending: b
|
||||
|
||||
function DockOverlay({
|
||||
pending,
|
||||
initialQuestionDraft,
|
||||
onSaveQuestionDraft,
|
||||
practice,
|
||||
compactionRunning,
|
||||
onSubmitThinking,
|
||||
@@ -1843,6 +1851,8 @@ function DockOverlay({
|
||||
queuedSteers,
|
||||
}: {
|
||||
pending: ReturnType<typeof findPendingToolCall>;
|
||||
initialQuestionDraft?: QuestionFormDraft;
|
||||
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
|
||||
/** Compaction running for this chat (round 14) — the docks' submit
|
||||
* affordance gate (the server's registry is the real one). */
|
||||
compactionRunning: boolean;
|
||||
@@ -2309,6 +2319,8 @@ function DockOverlay({
|
||||
phase={phase}
|
||||
onExpand={expandAndReveal}
|
||||
onSubmit={onSubmitAnswer}
|
||||
initialDraft={initialQuestionDraft}
|
||||
onSaveDraft={onSaveQuestionDraft}
|
||||
onEscape={dictationCancel}
|
||||
dictationClaim={!revealPending}
|
||||
compactionRunning={compactionRunning}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
type DockSelection,
|
||||
type ExplainToolInput,
|
||||
type QuestionAnswerData,
|
||||
type QuestionFormDraft,
|
||||
type QuestionData,
|
||||
type QuestionMode,
|
||||
type QuizToolInput,
|
||||
@@ -71,6 +72,9 @@ export interface QuestionDockProps {
|
||||
reveal?: boolean;
|
||||
onExpand: () => void;
|
||||
onSubmit: (output: QuestionAnswerData) => void;
|
||||
/** Restore only this call's unconfirmed submission after recovery. */
|
||||
initialDraft?: QuestionFormDraft;
|
||||
onSaveDraft?: (draft: QuestionFormDraft) => void;
|
||||
/** Escape in a question field stops dictation (STT wave) — threaded
|
||||
* from the page that owns the dictation hook. */
|
||||
onEscape?: () => void;
|
||||
@@ -100,6 +104,8 @@ export function QuestionDock({
|
||||
reveal = false,
|
||||
onExpand,
|
||||
onSubmit,
|
||||
initialDraft,
|
||||
onSaveDraft,
|
||||
onEscape,
|
||||
phase = "probe",
|
||||
dictationClaim = true,
|
||||
@@ -155,6 +161,8 @@ export function QuestionDock({
|
||||
phase={phase}
|
||||
active={expanded}
|
||||
onSubmit={onSubmit}
|
||||
initialDraft={initialDraft}
|
||||
onSaveDraft={onSaveDraft}
|
||||
onEscape={onEscape}
|
||||
dictationClaim={dictationClaim}
|
||||
compactionRunning={compactionRunning}
|
||||
@@ -250,6 +258,8 @@ function QuestionDockForm({
|
||||
phase = "probe",
|
||||
active,
|
||||
onSubmit,
|
||||
initialDraft,
|
||||
onSaveDraft,
|
||||
onEscape,
|
||||
dictationClaim = true,
|
||||
compactionRunning = false,
|
||||
@@ -311,22 +321,22 @@ function QuestionDockForm({
|
||||
// single-select displaces, multi-select coexists — picking the ask
|
||||
// "Other…" row in a multi-select must never clear already-picked
|
||||
// options, and picking an option must never clear a typed custom answer.
|
||||
const [sel, setSel] = useState<DockSelection>(emptyDockSelection());
|
||||
const [sel, setSel] = useState<DockSelection>(() => initialDraft?.selection ?? emptyDockSelection());
|
||||
// The learner's transfer channel — thinking, never "a note".
|
||||
const [thinking, setThinking] = useState("");
|
||||
const [other, setOther] = useState("");
|
||||
const [text, setText] = useState("");
|
||||
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
|
||||
const [other, setOther] = useState(initialDraft?.other ?? "");
|
||||
const [text, setText] = useState(initialDraft?.text ?? "");
|
||||
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
||||
* round, owner 2026-10-01): the run's flush lands via setState, which
|
||||
* a closure waiting through `await finishDictation()` cannot observe
|
||||
* in time — submit reads these, kept current at every write site. */
|
||||
const thinkingTextRef = useRef("");
|
||||
const answerTextRef = useRef("");
|
||||
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
|
||||
const answerTextRef = useRef(initialDraft?.text ?? "");
|
||||
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
||||
// resting at 50 — a real coin flip by definition, untouched or
|
||||
// deliberate (the same number, the same meaning). The dock unmounts
|
||||
// between questions, so each question starts fresh at 50.
|
||||
const [confIdx, setConfIdx] = useState<number>(DEFAULT_CONFIDENCE_INDEX);
|
||||
const [confIdx, setConfIdx] = useState<number>(initialDraft?.confidenceIndex ?? DEFAULT_CONFIDENCE_INDEX);
|
||||
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||
|
||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
||||
@@ -390,6 +400,15 @@ function QuestionDockForm({
|
||||
// "untouched"); buildAnswer drops it on "I don't know".
|
||||
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
||||
};
|
||||
// Snapshot before optimistic submission unmounts the form. Ref values
|
||||
// include the dictation finals that just landed, unlike stale closures.
|
||||
onSaveDraft?.({
|
||||
selection: { ...sel, selected: new Set(sel.selected) },
|
||||
thinking: thinkingTextRef.current,
|
||||
other,
|
||||
text: answerTextRef.current,
|
||||
confidenceIndex: confIdx,
|
||||
});
|
||||
onSubmit(buildAnswer(qd, req, toolName));
|
||||
};
|
||||
|
||||
|
||||
+173
-28
@@ -17,6 +17,8 @@ import type { AgentEvent } from "@earendil-works/pi-agent-core";
|
||||
import type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||
import {
|
||||
needsContinuation,
|
||||
danglingQuestionOf,
|
||||
answeredCallIds,
|
||||
measuredContextTokens,
|
||||
newAnswerResult,
|
||||
newNextMessage,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
type LhViewMessage,
|
||||
type LiveExecution,
|
||||
type QuestionAnswerData,
|
||||
type QuestionDraft,
|
||||
} from "../src/core";
|
||||
|
||||
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
||||
@@ -78,13 +81,25 @@ async function turnInFlight(model: SessionModel): Promise<boolean | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/** One wire event off the SSE body: parsed JSON, or the [DONE] terminal. */
|
||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
||||
const STREAM_DROPPED = "The connection dropped before the turn completed.";
|
||||
|
||||
/** Mark transport failures without confusing JSON/application bugs with drops. */
|
||||
class StreamReadError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super(cause instanceof Error ? cause.message : String(cause), { cause });
|
||||
}
|
||||
}
|
||||
|
||||
/** Wire events; return true ONLY for the explicit completion marker. */
|
||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>, boolean> {
|
||||
const decoder = new TextDecoder();
|
||||
let buf = "";
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) return;
|
||||
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||
try { chunk = await reader.read(); }
|
||||
catch (err) { throw new StreamReadError(err); }
|
||||
const { done, value } = chunk;
|
||||
if (done) return false;
|
||||
buf += decoder.decode(value, { stream: true });
|
||||
for (;;) {
|
||||
const end = buf.indexOf("\n\n");
|
||||
@@ -94,7 +109,7 @@ async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): Asyn
|
||||
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
||||
if (!dataLine) continue;
|
||||
const data = dataLine.slice("data: ".length);
|
||||
if (data === "[DONE]") return;
|
||||
if (data === "[DONE]") return true;
|
||||
yield JSON.parse(data) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
@@ -196,6 +211,8 @@ export function visibleQueuedSteers(
|
||||
export interface SessionModelOptions extends SessionModelDeps {
|
||||
chatId: string;
|
||||
initialMessages: LhMessage[];
|
||||
/** Retained, memory-only server failure when reopening a session. */
|
||||
initialError?: string;
|
||||
/** Fires on every state change (the hook re-renders). */
|
||||
onChange?: () => void;
|
||||
/** The reattach settled terminal (turn-settled): no turn running, the
|
||||
@@ -220,6 +237,9 @@ export class SessionModel {
|
||||
// at every full sync; the write-ahead absorbs the optimistic entries.
|
||||
private base: LhMessage[];
|
||||
private tail: LhMessage[] = [];
|
||||
private questionDrafts = new Map<string, QuestionDraft>();
|
||||
/** Only learner answers submitted through send(), never silent dismissals. */
|
||||
private unconfirmedAnswers = new Map<string, string>();
|
||||
// The live turn's execution layer (view model mid-turn states):
|
||||
private executions = new Map<string, LiveExecution>();
|
||||
private streamingIds = new Set<string>();
|
||||
@@ -229,6 +249,8 @@ export class SessionModel {
|
||||
private statusValue: SessionStatus = "ready";
|
||||
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||
private errorValue: { message: string } | null = null;
|
||||
/** Dismissing a banner must not authorize an automatic replacement turn. */
|
||||
private automaticContinuationBlocked = false;
|
||||
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||
private liveFailureId: string | null = null;
|
||||
// The single live consume (POST response or reattach stream): a newer
|
||||
@@ -240,6 +262,8 @@ export class SessionModel {
|
||||
constructor(opts: SessionModelOptions) {
|
||||
this.chatId = opts.chatId;
|
||||
this.base = opts.initialMessages;
|
||||
this.errorValue = opts.initialError === undefined ? null : { message: opts.initialError };
|
||||
this.automaticContinuationBlocked = opts.initialError !== undefined;
|
||||
// BOUND: the browser's native fetch is a Window method — storing it
|
||||
// unbound makes every call throw "Illegal invocation" in the browser
|
||||
// (Node's undici doesn't care, so the tests never saw it). Always go
|
||||
@@ -300,6 +324,7 @@ export class SessionModel {
|
||||
|
||||
private setError(message: string | null): void {
|
||||
this.errorValue = message === null ? null : { message };
|
||||
if (message !== null) this.automaticContinuationBlocked = true;
|
||||
this.onChange?.();
|
||||
}
|
||||
|
||||
@@ -346,6 +371,12 @@ export class SessionModel {
|
||||
}
|
||||
const agent = event as unknown as AgentEvent;
|
||||
switch (agent.type) {
|
||||
case "agent_start":
|
||||
// Positive evidence of a new/replayed server-owned turn. Any
|
||||
// later failure in that replay blocks continuation again.
|
||||
this.automaticContinuationBlocked = false;
|
||||
if (this.errorValue !== null) this.setError(null);
|
||||
return false;
|
||||
case "message_start":
|
||||
case "message_update":
|
||||
case "message_end": {
|
||||
@@ -445,18 +476,28 @@ export class SessionModel {
|
||||
|
||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||
* terminal (stream-route only) fires the settle hook and ends. */
|
||||
async consumeStream(res: Response, controller = this.consumeAbort): Promise<void> {
|
||||
async consumeStream(res: Response, controller = this.consumeAbort, allowRecovery = true): Promise<void> {
|
||||
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||
const reader = res.body.getReader();
|
||||
let sawEvent = false;
|
||||
let sawDone = false;
|
||||
try {
|
||||
for await (const event of sseEvents(reader)) {
|
||||
const events = sseEvents(reader);
|
||||
for (;;) {
|
||||
let next: IteratorResult<Record<string, unknown>, boolean>;
|
||||
try { next = await events.next(); }
|
||||
catch (err) {
|
||||
if (err instanceof StreamReadError) break;
|
||||
throw err;
|
||||
}
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (next.done) { sawDone = next.value; break; }
|
||||
const event = next.value;
|
||||
if (event.type === "turn-settled") {
|
||||
// The raced-settle terminal: no turn to attach to — the
|
||||
// record is current; the settle hook refetches.
|
||||
await reader.cancel().catch(() => {});
|
||||
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
||||
sawDone = true;
|
||||
this.onReattachSettled?.();
|
||||
return;
|
||||
}
|
||||
const acted = this.applyWireEvent(event);
|
||||
@@ -466,14 +507,32 @@ export class SessionModel {
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await reader.cancel().catch(() => {});
|
||||
reader.releaseLock();
|
||||
// A cancelled consume may finish after its replacement has entered
|
||||
// Thinking. Only the current owner can settle shared state.
|
||||
if (this.consumeAbort === controller) {
|
||||
this.streamingIds = new Set();
|
||||
this.executions = new Map();
|
||||
if (this.statusValue !== "error") this.setStatus("ready");
|
||||
if (sawDone && this.statusValue !== "error") this.setStatus("ready");
|
||||
}
|
||||
}
|
||||
if (this.consumeAbort !== controller || sawDone) return;
|
||||
if (!allowRecovery) throw new StreamReadError(new Error(STREAM_DROPPED));
|
||||
// One read-only recovery: refresh the canonical base, then replay the
|
||||
// SAME server-owned turn. Never resend the learner's input.
|
||||
await this.resumeStream(true, false);
|
||||
}
|
||||
|
||||
private async fetchView(url: string, signal: AbortSignal, recoveringDrop: boolean): Promise<Response> {
|
||||
try {
|
||||
const res = await this.fetch(url, { signal });
|
||||
if (recoveringDrop && !res.ok) throw new Error((await res.text()) || STREAM_DROPPED);
|
||||
return res;
|
||||
} catch (err) {
|
||||
if (recoveringDrop && !signal.aborted) throw new StreamReadError(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -489,7 +548,7 @@ export class SessionModel {
|
||||
* lands on top. The stream then replays the turn from its first event
|
||||
* through the SAME consume path.
|
||||
*/
|
||||
async resumeStream(refetchRecordFirst = true): Promise<void> {
|
||||
async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise<void> {
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
@@ -519,16 +578,27 @@ export class SessionModel {
|
||||
this.setStatus("submitted");
|
||||
try {
|
||||
if (refetchRecordFirst) {
|
||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery);
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (recordRes.ok) {
|
||||
const record = (await recordRes.json()) as { messages?: LhMessage[] };
|
||||
let text: string;
|
||||
try { text = await recordRes.text(); }
|
||||
catch (err) {
|
||||
if (!allowRecovery && !controller.signal.aborted) throw new StreamReadError(err);
|
||||
throw err;
|
||||
}
|
||||
// Parse outside the transport catch: malformed JSON is a
|
||||
// contract bug, not a connection-drop diagnostic.
|
||||
const record = JSON.parse(text) as { messages?: LhMessage[]; turnError?: string };
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (Array.isArray(record.messages)) {
|
||||
this.mutate(() => {
|
||||
this.base = record.messages!;
|
||||
if (record.turnError !== undefined) {
|
||||
this.errorValue = { message: record.turnError };
|
||||
this.automaticContinuationBlocked = true;
|
||||
}
|
||||
this.forgetResolvedDrafts(record.messages!);
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
});
|
||||
@@ -543,15 +613,19 @@ export class SessionModel {
|
||||
});
|
||||
}
|
||||
}
|
||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery);
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
||||
await this.consumeStream(res, controller);
|
||||
await this.consumeStream(res, controller, allowRecovery);
|
||||
} catch (err) {
|
||||
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
||||
this.setError((err as Error).message);
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled
|
||||
// A failed record read can leave the optimistic answer in the tail.
|
||||
// Restore its Submit affordance; the next attempt reconciles first.
|
||||
this.mutate(() => {
|
||||
this.tail = this.tail.filter(m => !this.unconfirmedAnswers.has(m.id));
|
||||
this.unconfirmedAnswers.clear();
|
||||
});
|
||||
this.setError(!allowRecovery && err instanceof StreamReadError ? STREAM_DROPPED : (err as Error).message);
|
||||
this.setStatus("error");
|
||||
}
|
||||
}
|
||||
@@ -563,12 +637,42 @@ export class SessionModel {
|
||||
* server's 409 + identity retry covers the wake-race window).
|
||||
*/
|
||||
async send(input: ChatInput): Promise<void> {
|
||||
if (!this.readyRef) return;
|
||||
const recovering = this.statusValue === "error";
|
||||
if (!this.readyRef && !recovering) return;
|
||||
this.consumeAbort?.abort();
|
||||
const controller = new AbortController();
|
||||
this.consumeAbort = controller;
|
||||
this.setError(null);
|
||||
this.setStatus("submitted");
|
||||
if (recovering && input.type === "answer") {
|
||||
try {
|
||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, { signal: controller.signal });
|
||||
if (this.consumeAbort !== controller) return;
|
||||
if (!res.ok) throw new Error((await res.text()) || "An error occurred.");
|
||||
const record = await res.json() as { messages?: LhMessage[]; turnInFlight?: boolean; turnError?: string };
|
||||
if (this.consumeAbort !== controller) return;
|
||||
// No verified record/liveness: retain the draft and never guess
|
||||
// whether a lost acknowledgement meant the input was accepted.
|
||||
if (!Array.isArray(record.messages) || typeof record.turnInFlight !== "boolean") throw new Error("An error occurred.");
|
||||
this.setMessages(record.messages, record.turnError);
|
||||
if (record.turnInFlight) {
|
||||
await this.resumeStream(false);
|
||||
return;
|
||||
}
|
||||
if (input.type === "answer" && (answeredCallIds(record.messages).has(input.toolCallId) || danglingQuestionOf(record.messages)?.toolCallId !== input.toolCallId)) {
|
||||
// The call already resolved, or no longer owns the dock. Adopt
|
||||
// truth, never POST that answer a second time.
|
||||
this.setStatus("ready");
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (input.type !== "continue") this.automaticContinuationBlocked = false;
|
||||
// The optimistic appends — exactly the messages the server's
|
||||
// write-ahead will persist (inputAppends), with LOCAL ids so
|
||||
// adoption can tell them from the record's absorbed copies.
|
||||
@@ -585,7 +689,9 @@ export class SessionModel {
|
||||
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||
} else if (input.type === "answer") {
|
||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
||||
optimistic.push({ ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() });
|
||||
const result = { ...newAnswerResult(input.toolCallId, tool, input.answer), id: localId() };
|
||||
optimistic.push(result);
|
||||
this.unconfirmedAnswers.set(result.id, input.toolCallId);
|
||||
}
|
||||
if (optimistic.length > 0) this.mutate(() => {
|
||||
this.tail = [...this.tail, ...optimistic];
|
||||
@@ -605,9 +711,7 @@ export class SessionModel {
|
||||
// connection is Diet A's whole job.
|
||||
const res = await this.postInputEvent(input);
|
||||
if (res.status === 409) {
|
||||
this.setError("A turn is already running — try again in a moment.");
|
||||
this.setStatus("error");
|
||||
return;
|
||||
throw new Error("A turn is already running — try again in a moment.");
|
||||
}
|
||||
if (!res.ok) {
|
||||
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
||||
@@ -629,7 +733,17 @@ export class SessionModel {
|
||||
if (this.consumeAbort !== controller) return;
|
||||
await this.resumeStream(true);
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return; // stop() or a newer consume won
|
||||
if (this.consumeAbort !== controller || controller.signal.aborted) return;
|
||||
// Restore the question when acceptance failed or is unknown. Remove
|
||||
// only THIS optimistic answer, never saved copies/newer live content.
|
||||
// Other input kinds retain their existing optimistic presentation.
|
||||
if (input.type === "answer") {
|
||||
const ids = new Set(optimistic.map(m => m.id));
|
||||
this.mutate(() => {
|
||||
this.tail = this.tail.filter(m => !ids.has(m.id));
|
||||
for (const id of ids) this.unconfirmedAnswers.delete(id);
|
||||
});
|
||||
}
|
||||
this.setError((err as Error).message);
|
||||
this.setStatus("error");
|
||||
}
|
||||
@@ -740,10 +854,32 @@ export class SessionModel {
|
||||
this.setStatus("ready");
|
||||
}
|
||||
|
||||
/** Submission snapshots outlive the dock, not the session mount. */
|
||||
questionDraft(toolCallId: string): QuestionDraft | undefined {
|
||||
return this.questionDrafts.get(toolCallId);
|
||||
}
|
||||
|
||||
saveQuestionDraft(toolCallId: string, draft: Partial<QuestionDraft>): void {
|
||||
this.questionDrafts.set(toolCallId, { ...this.questionDrafts.get(toolCallId), ...draft });
|
||||
}
|
||||
|
||||
private forgetResolvedDrafts(record: readonly LhMessage[]): void {
|
||||
const answered = answeredCallIds(record);
|
||||
for (const id of answered) this.questionDrafts.delete(id);
|
||||
for (const [id, callId] of this.unconfirmedAnswers) {
|
||||
if (answered.has(callId)) this.unconfirmedAnswers.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/** Full snapshot swap (the idle settle sync — server canon). */
|
||||
setMessages(record: LhMessage[]): void {
|
||||
setMessages(record: LhMessage[], turnError?: string): void {
|
||||
this.mutate(() => {
|
||||
this.base = record;
|
||||
if (turnError !== undefined) {
|
||||
this.errorValue = { message: turnError };
|
||||
this.automaticContinuationBlocked = true;
|
||||
}
|
||||
this.forgetResolvedDrafts(record);
|
||||
this.incompleteUsageIds.clear();
|
||||
this.tail = [];
|
||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
||||
@@ -763,10 +899,19 @@ export class SessionModel {
|
||||
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
||||
this.mutate(() => {
|
||||
this.base = record;
|
||||
this.forgetResolvedDrafts(record);
|
||||
this.tail = this.tail.filter((m) => !absorbed(m, record));
|
||||
});
|
||||
}
|
||||
|
||||
/** Automatic mount/sync continuation is never permission to retry a
|
||||
* failed turn. Read current model truth, not a stale React closure. */
|
||||
async continueIfOwed(record: readonly LhMessage[]): Promise<void> {
|
||||
if (this.statusValue === "ready" && !this.automaticContinuationBlocked && this.getState().error === null && needsContinuation(record)) {
|
||||
await this.send({ type: "continue" });
|
||||
}
|
||||
}
|
||||
|
||||
/** The mount/sync continuation fold (native-messages.ts). */
|
||||
static needsContinuation = needsContinuation;
|
||||
|
||||
|
||||
+11
-3
@@ -22,16 +22,18 @@ export interface UseChatSessionOptions {
|
||||
chatId: string;
|
||||
/** The native record seed (server-rendered page or a full sync). */
|
||||
initialMessages: LhMessage[];
|
||||
/** Transient settlement failure retained by the server, not the transcript. */
|
||||
initialError?: string;
|
||||
/** Reattach on mount: a turn is running server-side with no live view. */
|
||||
initiallyInFlight?: boolean;
|
||||
}
|
||||
|
||||
export function useChatSession({ chatId, initialMessages, initiallyInFlight }: UseChatSessionOptions) {
|
||||
export function useChatSession({ chatId, initialMessages, initialError, initiallyInFlight }: UseChatSessionOptions) {
|
||||
// The model is per-mount (chatId-keyed component); onChange forces the
|
||||
// re-render. The ref pattern keeps it stable across renders.
|
||||
const modelRef = useRef<SessionModel | null>(null);
|
||||
if (modelRef.current === null) {
|
||||
modelRef.current = new SessionModel({ chatId, initialMessages });
|
||||
modelRef.current = new SessionModel({ chatId, initialMessages, initialError });
|
||||
}
|
||||
const model = modelRef.current;
|
||||
// The render VERSION — bumped by every model change, so the state memo
|
||||
@@ -101,6 +103,7 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
);
|
||||
|
||||
const send = useCallback((input: ChatInput) => void model.send(input), [model]);
|
||||
const continueIfOwed = useCallback((record: readonly LhMessage[]) => void model.continueIfOwed(record), [model]);
|
||||
const steer = useCallback((text: string) => void model.steer(text), [model]);
|
||||
const submitAnswer = useCallback(
|
||||
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||
@@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
||||
[model],
|
||||
);
|
||||
const setMessages = useCallback((record: LhMessage[]) => model.setMessages(record), [model]);
|
||||
const questionDraft = useCallback((toolCallId: string) => model.questionDraft(toolCallId), [model]);
|
||||
const saveQuestionDraft = useCallback((toolCallId: string, draft: Parameters<SessionModel["saveQuestionDraft"]>[1]) => model.saveQuestionDraft(toolCallId, draft), [model]);
|
||||
const setMessages = useCallback((record: LhMessage[], turnError?: string) => model.setMessages(record, turnError), [model]);
|
||||
const adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||
const stop = useCallback(() => model.stop(), [model]);
|
||||
const clearError = useCallback(() => model.clearError(), [model]);
|
||||
@@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
||||
error: state.error,
|
||||
steerQueue: state.steerQueue,
|
||||
send,
|
||||
continueIfOwed,
|
||||
steer,
|
||||
submitAnswer,
|
||||
dismissQuestion,
|
||||
dismissProblem,
|
||||
questionDraft,
|
||||
saveQuestionDraft,
|
||||
setMessages,
|
||||
adoptRecord,
|
||||
stop,
|
||||
|
||||
@@ -307,7 +307,9 @@ sequenceDiagram
|
||||
- **The prober→teacher handoff**: the first turn(s) run the prober (floor/ceiling via quiz, goal via ask); `probe_done` (server-executed) files the assessment and queues a handoff steer; from the first `tool-probe_done` part in the transcript, the handler builds the teacher agent for every later turn. Phases (`core/phases.ts`) fold strictly: probe → plan → teach.
|
||||
- **The formal plan tree is author-once**: the teacher authors the session's dependency map with a single schema-validated `plan_init` tool (a mermaid-subset graph, parsed deterministically with line-numbered errors, validated as a DAG). Progress is never mutated — statuses (`current`/`visited`/upcoming) are **derived** from tagged quiz calls in the transcript; the tree panel and the plan blocks render that derivation.
|
||||
- **Job tools are server-executed, ack-and-steer**: `research`/`demo`/`visual` resolve immediately mid-turn with a fire-and-forget ack (the teacher keeps teaching); `spawn(job, input)` fires the background runner.
|
||||
- Each turn service is a named, error-contained seam taking explicit deps; failures log one `[chat <id>]` line and never touch the response. Swapping any of them touches nothing else.
|
||||
- Turn services are named seams taking explicit dependencies. Extraction contains its errors, but **canonical persistence must not fail silently**: a failed input write refuses acceptance without starting an agent; `persistTurn` throws on a failed reply write. The runner reports settlement failure before `[DONE]` and releases liveness even when normal settlement fails. Steer reconciliation uses the durable record, so a later bookkeeping failure cannot replay already-saved learner input.
|
||||
- **Late attachment must still see settlement failure.** A small, memory-only notice per session precedes the settled stream terminal when the failing turn has already left the live registry. The next accepted turn, successful session deletion, or server exit clears it. Reopening seeds the existing banner from that notice and waits for ordinary learner input; dismissing the banner is not permission to automatically retry the model. No synthetic failure entry is added to the saved transcript.
|
||||
- **Recovery never blindly resends an answer.** Submitted question drafts survive optimistic unmount in memory; the existing Submit action reconciles uncertain acceptance against the canonical record and running-turn state before sending again. EOF or a rejected stream read without a terminal triggers one read-only reattachment; another transport failure shows the existing error banner rather than claiming completion. Explicit completion, cancellation, and the literal-final-entry saved-error rule remain unchanged.
|
||||
|
||||
### 5.1 The wake pipeline (Workbench → Classroom)
|
||||
|
||||
|
||||
@@ -511,6 +511,21 @@ export interface DockSelection {
|
||||
|
||||
export const emptyDockSelection = (): DockSelection => ({ selected: new Set(), otherSelected: false, dontKnow: false });
|
||||
|
||||
/** In-memory submission snapshot: survives the question form's optimistic
|
||||
* unmount, never written to storage. Indices use the call's stable shuffle. */
|
||||
export interface QuestionFormDraft {
|
||||
selection: DockSelection;
|
||||
thinking: string;
|
||||
other: string;
|
||||
text: string;
|
||||
confidenceIndex: number;
|
||||
}
|
||||
|
||||
export interface QuestionDraft {
|
||||
form?: QuestionFormDraft;
|
||||
readingThinking?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggling a listed option. Single-select: the option becomes THE answer and
|
||||
* displaces "Other". Multi-select: the option toggles alongside its siblings
|
||||
|
||||
@@ -42,8 +42,8 @@ import {
|
||||
import { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||
import { applyParkedDismissal } from "./dismissal-park.ts";
|
||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
||||
import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
|
||||
import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
|
||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.ts";
|
||||
@@ -79,6 +79,10 @@ import type {CallUsage, SteerMessage} from "../core/contracts.ts";
|
||||
* for write-ahead persistence, turn liveness, and the turn services.
|
||||
*/
|
||||
|
||||
function settlementErrorText(error: unknown): string {
|
||||
return error instanceof TurnPersistenceError ? error.message : "An error occurred.";
|
||||
}
|
||||
|
||||
const jobRunner = createJobRunner({
|
||||
chatStore: stores.chatStore,
|
||||
jobStore: stores.jobStore,
|
||||
@@ -289,7 +293,11 @@ export async function handleChatInput(
|
||||
// the composer gates), so adjacency holds by construction.
|
||||
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
||||
const merged = [...record.messages, ...appends];
|
||||
stores.chatStore.saveMessages(chatId, merged);
|
||||
try { stores.chatStore.saveMessages(chatId, merged); }
|
||||
catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||
}
|
||||
emit("record-changed", { chatId });
|
||||
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
||||
}
|
||||
@@ -313,14 +321,19 @@ export async function handleChatInput(
|
||||
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||
try {
|
||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
||||
// in the record — flip their jobs now (the wake's steers ride this
|
||||
// write-ahead; the delivering turn may run for minutes).
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
endTurn(chatId);
|
||||
return new Response("Your input could not be saved. Please try again.", { status: 500 });
|
||||
}
|
||||
// Visibility bookkeeping is not the canonical save. Its failure must
|
||||
// neither misreport a saved input as lost nor prevent the turn running.
|
||||
try {
|
||||
for (const appended of stagedAutomatic ? [] : appends) {
|
||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||
console.error(`[chat ${chatId}] input visibility bookkeeping failed:`, err);
|
||||
}
|
||||
|
||||
// MONEY CHANNEL (wave 4): one collector per request, fed by the agent's
|
||||
@@ -496,6 +509,21 @@ export async function handleChatInput(
|
||||
console.error(`[chat ${chatId}] turn error:`, error);
|
||||
return "An error occurred.";
|
||||
},
|
||||
onSettleError: settlementErrorText,
|
||||
onSettleFailure: (error, reportedErrorText) => {
|
||||
// POST only acknowledges acceptance; the submitter may attach
|
||||
// after this turn has already failed and left the registry.
|
||||
rememberTurnFailure(chatId, reportedErrorText ?? settlementErrorText(error));
|
||||
endTurn(chatId);
|
||||
unregisterTurn(chatId);
|
||||
unregisterLiveAgent(chatId);
|
||||
// The canonical save may have succeeded before later bookkeeping
|
||||
// threw. Reconcile against actual durability, never replay input
|
||||
// merely because usage/extraction/other settlement work failed.
|
||||
const persisted = stores.chatStore.getChat(chatId)?.messages ?? [];
|
||||
reconcileUserSteers(chatId, persisted);
|
||||
settleSteers(chatId, persisted);
|
||||
},
|
||||
onFinish: (turnMessages, isAborted) => {
|
||||
// Wave B: the record write is the agent's own final state —
|
||||
// the new messages are the slice past the merged base.
|
||||
|
||||
+2
-1
@@ -5,6 +5,7 @@ import { titleFromPrompt } from "../core/contracts.ts";
|
||||
import { graphRevision } from "./graph.ts";
|
||||
import { stores } from "./stores.ts";
|
||||
import { isTurnRunning } from "./turn-liveness.ts";
|
||||
import { clearTurnFailure } from "./turn-runs.ts";
|
||||
|
||||
/**
|
||||
* Server-side chat entries for the transport layer (app/api/chats*,
|
||||
@@ -108,7 +109,7 @@ export async function deleteChatSession(chatId: string, mode: ChatDeleteMode): P
|
||||
// ledgers → jobs → the chat record.
|
||||
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
||||
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
|
||||
stores.chatStore.deleteChat(chatId);
|
||||
if (stores.chatStore.deleteChat(chatId)) clearTurnFailure(chatId);
|
||||
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
|
||||
}
|
||||
|
||||
|
||||
@@ -79,6 +79,10 @@ export interface PiTurnSpec {
|
||||
* event stream itself is verbatim — provider errors ride their
|
||||
* message's stopReason/errorMessage through untouched). */
|
||||
onError?: (error: unknown) => string;
|
||||
/** Report settlement failure while the views are still open. */
|
||||
onSettleError?: (error: unknown) => string;
|
||||
/** Release liveness even if the normal finish seam throws. */
|
||||
onSettleFailure?: (error: unknown, reportedErrorText?: string) => void;
|
||||
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||
* Receives the agent's FULL final state (native messages) — the caller
|
||||
* persists the slice past its own base count. */
|
||||
@@ -119,11 +123,12 @@ export interface PiTurnHandle {
|
||||
}
|
||||
|
||||
export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
const { agent, onError, onFinish, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||
const { agent, onError, onFinish, onSettleError, onSettleFailure, usedToolCallIds, updateFlushMs = 100, requestCycle } = spec;
|
||||
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||
|
||||
let settled = false;
|
||||
let sawAbort = false;
|
||||
let reportedErrorText: string | undefined;
|
||||
|
||||
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
||||
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
|
||||
@@ -221,11 +226,22 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
// messages of its own (the prompt is composed per turn), so dropping
|
||||
// the role wholesale is exact; revisit if prompt-updates ever land.
|
||||
const settleWith = () => {
|
||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
||||
try {
|
||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
||||
} catch (err) {
|
||||
console.error("[turn] settlement failed:", err);
|
||||
try { onSettleFailure?.(err, reportedErrorText); }
|
||||
catch (cleanupError) { console.error("[turn] settlement cleanup failed:", cleanupError); }
|
||||
if (reportedErrorText === undefined) {
|
||||
reportedErrorText = onSettleError?.(err) ?? "An error occurred.";
|
||||
send({ type: "error", errorText: reportedErrorText });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handle = (event: AgentEvent) => {
|
||||
if (settled) return;
|
||||
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
||||
// every consumer downstream shares. No uniquifier → verbatim.
|
||||
const uniqued = uniquifier ? uniquifier.event(event) : event;
|
||||
@@ -246,8 +262,9 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
if (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
|
||||
}
|
||||
if (event.type === "agent_end") {
|
||||
closeAll();
|
||||
// Persist/report before the terminal closes every view to new frames.
|
||||
settleWith();
|
||||
closeAll();
|
||||
}
|
||||
};
|
||||
requestCycle?.bindRelease(handle);
|
||||
@@ -273,11 +290,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
||||
// stream as stopReason "error"): surface, close, persist. If the
|
||||
// agent already settled (agent_end reached before the throw),
|
||||
// the seam has run — never settle twice.
|
||||
if (settled) return;
|
||||
if (settled) {
|
||||
console.error("[turn] error after settlement:", err);
|
||||
return;
|
||||
}
|
||||
console.error("[turn] agent loop failed:", err);
|
||||
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
||||
closeAll();
|
||||
reportedErrorText = onError?.(err) ?? "An error occurred.";
|
||||
send({ type: "error", errorText: reportedErrorText });
|
||||
settleWith();
|
||||
closeAll();
|
||||
}
|
||||
})();
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
*/
|
||||
|
||||
import { emit } from "../core/events.ts";
|
||||
import { clearTurnFailure } from "./turn-runs.ts";
|
||||
|
||||
interface LhGlobal {
|
||||
__lhTurnRegistry?: Map<string, number>;
|
||||
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
|
||||
*/
|
||||
export function beginTurn(chatId: string): boolean {
|
||||
if (isTurnRunning(chatId)) return false;
|
||||
clearTurnFailure(chatId);
|
||||
inFlight.set(chatId, Date.now());
|
||||
emit("turn-started", { chatId });
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
|
||||
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
|
||||
* durable truth). Emits only when an entry was actually present: a
|
||||
* Mark the chat's turn as finished after normal settlement, or after the
|
||||
* failure notice has been retained by settlement cleanup. A subsequent
|
||||
* fetch reads durable truth, never a promise that an unsaved reply landed.
|
||||
* Emits only when an entry was actually present: a
|
||||
* defensive no-op endTurn must not fire a spurious settle.
|
||||
*/
|
||||
export function endTurn(chatId: string): void {
|
||||
|
||||
+30
-5
@@ -13,7 +13,9 @@
|
||||
* Registration lifecycle: chat-handler registers the handle the moment
|
||||
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
||||
* — after persist, usage, extraction, and endTurn — so anything that sees
|
||||
* "no running turn" also sees an already-persisted record. The per-chat
|
||||
* "no running turn" sees durable record truth. Failed settlement also
|
||||
* retains its small notice before unregistering, so late attachments do
|
||||
* not mistake an unsaved reply for success. The per-chat
|
||||
* 409 guard means a chatId can hold at most one handle, and beginTurn's
|
||||
* 30-minute TTL bounds the pathological never-settling case the same way
|
||||
* it does for the liveness map: if onFinish never fires, the stale entry
|
||||
@@ -30,6 +32,7 @@ import type { PiTurnHandle } from "./pi/ui-stream.ts";
|
||||
|
||||
interface LhTurnRunsGlobal {
|
||||
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
||||
__lhTurnFailures?: Map<string, string>;
|
||||
}
|
||||
|
||||
function runs(): Map<string, PiTurnHandle> {
|
||||
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
|
||||
return (g.__lhTurnRuns ??= new Map<string, PiTurnHandle>());
|
||||
}
|
||||
|
||||
/** One small, transient notice per failed session. Never stores messages.
|
||||
* Cleared on the next accepted begin, session deletion, or process exit. */
|
||||
function failures(): Map<string, string> {
|
||||
const g = globalThis as typeof globalThis & LhTurnRunsGlobal;
|
||||
return (g.__lhTurnFailures ??= new Map<string, string>());
|
||||
}
|
||||
|
||||
export function rememberTurnFailure(chatId: string, errorText: string): void {
|
||||
failures().set(chatId, errorText);
|
||||
}
|
||||
|
||||
export function turnFailure(chatId: string): string | undefined {
|
||||
return failures().get(chatId);
|
||||
}
|
||||
|
||||
export function clearTurnFailure(chatId: string): void {
|
||||
failures().delete(chatId);
|
||||
}
|
||||
|
||||
/** Register the chat's running turn (chat-handler, right after the turn
|
||||
* starts). One per chat by construction — the 409 guard. */
|
||||
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
||||
@@ -67,11 +89,14 @@ export function abortTurn(chatId: string): { ok: true } | { ok: false; reason: "
|
||||
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
||||
* turn (raced settle, or the client checked turnInFlight a beat too
|
||||
* late): a same-dialect SSE response whose single chunk tells the client
|
||||
* "nothing to attach to — the record is current, refetch it". Chosen as
|
||||
* an EVENT (not a status code) so the reattach path consumes ONE wire
|
||||
* "nothing to attach to — refetch durable truth". A retained settlement
|
||||
* failure is delivered first, including when no view attached in time.
|
||||
* Chosen as an EVENT (not a status code), consuming ONE wire
|
||||
* protocol end to end. */
|
||||
export function settledTurnResponse(): Response {
|
||||
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
|
||||
export function settledTurnResponse(chatId?: string): Response {
|
||||
const errorText = chatId ? turnFailure(chatId) : undefined;
|
||||
const notice = errorText === undefined ? "" : `data: ${JSON.stringify({ type: "error", errorText })}\n\n`;
|
||||
return new Response(`${notice}data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n`, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/**
|
||||
* The chat-turn services — one named, error-contained seam per turn
|
||||
* The chat-turn services — one named seam per turn
|
||||
* responsibility, extracted from the chat-handler so it composes instead of
|
||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||
* job runner) — no module here imports an adapter.
|
||||
@@ -26,6 +26,14 @@ import type { ChatStore, GraphStore } from "../core/ports.ts";
|
||||
import type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.ts";
|
||||
|
||||
/** Distinguish a failed canonical save from later bookkeeping failures. */
|
||||
export class TurnPersistenceError extends Error {
|
||||
constructor(cause: unknown) {
|
||||
super("The reply could not be saved.", { cause });
|
||||
this.name = "TurnPersistenceError";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The canonical state write (Wave B, owner-approved 2026-09-26): append the
|
||||
* turn's own messages — the agent loop's native history, provider/model/
|
||||
@@ -45,7 +53,8 @@ import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/setti
|
||||
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||
* idempotent (persist replays are safe).
|
||||
*
|
||||
* Returns the stamped new messages (usage provenance reads them).
|
||||
* Returns the stamped new messages (usage provenance reads them). Throws
|
||||
* TurnPersistenceError on failure so the runner can report it before DONE.
|
||||
*/
|
||||
export function persistTurn(
|
||||
chatStore: ChatStore,
|
||||
@@ -56,7 +65,7 @@ export function persistTurn(
|
||||
): LhMessage[] {
|
||||
try {
|
||||
const record = chatStore.getChat(chatId);
|
||||
if (!record) return [];
|
||||
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||
const stamped: LhMessage[] = [];
|
||||
for (const m of turnMessages.slice(baseCount)) {
|
||||
const message = m as LhMessage;
|
||||
@@ -79,7 +88,7 @@ export function persistTurn(
|
||||
return stamped;
|
||||
} catch (err) {
|
||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
||||
return [];
|
||||
throw new TurnPersistenceError(err);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+102
-2
@@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { effectiveSettings } from "../src/server/stores.ts";
|
||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts";
|
||||
import { SessionModel } from "../components/session-core.ts";
|
||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts";
|
||||
import { projectView } from "../src/core/view.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
@@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
// store imports already resolved under).
|
||||
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
||||
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
|
||||
const streamRoute = await import("../app/api/chats/[id]/stream/route.ts");
|
||||
const recordRoute = await import("../app/api/chats/[id]/route.ts");
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||
@@ -845,6 +848,103 @@ describe("chat store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical persistence failures", () => {
|
||||
it("refuses a failed input save without building an agent or leaving a live turn", async () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: failed answer write-ahead");
|
||||
createdChats.push(chat.id);
|
||||
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||
const before = chatStore.getChat(chat.id)!.messages;
|
||||
const save = chatStore.saveMessages;
|
||||
let builds = 0;
|
||||
chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); };
|
||||
try {
|
||||
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => {
|
||||
builds++;
|
||||
return agentFactory(engine)!(opts);
|
||||
});
|
||||
const running = runningTurn(chat.id);
|
||||
if (running) await new Response(running.subscribe()).text();
|
||||
assert.equal(res.status, 500);
|
||||
const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() });
|
||||
await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"]));
|
||||
assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again.");
|
||||
assert.equal(await res.text(), "Your input could not be saved. Please try again.");
|
||||
assert.equal(builds, 0);
|
||||
assert.equal(isTurnRunning(chat.id), false);
|
||||
assert.equal(runningTurn(chat.id), undefined);
|
||||
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
|
||||
} finally { chatStore.saveMessages = save; }
|
||||
});
|
||||
|
||||
it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: failed reply persistence");
|
||||
createdChats.push(chat.id);
|
||||
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||
const save = chatStore.saveMessages;
|
||||
let writes = 0;
|
||||
chatStore.saveMessages = (id, messages) => {
|
||||
if (++writes === 2) throw new Error("synthetic reply save failure");
|
||||
return save(id, messages);
|
||||
};
|
||||
try {
|
||||
const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine));
|
||||
const errors = eventsOf(body).filter(e => e.type === "error");
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0]!.errorText, "The reply could not be saved.");
|
||||
assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]"));
|
||||
assert.equal(isTurnRunning(chat.id), false);
|
||||
assert.equal(runningTurn(chat.id), undefined);
|
||||
assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult");
|
||||
// The normal submitter may not reach GET /stream until AFTER the
|
||||
// failing turn has left the live registry. It must still see it.
|
||||
const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) });
|
||||
assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved.");
|
||||
// An already-open idle view can miss both the start and the wire
|
||||
// error, then learn of settlement only via a record synchronization.
|
||||
const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json();
|
||||
assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation");
|
||||
let automaticPosts = 0;
|
||||
const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => {
|
||||
automaticPosts++;
|
||||
return new Response("unexpected automatic retry", { status: 500 });
|
||||
} });
|
||||
missedView.setMessages(snapshot.messages, snapshot.turnError);
|
||||
await missedView.continueIfOwed(snapshot.messages);
|
||||
assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment");
|
||||
assert.equal(missedView.getState().error?.message, "The reply could not be saved.");
|
||||
} finally { chatStore.saveMessages = save; }
|
||||
});
|
||||
|
||||
it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => {
|
||||
const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]);
|
||||
const chat = createConfiguredChat("Test: post-save bookkeeping failure");
|
||||
createdChats.push(chat.id);
|
||||
const steer = newUserMessage("Synthetic learner steer");
|
||||
const appendUsage = chatStore.appendTurnUsage;
|
||||
chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); };
|
||||
try {
|
||||
const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => {
|
||||
const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage });
|
||||
agent.subscribe(event => {
|
||||
if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true);
|
||||
});
|
||||
return agent;
|
||||
});
|
||||
assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred.");
|
||||
assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed");
|
||||
const delivered: LhMessage[] = [];
|
||||
const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters<typeof registerLiveAgent>[1];
|
||||
registerLiveAgent(chat.id, next);
|
||||
assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent");
|
||||
} finally {
|
||||
chatStore.appendTurnUsage = appendUsage;
|
||||
unregisterLiveAgent(chat.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
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 { engine } = scriptedTeacher(defaultLesson);
|
||||
|
||||
+5
-1
@@ -657,6 +657,7 @@ describe("graph ledger store", () => {
|
||||
|
||||
import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts";
|
||||
import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts";
|
||||
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import {
|
||||
@@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
const turnedAway = await deleteChatSession(dA.id, "delete-nodes");
|
||||
assert.deepEqual(turnedAway, { ok: false, status: 409, code: "turn_in_flight", error: "A turn is running in this session — try again when it finishes", jobIds: [] });
|
||||
endTurn(dA.id);
|
||||
// 409 — running background jobs.
|
||||
// 409 — running background jobs; a refused delete keeps its notice.
|
||||
rememberTurnFailure(dA.id, "The reply could not be saved.");
|
||||
const job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" });
|
||||
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
|
||||
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
|
||||
if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]);
|
||||
assert.match(await settledTurnResponse(dA.id).text(), /The reply could not be saved\./);
|
||||
assert.equal(jobStore.deleteJobsForChat(dA.id), 1, "test cleanup — the job file is gone");
|
||||
|
||||
// Success — a terminal job rides along; the chat record dies last.
|
||||
@@ -830,6 +833,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.ok(outcome.graphRevision > 0, "the version stamp moved");
|
||||
}
|
||||
assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone");
|
||||
assert.doesNotMatch(await settledTurnResponse(dA.id).text(), /"type":"error"/, "deletion clears its transient notice");
|
||||
assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone");
|
||||
// And a second call is an honest 404.
|
||||
assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" });
|
||||
|
||||
@@ -78,6 +78,31 @@ function renderDock(props: {
|
||||
);
|
||||
}
|
||||
|
||||
describe("question submission draft restoration", () => {
|
||||
const draft = {
|
||||
selection: { selected: new Set([0]), otherSelected: true, dontKnow: false },
|
||||
thinking: "preserved reasoning",
|
||||
other: "preserved custom answer",
|
||||
text: "preserved free-text answer",
|
||||
confidenceIndex: 4,
|
||||
};
|
||||
|
||||
it("restores a quiz selection, thinking, and confidence when its form remounts", () => {
|
||||
const html = renderToStaticMarkup(<QuestionDock toolName="quiz" toolCallId="q1" input={quizInput} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.match(html, /role="radio" aria-checked="true" data-kbdrow="0"/);
|
||||
assert.ok(html.includes("preserved reasoning"));
|
||||
assert.ok(html.includes("90%"));
|
||||
});
|
||||
|
||||
it("restores an ask's custom answer and a free-text answer", () => {
|
||||
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.ok(custom.includes('value="preserved custom answer"'));
|
||||
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
|
||||
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.ok(text.includes("preserved free-text answer"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
|
||||
const job = (over: Partial<JobRecord>): JobRecord => ({
|
||||
id: "j1",
|
||||
|
||||
@@ -684,6 +684,334 @@ describe("provider-error banner (live and saved)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncated stream recovery", () => {
|
||||
const truncated = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "partial" }] } });
|
||||
const completed = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "complete" }] } }, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "complete" }], stopReason: "stop" } }, "[DONE]");
|
||||
|
||||
it("reattaches once after EOF without DONE and replaces the incomplete replay", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ userText: "go", stream: () => ++streams === 1 ? truncated() : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.equal(m.getState().nativeMessages.filter(m => m.role === "assistant").length, 1);
|
||||
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
|
||||
});
|
||||
|
||||
it("surfaces an error after a second truncation without retrying the input", async () => {
|
||||
let streams = 0;
|
||||
let posts = 0;
|
||||
const m = model({ fetch: dietFetch({ onPost: () => posts++, stream: () => { streams++; return truncated(); } }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("reports the approved connection error when the recovery record read fails", async () => {
|
||||
let reads = 0;
|
||||
const m = model({ fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
if (String(url).endsWith("/stream")) return truncated();
|
||||
if (++reads === 1) return Response.json({ messages: [] });
|
||||
throw new TypeError("synthetic offline");
|
||||
} });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("accepts turn-settled when the original turn finished during the disconnection", async () => {
|
||||
let streams = 0;
|
||||
let settled = 0;
|
||||
const m = model({ onReattachSettled: () => { settled++; }, fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : sseResponse({ type: "turn-settled" }, "[DONE]") }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(settled, 1);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("also recovers a rejected body read but does not relabel event-application bugs", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic read failure")); } })) : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
streams = 0;
|
||||
const broken = model({ fetch: dietFetch({ stream: () => { streams++; return completed(); } }) });
|
||||
broken.applyWireEvent = () => { throw new Error("synthetic event application bug"); };
|
||||
await broken.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 1);
|
||||
assert.equal(broken.getState().error?.message, "synthetic event application bug");
|
||||
});
|
||||
|
||||
it("recovers an AbortError body rejection unless the view was actually cancelled", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
|
||||
start(controller) { controller.error(new DOMException("synthetic unexpected read abort", "AbortError")); },
|
||||
})) : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("uses the approved drop message when recovery record headers arrive but its body read fails", async () => {
|
||||
let reads = 0;
|
||||
const m = model({ fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
if (String(url).endsWith("/stream")) return truncated();
|
||||
if (++reads === 1) return Response.json({ messages: [] });
|
||||
return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic record-body transport failure")); } }));
|
||||
} });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("preserves malformed-event diagnostics rather than calling them network drops", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => { streams++; return new Response("data: {broken}\n\n"); } }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 1);
|
||||
assert.equal(m.status, "error");
|
||||
assert.notEqual(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
assert.match(m.getState().error!.message, /JSON|property name/);
|
||||
});
|
||||
|
||||
it("reports a failed recovery stream request without attempting a third attachment", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : new Response("synthetic unavailable", { status: 503 }) }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("a superseded consume cannot recover or overwrite its replacement's state", async () => {
|
||||
let streams = 0;
|
||||
let closeFirst!: () => void;
|
||||
let beganReading!: () => void;
|
||||
const reading = new Promise<void>(resolve => { beganReading = resolve; });
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
|
||||
start(controller) { closeFirst = () => controller.close(); },
|
||||
pull() { beganReading(); },
|
||||
}, { highWaterMark: 0 })) : completed() }) });
|
||||
const first = m.resumeStream(false);
|
||||
await reading;
|
||||
await m.resumeStream(false);
|
||||
closeFirst();
|
||||
await first;
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automatic continuation after recovery failure", () => {
|
||||
const question = { ...assistantMsg("question", ""), content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }] } as LhMessage;
|
||||
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true });
|
||||
const answered = [question, newAnswerResult("q1", "quiz", answer)];
|
||||
|
||||
it("a retained failure seeds the banner and blocks continuation even after banner dismissal", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, initialError: "The reply could not be saved.", fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
assert.equal(SessionModel.needsContinuation(answered), true);
|
||||
assert.equal(m.getState().error?.message, "The reply could not be saved.");
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0);
|
||||
m.clearError();
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0, "closing the banner is not permission to retry the model");
|
||||
await m.send({ type: "text", text: "explicit learner input" });
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("a new server-owned turn clears the retained banner without releasing Thinking", () => {
|
||||
const m = model({ initialMessages: answered, initialError: "The reply could not be saved." });
|
||||
assert.equal(m.getState().error?.message, "The reply could not be saved.");
|
||||
assert.equal(m.applyWireEvent({ type: "agent_start" }), false);
|
||||
assert.equal(m.getState().error, null, "positive new-turn evidence replaces the old transient notice");
|
||||
});
|
||||
|
||||
it("a live failed save cannot auto-continue the accepted answer during canonical sync", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
m.applyWireEvent({ type: "error", errorText: "The reply could not be saved." });
|
||||
m.setMessages(answered);
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0);
|
||||
});
|
||||
|
||||
it("a healthy owed continuation still starts once through the normal send gate", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── failed answer submissions (no replay without canonical reconciliation) ──
|
||||
|
||||
describe("answer submission recovery", () => {
|
||||
const question = {
|
||||
...assistantMsg("question", "Q?"),
|
||||
content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }],
|
||||
} as LhMessage;
|
||||
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true, thinking: "keep my reasoning" });
|
||||
|
||||
it("restores the question after a failed POST and checks the record before another POST", async () => {
|
||||
const calls: string[] = [];
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url) === "/api/chat") {
|
||||
posts++;
|
||||
return posts === 1 ? new Response("request refused", { status: 500 }) : Response.json({ ok: true });
|
||||
}
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [question], turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.getState().messages[0]!.parts.find(p => p.toolCallId === "q1")!.state, "input-available");
|
||||
assert.equal(m.status, "error");
|
||||
m.clearError(); // dismissing the banner must not disable recovery
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 2, "error must not permanently close the send gate");
|
||||
assert.deepEqual(calls.slice(0, 3), ["/api/chat", "/api/chats/chat_x", "/api/chat"]);
|
||||
});
|
||||
|
||||
it("retains all submitted draft channels until the canonical answer resolves the call", async () => {
|
||||
const form = { selection: { selected: new Set([1]), otherSelected: true, dontKnow: false }, thinking: "reasoning", other: "custom answer", text: "free text", confidenceIndex: 4 };
|
||||
const m = model({ initialMessages: [question], fetch: async () => { throw new TypeError("offline"); } });
|
||||
m.saveQuestionDraft("q1", { form });
|
||||
m.saveQuestionDraft("q1", { readingThinking: "reading reasoning" });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
|
||||
m.setMessages([question]);
|
||||
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
|
||||
assert.equal(m.questionDraft("q2"), undefined, "a different question never inherits the draft");
|
||||
m.adoptRecord([question, newAnswerResult("q1", "quiz", answer)]);
|
||||
assert.equal(m.questionDraft("q1"), undefined, "only confirmed native resolution clears it");
|
||||
});
|
||||
|
||||
it("a final 409 restores the question instead of leaving an optimistic answer", async () => {
|
||||
const m = model({ initialMessages: [question], waitMaxMs: 0, fetch: async () => new Response("busy", { status: 409 }) });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.getState().nativeMessages.length, 1);
|
||||
assert.equal(m.status, "error");
|
||||
});
|
||||
|
||||
it("does not repost an answer whose acceptance acknowledgement was lost", async () => {
|
||||
let posts = 0;
|
||||
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
|
||||
return Response.json({ messages: saved, turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.deepEqual(m.getState().nativeMessages, saved);
|
||||
});
|
||||
|
||||
it("reattaches to an accepted running turn instead of posting another answer", async () => {
|
||||
const calls: string[] = [];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url) === "/api/chat") throw new TypeError("failed fetch");
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [question, newAnswerResult("q1", "quiz", answer)], turnInFlight: true });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x", "/api/chats/chat_x/stream"]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
|
||||
it("keeps the question and makes no further POST while reconciliation is unreachable", async () => {
|
||||
const calls: string[] = [];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => { calls.push(String(url)); throw new TypeError("offline"); } });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x"]);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
|
||||
});
|
||||
|
||||
it("does not undo an acknowledged dismissal when an unrelated send's record read fails", async () => {
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url).endsWith("/dismiss") || String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
throw new TypeError("record read failed");
|
||||
} });
|
||||
await m.dismissQuestion("q1", dismissedAnswer("quiz", "Q?"));
|
||||
await m.send({ type: "text", text: "move on" });
|
||||
assert.equal(m.status, "error");
|
||||
const part = m.getState().messages.flatMap(m => m.parts).find(p => p.toolCallId === "q1")!;
|
||||
assert.equal(part.state, "output-available");
|
||||
assert.equal((part.output as { dismissed: boolean }).dismissed, true);
|
||||
});
|
||||
|
||||
it("restores an unconfirmed optimistic answer when the post-ack record read fails", async () => {
|
||||
let posts = 0;
|
||||
let reads = 0;
|
||||
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
|
||||
if (++reads === 1) throw new TypeError("record read failed");
|
||||
return Response.json({ messages: saved, turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1, "the acknowledged answer must not be reposted");
|
||||
assert.equal(m.status, "ready");
|
||||
assert.deepEqual(m.getState().nativeMessages, saved);
|
||||
});
|
||||
|
||||
for (const next of [{ type: "text", text: "a distinct new message" }, { type: "thinking", problemCallId: "problem1", text: "new practice thinking" }] as const) {
|
||||
it(`preserves the existing POST/409-wait path for a new ${next.type} submission after an error`, async () => {
|
||||
const posted: ChatInput[] = [];
|
||||
const m = model({ pollMs: 0, fetch: async (url, init) => {
|
||||
if (String(url) === "/api/chat") {
|
||||
posted.push(JSON.parse(String(init?.body)).input);
|
||||
if (posted.length === 1) throw new TypeError("failed fetch");
|
||||
if (posted.length === 2) return new Response("busy", { status: 409 });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
if (String(url).startsWith("/api/jobs")) return Response.json({ turnInFlight: false });
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [], turnInFlight: true });
|
||||
} });
|
||||
await m.send({ type: "text", text: "original input" });
|
||||
await m.send(next);
|
||||
assert.deepEqual(posted, [{ type: "text", text: "original input" }, next, next]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
}
|
||||
|
||||
it("adopts a changed pending question instead of sending the stale answer", async () => {
|
||||
let posts = 0;
|
||||
const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage;
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
|
||||
return Response.json({ messages: [question, newer], turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1);
|
||||
assert.deepEqual(m.getState().nativeMessages, [question, newer]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||
|
||||
describe("resumeStream (the reattach path)", () => {
|
||||
|
||||
@@ -11,8 +11,19 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { subscribe, type ServerEvent } from "../src/core/events.ts";
|
||||
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
|
||||
describe("turn liveness registry", () => {
|
||||
it("retains a late-attach failure notice until the next accepted begin", async () => {
|
||||
const id = "chat_failure_notice_unit";
|
||||
rememberTurnFailure(id, "The reply could not be saved.");
|
||||
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./);
|
||||
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice");
|
||||
assert.equal(beginTurn(id), true);
|
||||
assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/);
|
||||
endTurn(id);
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ChatStore } from "../src/core/ports.ts";
|
||||
import { runPiTurn } from "../src/server/pi/ui-stream.ts";
|
||||
import { persistTurn } from "../src/server/turn-service.ts";
|
||||
|
||||
/** No engine or network: exercise the runner's awaited listener seam. */
|
||||
function agentEndingWith(loopFailure?: Error): Agent {
|
||||
const listeners: Array<(event: never) => unknown> = [];
|
||||
return {
|
||||
state: { messages: [] },
|
||||
subscribe(listener: (event: never) => unknown) { listeners.push(listener); return () => {}; },
|
||||
async continue() {
|
||||
await Promise.resolve(); // let the view subscribe before the terminal
|
||||
if (loopFailure) throw loopFailure;
|
||||
for (const listener of listeners) await listener({ type: "agent_end", messages: [] } as never);
|
||||
},
|
||||
abort() {},
|
||||
} as unknown as Agent;
|
||||
}
|
||||
|
||||
function wireFrames(wire: string): string[] {
|
||||
return wire.split("\n\n").filter(Boolean).map(frame => frame.slice("data: ".length));
|
||||
}
|
||||
|
||||
describe("turn settlement recovery", () => {
|
||||
it("reports a failed canonical save before DONE and cleans up before reporting", async () => {
|
||||
const order: string[] = [];
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(),
|
||||
onFinish() { order.push("save"); throw new Error("synthetic disk failure"); },
|
||||
onSettleFailure() { order.push("cleanup"); },
|
||||
onSettleError() { order.push("report"); return "The reply could not be saved."; },
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.deepEqual(order, ["save", "cleanup", "report"]);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
assert.ok(frames.at(-2)?.includes("The reply could not be saved."));
|
||||
});
|
||||
|
||||
it("still reports settlement failure when cleanup itself throws", async () => {
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(),
|
||||
onFinish() { throw new Error("synthetic save failure"); },
|
||||
onSettleFailure() { throw new Error("synthetic cleanup failure"); },
|
||||
onSettleError: () => "The reply could not be saved.",
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
});
|
||||
|
||||
it("does not replace an already reported loop failure with a second settlement error", async () => {
|
||||
let cleanups = 0;
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(new Error("synthetic loop failure")),
|
||||
onError: () => "An error occurred.",
|
||||
onFinish() { throw new Error("synthetic save failure"); },
|
||||
onSettleFailure(error, reportedErrorText) {
|
||||
cleanups++;
|
||||
assert.match(String(error), /synthetic save failure/);
|
||||
assert.equal(reportedErrorText, "An error occurred.");
|
||||
},
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(cleanups, 1);
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
});
|
||||
|
||||
it("a healthy terminal follows persistence and emits no error", async () => {
|
||||
let saves = 0;
|
||||
const handle = runPiTurn({ agent: agentEndingWith(), onFinish() { saves++; } });
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.some(f => f.includes('"type":"error"')), false);
|
||||
});
|
||||
|
||||
it("does not contain a canonical write failure as an empty successful persist", () => {
|
||||
const store = {
|
||||
getChat: () => ({ messages: [] }),
|
||||
saveMessages() { throw new Error("synthetic disk failure"); },
|
||||
} as unknown as ChatStore;
|
||||
const message = { role: "assistant", content: [{ type: "text", text: "Synthetic reply" }], stopReason: "stop", timestamp: 1 } as AgentMessage;
|
||||
assert.throws(() => persistTurn(store, "fixture", 0, [message], false), /The reply could not be saved\./);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user