Merge bounded session answer and stream recovery
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 }> }) {
|
export async function GET(_req: Request, { params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
@@ -19,6 +19,8 @@ export async function GET(_req: Request, { params }: { params: Promise<{ id: str
|
|||||||
messages: chat.messages,
|
messages: chat.messages,
|
||||||
pricing: sessionCost(chat),
|
pricing: sessionCost(chat),
|
||||||
turnInFlight: isTurnRunning(id),
|
turnInFlight: isTurnRunning(id),
|
||||||
|
// Also reaches idle views that missed the failing turn's stream.
|
||||||
|
turnError: turnFailure(id),
|
||||||
compacting: compactionInFlight(id),
|
compacting: compactionInFlight(id),
|
||||||
coverage: sessionCoverage(id),
|
coverage: sessionCoverage(id),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
* submitter too attaches here. One SSE stream per chat: a running turn →
|
* 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
|
* subscribe to the live turn object: FULL replay of the event log, then
|
||||||
* live chunks until settle ([DONE]). No running turn → the cheap terminal
|
* live chunks until settle ([DONE]). No running turn → the cheap terminal
|
||||||
* ({"type":"turn-settled"} + [DONE]): the record is current, the client
|
* ({"type":"turn-settled"} + [DONE]): the client refetches the record.
|
||||||
* refetches it.
|
* 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
|
* 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
|
* 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;
|
const { id } = await ctx.params;
|
||||||
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
if (!getChat(id)) return Response.json({ error: "Unknown chat" }, { status: 404 });
|
||||||
const handle = runningTurn(id);
|
const handle = runningTurn(id);
|
||||||
if (!handle) return settledTurnResponse();
|
if (!handle) return settledTurnResponse(id);
|
||||||
return piTurnResponse(handle);
|
return piTurnResponse(handle);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { Chat } from "@/components/chat";
|
import { Chat } from "@/components/chat";
|
||||||
import { slotsConfigured } from "@/src/core";
|
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";
|
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
|
// derived app-side (core/view.ts projectView; GET serves the same
|
||||||
// native messages).
|
// native messages).
|
||||||
initialMessages={chat.messages}
|
initialMessages={chat.messages}
|
||||||
|
initialError={turnFailure(chat.id)}
|
||||||
initialConfigured={slotsConfigured(record?.models)}
|
initialConfigured={slotsConfigured(record?.models)}
|
||||||
initialPrompt={prompt}
|
initialPrompt={prompt}
|
||||||
// A teacher turn that outlived its client: the remounted view must
|
// A teacher turn that outlived its client: the remounted view must
|
||||||
|
|||||||
+32
-20
@@ -38,7 +38,6 @@ import {
|
|||||||
messageAnchorId,
|
messageAnchorId,
|
||||||
textPartMessageId,
|
textPartMessageId,
|
||||||
kbdEventForAction as kbdEventForChat,
|
kbdEventForAction as kbdEventForChat,
|
||||||
needsContinuation,
|
|
||||||
type ChatInput,
|
type ChatInput,
|
||||||
type ChatSettings,
|
type ChatSettings,
|
||||||
type LhMessage,
|
type LhMessage,
|
||||||
@@ -57,7 +56,7 @@ import { PracticeDock } from "@/components/practice-dock";
|
|||||||
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
import { ThinkingCard, type PracticeThinkingData } from "@/components/practice-cards";
|
||||||
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
import { GraphContextCard, type GraphContextData } from "@/components/graph-context-card";
|
||||||
import { TopBar } from "@/components/top-bar";
|
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 type { LhViewMessage } from "@/src/core";
|
||||||
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
import { PhaseChip, PhaseDivider } from "@/components/phase-divider";
|
||||||
import {
|
import {
|
||||||
@@ -198,6 +197,7 @@ export function Chat({
|
|||||||
chatId,
|
chatId,
|
||||||
title,
|
title,
|
||||||
initialMessages,
|
initialMessages,
|
||||||
|
initialError,
|
||||||
initialPrompt,
|
initialPrompt,
|
||||||
initiallyInFlight,
|
initiallyInFlight,
|
||||||
initiallyCompacting = false,
|
initiallyCompacting = false,
|
||||||
@@ -207,6 +207,8 @@ export function Chat({
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
title: string;
|
title: string;
|
||||||
initialMessages: LhMessage[];
|
initialMessages: LhMessage[];
|
||||||
|
/** Memory-only settlement notice supplied on reopening. */
|
||||||
|
initialError?: string;
|
||||||
/** The session kind (practice sessions, owner 2026-10-04): practice
|
/** The session kind (practice sessions, owner 2026-10-04): practice
|
||||||
* renders no phase chip (no probe→teach arc applies) and a Practice
|
* renders no phase chip (no probe→teach arc applies) and a Practice
|
||||||
* tag rides the header instead. */
|
* tag rides the header instead. */
|
||||||
@@ -236,8 +238,8 @@ export function Chat({
|
|||||||
// (core/view.ts projectView); sends are ONE explicit input event each
|
// (core/view.ts projectView); sends are ONE explicit input event each
|
||||||
// ({chatId, input} — the stash indirection died with the SDK), and the
|
// ({chatId, input} — the stash indirection died with the SDK), and the
|
||||||
// turn stream is the VERBATIM AgentEvent passthrough the server emits.
|
// 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 } =
|
const { messages, nativeMessages, contextTokens, send, continueIfOwed, steer, steerQueue, submitAnswer, dismissQuestion, dismissProblem, questionDraft, saveQuestionDraft, setMessages, adoptRecord, status, error, stop, clearError, resumeStream, setReattachSettled } =
|
||||||
useChatSession({ chatId, initialMessages, initiallyInFlight });
|
useChatSession({ chatId, initialMessages, initialError, initiallyInFlight });
|
||||||
|
|
||||||
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
const sessionFindScopeRef = useRef<HTMLDivElement | null>(null);
|
||||||
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
const scrollContextRef = useRef<StickToBottomContext | null>(null);
|
||||||
@@ -434,9 +436,7 @@ export function Chat({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (mountContinuation.current) return;
|
if (mountContinuation.current) return;
|
||||||
mountContinuation.current = true;
|
mountContinuation.current = true;
|
||||||
if (!turnInFlight && status === "ready" && needsContinuation(initialMessages)) {
|
if (!turnInFlight) continueIfOwed(initialMessages);
|
||||||
void send({ type: "continue" });
|
|
||||||
}
|
|
||||||
// mount-only by design (turnInFlight read at initial value)
|
// mount-only by design (turnInFlight read at initial value)
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -532,10 +532,12 @@ export function Chat({
|
|||||||
}, []);
|
}, []);
|
||||||
const pendingQuestionId = pending?.toolCallId ?? null;
|
const pendingQuestionId = pending?.toolCallId ?? null;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// A new pending question never inherits the previous one's draft.
|
// Different calls never share a draft; a failed submission of THIS
|
||||||
readingThinkingRef.current = "";
|
// call restores the reading channel alongside the form's snapshot.
|
||||||
setReadingThinking("");
|
const reading = pendingQuestionId ? questionDraft(pendingQuestionId)?.readingThinking ?? "" : "";
|
||||||
}, [pendingQuestionId]);
|
readingThinkingRef.current = reading;
|
||||||
|
setReadingThinking(reading);
|
||||||
|
}, [pendingQuestionId, questionDraft]);
|
||||||
|
|
||||||
// The phase at every message (one linear fold): the transcript draws a
|
// The phase at every message (one linear fold): the transcript draws a
|
||||||
// color-coded dotted divider at each boundary; the header chip reads the
|
// color-coded dotted divider at each boundary; the header chip reads the
|
||||||
@@ -717,20 +719,20 @@ export function Chat({
|
|||||||
const syncFromServer = useCallback(async () => {
|
const syncFromServer = useCallback(async () => {
|
||||||
const res = await fetch(`/api/chats/${chatId}`);
|
const res = await fetch(`/api/chats/${chatId}`);
|
||||||
if (!res.ok) return;
|
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);
|
if (Array.isArray(record.turns)) setTurns(record.turns);
|
||||||
setPricing(record.pricing);
|
setPricing(record.pricing);
|
||||||
if (!Array.isArray(record.messages)) return;
|
if (!Array.isArray(record.messages)) return;
|
||||||
setMessages(record.messages);
|
setMessages(record.messages, record.turnError);
|
||||||
// The continuation seam (contracts.ts protocol §4): a job result
|
// The continuation seam (contracts.ts protocol §4): a job result
|
||||||
// landing in the record leaves the agent owing a continuation — the
|
// landing in the record leaves the agent owing a continuation — the
|
||||||
// record ENDS on un-consumed results, the dock owns no question, and
|
// record ENDS on un-consumed results, the dock owns no question, and
|
||||||
// nothing runs. The native fold (needsContinuation) is the old SDK
|
// nothing runs. The native fold (needsContinuation) is the old SDK
|
||||||
// lastAssistantMessageIsCompleteWithToolCalls check.
|
// lastAssistantMessageIsCompleteWithToolCalls check.
|
||||||
if (status === "ready" && needsContinuation(record.messages)) {
|
// Current model truth also guards retained/live failures: canonical
|
||||||
void send({ type: "continue" });
|
// sync must never automatically replace a reply that failed to save.
|
||||||
}
|
continueIfOwed(record.messages);
|
||||||
}, [chatId, setMessages, send, status]);
|
}, [chatId, setMessages, continueIfOwed]);
|
||||||
|
|
||||||
// The settled terminal's other half (stream-reattach wave): the
|
// The settled terminal's other half (stream-reattach wave): the
|
||||||
// transport resolved turn-settled — no turn running, the record is
|
// 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
|
// (endTurn emits from onFinish before the response terminates, so the
|
||||||
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
// SSE frame reliably beats [DONE]) — the handler below cannot sync
|
||||||
// there, it flags instead, and THIS flip consumes the flag: the record
|
// there, it flags instead, and THIS flip consumes the flag: the record
|
||||||
// is guaranteed durable (endTurn fires only after persistTurn, the S2
|
// reflects durable truth (a failed settlement retains its notice,
|
||||||
// ordering law), so replacing the view onto it absorbs the tail's
|
// not its unsaved reply), so replacing the view absorbs the tail's
|
||||||
// streamed c_-id copies into base under their server ids. Without
|
// streamed c_-id copies into base under their server ids. Without
|
||||||
// this the tail kept those copies forever and the next send's
|
// this the tail kept those copies forever and the next send's
|
||||||
// steer-adoption window rendered them a second time. The wake handoff
|
// steer-adoption window rendered them a second time. The wake handoff
|
||||||
@@ -1596,6 +1598,8 @@ export function Chat({
|
|||||||
fold) and NEVER overlaps the composer. */}
|
fold) and NEVER overlaps the composer. */}
|
||||||
<DockOverlay
|
<DockOverlay
|
||||||
pending={pending}
|
pending={pending}
|
||||||
|
initialQuestionDraft={pending ? questionDraft(pending.toolCallId)?.form : undefined}
|
||||||
|
onSaveQuestionDraft={(form) => { if (pending) saveQuestionDraft(pending.toolCallId, { form }); }}
|
||||||
practice={activeProblem}
|
practice={activeProblem}
|
||||||
compactionRunning={compacting}
|
compactionRunning={compacting}
|
||||||
onSubmitThinking={submitThinking}
|
onSubmitThinking={submitThinking}
|
||||||
@@ -1663,8 +1667,10 @@ export function Chat({
|
|||||||
// ride the same callback (QuestionAnswerData.dismissed). The
|
// ride the same callback (QuestionAnswerData.dismissed). The
|
||||||
// reading-thinking draft rides along (owner 2026-10-03) — the
|
// reading-thinking draft rides along (owner 2026-10-03) — the
|
||||||
// check answer is its only carrier.
|
// check answer is its only carrier.
|
||||||
|
saveQuestionDraft(pending.toolCallId, { readingThinking: readingThinkingRef.current });
|
||||||
const reading = readingThinkingRef.current.trim() || undefined;
|
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);
|
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({
|
function DockOverlay({
|
||||||
pending,
|
pending,
|
||||||
|
initialQuestionDraft,
|
||||||
|
onSaveQuestionDraft,
|
||||||
practice,
|
practice,
|
||||||
compactionRunning,
|
compactionRunning,
|
||||||
onSubmitThinking,
|
onSubmitThinking,
|
||||||
@@ -1843,6 +1851,8 @@ function DockOverlay({
|
|||||||
queuedSteers,
|
queuedSteers,
|
||||||
}: {
|
}: {
|
||||||
pending: ReturnType<typeof findPendingToolCall>;
|
pending: ReturnType<typeof findPendingToolCall>;
|
||||||
|
initialQuestionDraft?: QuestionFormDraft;
|
||||||
|
onSaveQuestionDraft: (draft: QuestionFormDraft) => void;
|
||||||
/** Compaction running for this chat (round 14) — the docks' submit
|
/** Compaction running for this chat (round 14) — the docks' submit
|
||||||
* affordance gate (the server's registry is the real one). */
|
* affordance gate (the server's registry is the real one). */
|
||||||
compactionRunning: boolean;
|
compactionRunning: boolean;
|
||||||
@@ -2309,6 +2319,8 @@ function DockOverlay({
|
|||||||
phase={phase}
|
phase={phase}
|
||||||
onExpand={expandAndReveal}
|
onExpand={expandAndReveal}
|
||||||
onSubmit={onSubmitAnswer}
|
onSubmit={onSubmitAnswer}
|
||||||
|
initialDraft={initialQuestionDraft}
|
||||||
|
onSaveDraft={onSaveQuestionDraft}
|
||||||
onEscape={dictationCancel}
|
onEscape={dictationCancel}
|
||||||
dictationClaim={!revealPending}
|
dictationClaim={!revealPending}
|
||||||
compactionRunning={compactionRunning}
|
compactionRunning={compactionRunning}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ import {
|
|||||||
type DockSelection,
|
type DockSelection,
|
||||||
type ExplainToolInput,
|
type ExplainToolInput,
|
||||||
type QuestionAnswerData,
|
type QuestionAnswerData,
|
||||||
|
type QuestionFormDraft,
|
||||||
type QuestionData,
|
type QuestionData,
|
||||||
type QuestionMode,
|
type QuestionMode,
|
||||||
type QuizToolInput,
|
type QuizToolInput,
|
||||||
@@ -71,6 +72,9 @@ export interface QuestionDockProps {
|
|||||||
reveal?: boolean;
|
reveal?: boolean;
|
||||||
onExpand: () => void;
|
onExpand: () => void;
|
||||||
onSubmit: (output: QuestionAnswerData) => 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
|
/** Escape in a question field stops dictation (STT wave) — threaded
|
||||||
* from the page that owns the dictation hook. */
|
* from the page that owns the dictation hook. */
|
||||||
onEscape?: () => void;
|
onEscape?: () => void;
|
||||||
@@ -100,6 +104,8 @@ export function QuestionDock({
|
|||||||
reveal = false,
|
reveal = false,
|
||||||
onExpand,
|
onExpand,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
initialDraft,
|
||||||
|
onSaveDraft,
|
||||||
onEscape,
|
onEscape,
|
||||||
phase = "probe",
|
phase = "probe",
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
@@ -155,6 +161,8 @@ export function QuestionDock({
|
|||||||
phase={phase}
|
phase={phase}
|
||||||
active={expanded}
|
active={expanded}
|
||||||
onSubmit={onSubmit}
|
onSubmit={onSubmit}
|
||||||
|
initialDraft={initialDraft}
|
||||||
|
onSaveDraft={onSaveDraft}
|
||||||
onEscape={onEscape}
|
onEscape={onEscape}
|
||||||
dictationClaim={dictationClaim}
|
dictationClaim={dictationClaim}
|
||||||
compactionRunning={compactionRunning}
|
compactionRunning={compactionRunning}
|
||||||
@@ -250,6 +258,8 @@ function QuestionDockForm({
|
|||||||
phase = "probe",
|
phase = "probe",
|
||||||
active,
|
active,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
|
initialDraft,
|
||||||
|
onSaveDraft,
|
||||||
onEscape,
|
onEscape,
|
||||||
dictationClaim = true,
|
dictationClaim = true,
|
||||||
compactionRunning = false,
|
compactionRunning = false,
|
||||||
@@ -311,22 +321,22 @@ function QuestionDockForm({
|
|||||||
// single-select displaces, multi-select coexists — picking the ask
|
// single-select displaces, multi-select coexists — picking the ask
|
||||||
// "Other…" row in a multi-select must never clear already-picked
|
// "Other…" row in a multi-select must never clear already-picked
|
||||||
// options, and picking an option must never clear a typed custom answer.
|
// 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".
|
// The learner's transfer channel — thinking, never "a note".
|
||||||
const [thinking, setThinking] = useState("");
|
const [thinking, setThinking] = useState(initialDraft?.thinking ?? "");
|
||||||
const [other, setOther] = useState("");
|
const [other, setOther] = useState(initialDraft?.other ?? "");
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState(initialDraft?.text ?? "");
|
||||||
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
/* Refs MIRROR the two dictation-target fields ("nothing gets lost"
|
||||||
* round, owner 2026-10-01): the run's flush lands via setState, which
|
* round, owner 2026-10-01): the run's flush lands via setState, which
|
||||||
* a closure waiting through `await finishDictation()` cannot observe
|
* a closure waiting through `await finishDictation()` cannot observe
|
||||||
* in time — submit reads these, kept current at every write site. */
|
* in time — submit reads these, kept current at every write site. */
|
||||||
const thinkingTextRef = useRef("");
|
const thinkingTextRef = useRef(initialDraft?.thinking ?? "");
|
||||||
const answerTextRef = useRef("");
|
const answerTextRef = useRef(initialDraft?.text ?? "");
|
||||||
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
// Confidence (owner, 2026-10-01): an index into CONFIDENCE_STOPS,
|
||||||
// resting at 50 — a real coin flip by definition, untouched or
|
// resting at 50 — a real coin flip by definition, untouched or
|
||||||
// deliberate (the same number, the same meaning). The dock unmounts
|
// deliberate (the same number, the same meaning). The dock unmounts
|
||||||
// between questions, so each question starts fresh at 50.
|
// 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";
|
const selectMode: SelectMode = qd.mode === "multi-select" ? "multi-select" : "single-select";
|
||||||
|
|
||||||
// The PRIMARY dictation input (owner, STT round 1): while a question
|
// 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".
|
// "untouched"); buildAnswer drops it on "I don't know".
|
||||||
confidence: kind === "quiz" ? CONFIDENCE_STOPS[confIdx] : undefined,
|
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));
|
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 type { ToolResultMessage } from "@earendil-works/pi-ai";
|
||||||
import {
|
import {
|
||||||
needsContinuation,
|
needsContinuation,
|
||||||
|
danglingQuestionOf,
|
||||||
|
answeredCallIds,
|
||||||
measuredContextTokens,
|
measuredContextTokens,
|
||||||
newAnswerResult,
|
newAnswerResult,
|
||||||
newNextMessage,
|
newNextMessage,
|
||||||
@@ -30,6 +32,7 @@ import {
|
|||||||
type LhViewMessage,
|
type LhViewMessage,
|
||||||
type LiveExecution,
|
type LiveExecution,
|
||||||
type QuestionAnswerData,
|
type QuestionAnswerData,
|
||||||
|
type QuestionDraft,
|
||||||
} from "../src/core";
|
} from "../src/core";
|
||||||
|
|
||||||
export type SessionStatus = "ready" | "submitted" | "streaming" | "error";
|
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. */
|
const STREAM_DROPPED = "The connection dropped before the turn completed.";
|
||||||
async function* sseEvents(reader: ReadableStreamDefaultReader<Uint8Array>): AsyncGenerator<Record<string, unknown>> {
|
|
||||||
|
/** 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();
|
const decoder = new TextDecoder();
|
||||||
let buf = "";
|
let buf = "";
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const { done, value } = await reader.read();
|
let chunk: ReadableStreamReadResult<Uint8Array>;
|
||||||
if (done) return;
|
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 });
|
buf += decoder.decode(value, { stream: true });
|
||||||
for (;;) {
|
for (;;) {
|
||||||
const end = buf.indexOf("\n\n");
|
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: "));
|
const dataLine = event.split("\n").find((l) => l.startsWith("data: "));
|
||||||
if (!dataLine) continue;
|
if (!dataLine) continue;
|
||||||
const data = dataLine.slice("data: ".length);
|
const data = dataLine.slice("data: ".length);
|
||||||
if (data === "[DONE]") return;
|
if (data === "[DONE]") return true;
|
||||||
yield JSON.parse(data) as Record<string, unknown>;
|
yield JSON.parse(data) as Record<string, unknown>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -196,6 +211,8 @@ export function visibleQueuedSteers(
|
|||||||
export interface SessionModelOptions extends SessionModelDeps {
|
export interface SessionModelOptions extends SessionModelDeps {
|
||||||
chatId: string;
|
chatId: string;
|
||||||
initialMessages: LhMessage[];
|
initialMessages: LhMessage[];
|
||||||
|
/** Retained, memory-only server failure when reopening a session. */
|
||||||
|
initialError?: string;
|
||||||
/** Fires on every state change (the hook re-renders). */
|
/** Fires on every state change (the hook re-renders). */
|
||||||
onChange?: () => void;
|
onChange?: () => void;
|
||||||
/** The reattach settled terminal (turn-settled): no turn running, the
|
/** 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.
|
// at every full sync; the write-ahead absorbs the optimistic entries.
|
||||||
private base: LhMessage[];
|
private base: LhMessage[];
|
||||||
private tail: 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):
|
// The live turn's execution layer (view model mid-turn states):
|
||||||
private executions = new Map<string, LiveExecution>();
|
private executions = new Map<string, LiveExecution>();
|
||||||
private streamingIds = new Set<string>();
|
private streamingIds = new Set<string>();
|
||||||
@@ -229,6 +249,8 @@ export class SessionModel {
|
|||||||
private statusValue: SessionStatus = "ready";
|
private statusValue: SessionStatus = "ready";
|
||||||
/** Request/loop failures are transient; provider failures derive from messages. */
|
/** Request/loop failures are transient; provider failures derive from messages. */
|
||||||
private errorValue: { message: string } | null = null;
|
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. */
|
/** A completed failure from THIS consume, not the previous attempt's record. */
|
||||||
private liveFailureId: string | null = null;
|
private liveFailureId: string | null = null;
|
||||||
// The single live consume (POST response or reattach stream): a newer
|
// The single live consume (POST response or reattach stream): a newer
|
||||||
@@ -240,6 +262,8 @@ export class SessionModel {
|
|||||||
constructor(opts: SessionModelOptions) {
|
constructor(opts: SessionModelOptions) {
|
||||||
this.chatId = opts.chatId;
|
this.chatId = opts.chatId;
|
||||||
this.base = opts.initialMessages;
|
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
|
// BOUND: the browser's native fetch is a Window method — storing it
|
||||||
// unbound makes every call throw "Illegal invocation" in the browser
|
// unbound makes every call throw "Illegal invocation" in the browser
|
||||||
// (Node's undici doesn't care, so the tests never saw it). Always go
|
// (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 {
|
private setError(message: string | null): void {
|
||||||
this.errorValue = message === null ? null : { message };
|
this.errorValue = message === null ? null : { message };
|
||||||
|
if (message !== null) this.automaticContinuationBlocked = true;
|
||||||
this.onChange?.();
|
this.onChange?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -346,6 +371,12 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
const agent = event as unknown as AgentEvent;
|
const agent = event as unknown as AgentEvent;
|
||||||
switch (agent.type) {
|
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_start":
|
||||||
case "message_update":
|
case "message_update":
|
||||||
case "message_end": {
|
case "message_end": {
|
||||||
@@ -445,18 +476,28 @@ export class SessionModel {
|
|||||||
|
|
||||||
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
/** Consume one SSE body to its [DONE] terminal. The turn-settled
|
||||||
* terminal (stream-route only) fires the settle hook and ends. */
|
* 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.");
|
if (!res.body) throw new Error("The turn stream body is empty.");
|
||||||
const reader = res.body.getReader();
|
const reader = res.body.getReader();
|
||||||
let sawEvent = false;
|
let sawEvent = false;
|
||||||
|
let sawDone = false;
|
||||||
try {
|
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 (this.consumeAbort !== controller) return;
|
||||||
|
if (next.done) { sawDone = next.value; break; }
|
||||||
|
const event = next.value;
|
||||||
if (event.type === "turn-settled") {
|
if (event.type === "turn-settled") {
|
||||||
// The raced-settle terminal: no turn to attach to — the
|
// The raced-settle terminal: no turn to attach to — the
|
||||||
// record is current; the settle hook refetches.
|
// record is current; the settle hook refetches.
|
||||||
await reader.cancel().catch(() => {});
|
sawDone = true;
|
||||||
if (this.consumeAbort === controller) this.onReattachSettled?.();
|
this.onReattachSettled?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const acted = this.applyWireEvent(event);
|
const acted = this.applyWireEvent(event);
|
||||||
@@ -466,14 +507,32 @@ export class SessionModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
await reader.cancel().catch(() => {});
|
||||||
|
reader.releaseLock();
|
||||||
// A cancelled consume may finish after its replacement has entered
|
// A cancelled consume may finish after its replacement has entered
|
||||||
// Thinking. Only the current owner can settle shared state.
|
// Thinking. Only the current owner can settle shared state.
|
||||||
if (this.consumeAbort === controller) {
|
if (this.consumeAbort === controller) {
|
||||||
this.streamingIds = new Set();
|
this.streamingIds = new Set();
|
||||||
this.executions = new Map();
|
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
|
* lands on top. The stream then replays the turn from its first event
|
||||||
* through the SAME consume path.
|
* through the SAME consume path.
|
||||||
*/
|
*/
|
||||||
async resumeStream(refetchRecordFirst = true): Promise<void> {
|
async resumeStream(refetchRecordFirst = true, allowRecovery = true): Promise<void> {
|
||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
@@ -519,16 +578,27 @@ export class SessionModel {
|
|||||||
this.setStatus("submitted");
|
this.setStatus("submitted");
|
||||||
try {
|
try {
|
||||||
if (refetchRecordFirst) {
|
if (refetchRecordFirst) {
|
||||||
const recordRes = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}`, {
|
const recordRes = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}`, controller.signal, !allowRecovery);
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
if (recordRes.ok) {
|
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 (this.consumeAbort !== controller) return;
|
||||||
if (Array.isArray(record.messages)) {
|
if (Array.isArray(record.messages)) {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record.messages!;
|
this.base = record.messages!;
|
||||||
|
if (record.turnError !== undefined) {
|
||||||
|
this.errorValue = { message: record.turnError };
|
||||||
|
this.automaticContinuationBlocked = true;
|
||||||
|
}
|
||||||
|
this.forgetResolvedDrafts(record.messages!);
|
||||||
this.incompleteUsageIds.clear();
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
});
|
});
|
||||||
@@ -543,15 +613,19 @@ export class SessionModel {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const res = await this.fetch(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, {
|
const res = await this.fetchView(`/api/chats/${encodeURIComponent(this.chatId)}/stream`, controller.signal, !allowRecovery);
|
||||||
signal: controller.signal,
|
|
||||||
});
|
|
||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
if (!res.ok) throw new Error((await res.text()) || "Failed to attach to the running turn.");
|
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) {
|
} catch (err) {
|
||||||
if (this.consumeAbort !== controller || (err as Error).name === "AbortError") return; // a newer consume won
|
if (this.consumeAbort !== controller || controller.signal.aborted) return; // intentionally cancelled
|
||||||
this.setError((err as Error).message);
|
// 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");
|
this.setStatus("error");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -563,12 +637,42 @@ export class SessionModel {
|
|||||||
* server's 409 + identity retry covers the wake-race window).
|
* server's 409 + identity retry covers the wake-race window).
|
||||||
*/
|
*/
|
||||||
async send(input: ChatInput): Promise<void> {
|
async send(input: ChatInput): Promise<void> {
|
||||||
if (!this.readyRef) return;
|
const recovering = this.statusValue === "error";
|
||||||
|
if (!this.readyRef && !recovering) return;
|
||||||
this.consumeAbort?.abort();
|
this.consumeAbort?.abort();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
this.consumeAbort = controller;
|
this.consumeAbort = controller;
|
||||||
this.setError(null);
|
this.setError(null);
|
||||||
this.setStatus("submitted");
|
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
|
// The optimistic appends — exactly the messages the server's
|
||||||
// write-ahead will persist (inputAppends), with LOCAL ids so
|
// write-ahead will persist (inputAppends), with LOCAL ids so
|
||||||
// adoption can tell them from the record's absorbed copies.
|
// adoption can tell them from the record's absorbed copies.
|
||||||
@@ -585,7 +689,9 @@ export class SessionModel {
|
|||||||
optimistic.push({ ...newNextMessage(), id: localId() });
|
optimistic.push({ ...newNextMessage(), id: localId() });
|
||||||
} else if (input.type === "answer") {
|
} else if (input.type === "answer") {
|
||||||
const tool = pendingCallTool([...this.base, ...this.tail], input.toolCallId) ?? "quiz";
|
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(() => {
|
if (optimistic.length > 0) this.mutate(() => {
|
||||||
this.tail = [...this.tail, ...optimistic];
|
this.tail = [...this.tail, ...optimistic];
|
||||||
@@ -605,9 +711,7 @@ export class SessionModel {
|
|||||||
// connection is Diet A's whole job.
|
// connection is Diet A's whole job.
|
||||||
const res = await this.postInputEvent(input);
|
const res = await this.postInputEvent(input);
|
||||||
if (res.status === 409) {
|
if (res.status === 409) {
|
||||||
this.setError("A turn is already running — try again in a moment.");
|
throw new Error("A turn is already running — try again in a moment.");
|
||||||
this.setStatus("error");
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
// Refusal envelopes (503 BYOK/key gates) and 400s: the RAW body
|
||||||
@@ -629,7 +733,17 @@ export class SessionModel {
|
|||||||
if (this.consumeAbort !== controller) return;
|
if (this.consumeAbort !== controller) return;
|
||||||
await this.resumeStream(true);
|
await this.resumeStream(true);
|
||||||
} catch (err) {
|
} 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.setError((err as Error).message);
|
||||||
this.setStatus("error");
|
this.setStatus("error");
|
||||||
}
|
}
|
||||||
@@ -740,10 +854,32 @@ export class SessionModel {
|
|||||||
this.setStatus("ready");
|
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). */
|
/** Full snapshot swap (the idle settle sync — server canon). */
|
||||||
setMessages(record: LhMessage[]): void {
|
setMessages(record: LhMessage[], turnError?: string): void {
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
if (turnError !== undefined) {
|
||||||
|
this.errorValue = { message: turnError };
|
||||||
|
this.automaticContinuationBlocked = true;
|
||||||
|
}
|
||||||
|
this.forgetResolvedDrafts(record);
|
||||||
this.incompleteUsageIds.clear();
|
this.incompleteUsageIds.clear();
|
||||||
this.tail = [];
|
this.tail = [];
|
||||||
this.streamingMessageId = null; // the tail (and its slot) is gone
|
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
|
if (baseAbsorbed) return; // not a pure append — the idle full sync is the net
|
||||||
this.mutate(() => {
|
this.mutate(() => {
|
||||||
this.base = record;
|
this.base = record;
|
||||||
|
this.forgetResolvedDrafts(record);
|
||||||
this.tail = this.tail.filter((m) => !absorbed(m, 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). */
|
/** The mount/sync continuation fold (native-messages.ts). */
|
||||||
static needsContinuation = needsContinuation;
|
static needsContinuation = needsContinuation;
|
||||||
|
|
||||||
|
|||||||
+11
-3
@@ -22,16 +22,18 @@ export interface UseChatSessionOptions {
|
|||||||
chatId: string;
|
chatId: string;
|
||||||
/** The native record seed (server-rendered page or a full sync). */
|
/** The native record seed (server-rendered page or a full sync). */
|
||||||
initialMessages: LhMessage[];
|
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. */
|
/** Reattach on mount: a turn is running server-side with no live view. */
|
||||||
initiallyInFlight?: boolean;
|
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
|
// The model is per-mount (chatId-keyed component); onChange forces the
|
||||||
// re-render. The ref pattern keeps it stable across renders.
|
// re-render. The ref pattern keeps it stable across renders.
|
||||||
const modelRef = useRef<SessionModel | null>(null);
|
const modelRef = useRef<SessionModel | null>(null);
|
||||||
if (modelRef.current === null) {
|
if (modelRef.current === null) {
|
||||||
modelRef.current = new SessionModel({ chatId, initialMessages });
|
modelRef.current = new SessionModel({ chatId, initialMessages, initialError });
|
||||||
}
|
}
|
||||||
const model = modelRef.current;
|
const model = modelRef.current;
|
||||||
// The render VERSION — bumped by every model change, so the state memo
|
// 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 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 steer = useCallback((text: string) => void model.steer(text), [model]);
|
||||||
const submitAnswer = useCallback(
|
const submitAnswer = useCallback(
|
||||||
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
(tool: string, toolCallId: string, output: QuestionAnswerData) =>
|
||||||
@@ -115,7 +118,9 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
(problemCallId: string) => void model.dismissProblem(problemCallId),
|
||||||
[model],
|
[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 adoptRecord = useCallback((record: LhMessage[]) => model.adoptRecord(record), [model]);
|
||||||
const stop = useCallback(() => model.stop(), [model]);
|
const stop = useCallback(() => model.stop(), [model]);
|
||||||
const clearError = useCallback(() => model.clearError(), [model]);
|
const clearError = useCallback(() => model.clearError(), [model]);
|
||||||
@@ -129,10 +134,13 @@ export function useChatSession({ chatId, initialMessages, initiallyInFlight }: U
|
|||||||
error: state.error,
|
error: state.error,
|
||||||
steerQueue: state.steerQueue,
|
steerQueue: state.steerQueue,
|
||||||
send,
|
send,
|
||||||
|
continueIfOwed,
|
||||||
steer,
|
steer,
|
||||||
submitAnswer,
|
submitAnswer,
|
||||||
dismissQuestion,
|
dismissQuestion,
|
||||||
dismissProblem,
|
dismissProblem,
|
||||||
|
questionDraft,
|
||||||
|
saveQuestionDraft,
|
||||||
setMessages,
|
setMessages,
|
||||||
adoptRecord,
|
adoptRecord,
|
||||||
stop,
|
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 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.
|
- **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.
|
- **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)
|
### 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 });
|
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
|
* Toggling a listed option. Single-select: the option becomes THE answer and
|
||||||
* displaces "Other". Multi-select: the option toggles alongside its siblings
|
* 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 { modelsNotConfigured, providerKeyMissing as providerKeyMissingEnvelope, type ProviderKeyMissing } from "../core/settings.ts";
|
||||||
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
import { beginTurn, endTurn } from "./turn-liveness.ts";
|
||||||
import { applyParkedDismissal } from "./dismissal-park.ts";
|
import { applyParkedDismissal } from "./dismissal-park.ts";
|
||||||
import { registerTurn, unregisterTurn } from "./turn-runs.ts";
|
import { registerTurn, rememberTurnFailure, unregisterTurn } from "./turn-runs.ts";
|
||||||
import { persistTurn, triggerExtraction } from "./turn-service.ts";
|
import { persistTurn, triggerExtraction, TurnPersistenceError } from "./turn-service.ts";
|
||||||
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
import { hasProbeDone, danglingQuestionOf } from "../core/native-messages.ts";
|
||||||
import { composeInstructions } from "../agent/teacher-agent.ts";
|
import { composeInstructions } from "../agent/teacher-agent.ts";
|
||||||
import { ensureCompacted, kickCompaction, effectiveThresholdTokens } from "./compaction.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.
|
* 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({
|
const jobRunner = createJobRunner({
|
||||||
chatStore: stores.chatStore,
|
chatStore: stores.chatStore,
|
||||||
jobStore: stores.jobStore,
|
jobStore: stores.jobStore,
|
||||||
@@ -289,7 +293,11 @@ export async function handleChatInput(
|
|||||||
// the composer gates), so adjacency holds by construction.
|
// the composer gates), so adjacency holds by construction.
|
||||||
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
if (input.type === "answer" && danglingQuestionOf([...record.messages, ...appends]) !== null) {
|
||||||
const merged = [...record.messages, ...appends];
|
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 });
|
emit("record-changed", { chatId });
|
||||||
return settledTurnResponse(); // the cheap terminal — no beginTurn, no turn
|
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;
|
let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length;
|
||||||
try {
|
try {
|
||||||
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged);
|
||||||
// Dock fix (owner ruling 2026-09-26): persisted markers are visible
|
} catch (err) {
|
||||||
// in the record — flip their jobs now (the wake's steers ride this
|
console.error(`[chat ${chatId}] write-ahead persist failed:`, err);
|
||||||
// write-ahead; the delivering turn may run for minutes).
|
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) {
|
for (const appended of stagedAutomatic ? [] : appends) {
|
||||||
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
if ((appended as { role?: string }).role === "custom") noteSteerVisible(appended as SteerMessage);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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
|
// 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);
|
console.error(`[chat ${chatId}] turn error:`, error);
|
||||||
return "An error occurred.";
|
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) => {
|
onFinish: (turnMessages, isAborted) => {
|
||||||
// Wave B: the record write is the agent's own final state —
|
// Wave B: the record write is the agent's own final state —
|
||||||
// the new messages are the slice past the merged base.
|
// 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 { graphRevision } from "./graph.ts";
|
||||||
import { stores } from "./stores.ts";
|
import { stores } from "./stores.ts";
|
||||||
import { isTurnRunning } from "./turn-liveness.ts";
|
import { isTurnRunning } from "./turn-liveness.ts";
|
||||||
|
import { clearTurnFailure } from "./turn-runs.ts";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Server-side chat entries for the transport layer (app/api/chats*,
|
* 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.
|
// ledgers → jobs → the chat record.
|
||||||
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
const stats = deleteChatTraces(stores.graphStore, chatId, mode);
|
||||||
const jobsRemoved = stores.jobStore.deleteJobsForChat(chatId);
|
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() };
|
return { ok: true, mode, stats, jobsRemoved, graphRevision: graphRevision() };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,10 @@ export interface PiTurnSpec {
|
|||||||
* event stream itself is verbatim — provider errors ride their
|
* event stream itself is verbatim — provider errors ride their
|
||||||
* message's stopReason/errorMessage through untouched). */
|
* message's stopReason/errorMessage through untouched). */
|
||||||
onError?: (error: unknown) => string;
|
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.
|
/** The canonical end-of-turn seam: persist + usage + extraction + endTurn.
|
||||||
* Receives the agent's FULL final state (native messages) — the caller
|
* Receives the agent's FULL final state (native messages) — the caller
|
||||||
* persists the slice past its own base count. */
|
* persists the slice past its own base count. */
|
||||||
@@ -119,11 +123,12 @@ export interface PiTurnHandle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function runPiTurn(spec: PiTurnSpec): 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;
|
const uniquifier = usedToolCallIds ? new ToolCallIdUniquifier(usedToolCallIds) : undefined;
|
||||||
|
|
||||||
let settled = false;
|
let settled = false;
|
||||||
let sawAbort = false;
|
let sawAbort = false;
|
||||||
|
let reportedErrorText: string | undefined;
|
||||||
|
|
||||||
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
// THE PER-TURN STATE LOG (memory-only — the settle-only disk writes
|
||||||
// are unchanged). COMPACTION LAW (owner, 2026-10-09, supersedes the
|
// 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
|
// messages of its own (the prompt is composed per turn), so dropping
|
||||||
// the role wholesale is exact; revisit if prompt-updates ever land.
|
// the role wholesale is exact; revisit if prompt-updates ever land.
|
||||||
const settleWith = () => {
|
const settleWith = () => {
|
||||||
const settled_ = [...agent.state.messages].filter((m) => (m as { role?: string }).role !== "system");
|
try {
|
||||||
onFinish((uniquifier?.remapSettled(settled_) as AgentMessage[]) ?? settled_, sawAbort);
|
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) => {
|
const handle = (event: AgentEvent) => {
|
||||||
|
if (settled) return;
|
||||||
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
// THE LACAN FIX: ids uniquify HERE, before any view — the one seam
|
||||||
// every consumer downstream shares. No uniquifier → verbatim.
|
// every consumer downstream shares. No uniquifier → verbatim.
|
||||||
const uniqued = uniquifier ? uniquifier.event(event) : event;
|
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 (m?.role === "custom") noteSteerVisible(event.message as SteerMessage);
|
||||||
}
|
}
|
||||||
if (event.type === "agent_end") {
|
if (event.type === "agent_end") {
|
||||||
closeAll();
|
// Persist/report before the terminal closes every view to new frames.
|
||||||
settleWith();
|
settleWith();
|
||||||
|
closeAll();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
requestCycle?.bindRelease(handle);
|
requestCycle?.bindRelease(handle);
|
||||||
@@ -273,11 +290,15 @@ export function runPiTurn(spec: PiTurnSpec): PiTurnHandle {
|
|||||||
// stream as stopReason "error"): surface, close, persist. If the
|
// stream as stopReason "error"): surface, close, persist. If the
|
||||||
// agent already settled (agent_end reached before the throw),
|
// agent already settled (agent_end reached before the throw),
|
||||||
// the seam has run — never settle twice.
|
// 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);
|
console.error("[turn] agent loop failed:", err);
|
||||||
send({ type: "error", errorText: onError?.(err) ?? "An error occurred." });
|
reportedErrorText = onError?.(err) ?? "An error occurred.";
|
||||||
closeAll();
|
send({ type: "error", errorText: reportedErrorText });
|
||||||
settleWith();
|
settleWith();
|
||||||
|
closeAll();
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { emit } from "../core/events.ts";
|
import { emit } from "../core/events.ts";
|
||||||
|
import { clearTurnFailure } from "./turn-runs.ts";
|
||||||
|
|
||||||
interface LhGlobal {
|
interface LhGlobal {
|
||||||
__lhTurnRegistry?: Map<string, number>;
|
__lhTurnRegistry?: Map<string, number>;
|
||||||
@@ -43,15 +44,17 @@ const MAX_TURN_MS = 30 * 60_000;
|
|||||||
*/
|
*/
|
||||||
export function beginTurn(chatId: string): boolean {
|
export function beginTurn(chatId: string): boolean {
|
||||||
if (isTurnRunning(chatId)) return false;
|
if (isTurnRunning(chatId)) return false;
|
||||||
|
clearTurnFailure(chatId);
|
||||||
inFlight.set(chatId, Date.now());
|
inFlight.set(chatId, Date.now());
|
||||||
emit("turn-started", { chatId });
|
emit("turn-started", { chatId });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mark the chat's turn as finished (runs in onFinish, aborted or not —
|
* Mark the chat's turn as finished after normal settlement, or after the
|
||||||
* AFTER persistTurn, so "turn-settled" guarantees a subsequent fetch reads
|
* failure notice has been retained by settlement cleanup. A subsequent
|
||||||
* durable truth). Emits only when an entry was actually present: a
|
* 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.
|
* defensive no-op endTurn must not fire a spurious settle.
|
||||||
*/
|
*/
|
||||||
export function endTurn(chatId: string): void {
|
export function endTurn(chatId: string): void {
|
||||||
|
|||||||
+30
-5
@@ -13,7 +13,9 @@
|
|||||||
* Registration lifecycle: chat-handler registers the handle the moment
|
* Registration lifecycle: chat-handler registers the handle the moment
|
||||||
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
* the turn starts (after the 409 guard) and its onFinish unregisters LAST
|
||||||
* — after persist, usage, extraction, and endTurn — so anything that sees
|
* — 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
|
* 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
|
* 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
|
* 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 {
|
interface LhTurnRunsGlobal {
|
||||||
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
__lhTurnRuns?: Map<string, PiTurnHandle>;
|
||||||
|
__lhTurnFailures?: Map<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function runs(): Map<string, PiTurnHandle> {
|
function runs(): Map<string, PiTurnHandle> {
|
||||||
@@ -37,6 +40,25 @@ function runs(): Map<string, PiTurnHandle> {
|
|||||||
return (g.__lhTurnRuns ??= new 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
|
/** Register the chat's running turn (chat-handler, right after the turn
|
||||||
* starts). One per chat by construction — the 409 guard. */
|
* starts). One per chat by construction — the 409 guard. */
|
||||||
export function registerTurn(chatId: string, handle: PiTurnHandle): void {
|
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
|
/** The cheap terminal when GET /api/chats/[id]/stream finds no running
|
||||||
* turn (raced settle, or the client checked turnInFlight a beat too
|
* turn (raced settle, or the client checked turnInFlight a beat too
|
||||||
* late): a same-dialect SSE response whose single chunk tells the client
|
* late): a same-dialect SSE response whose single chunk tells the client
|
||||||
* "nothing to attach to — the record is current, refetch it". Chosen as
|
* "nothing to attach to — refetch durable truth". A retained settlement
|
||||||
* an EVENT (not a status code) so the reattach path consumes ONE wire
|
* 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. */
|
* protocol end to end. */
|
||||||
export function settledTurnResponse(): Response {
|
export function settledTurnResponse(chatId?: string): Response {
|
||||||
return new Response('data: {"type":"turn-settled"}\n\ndata: [DONE]\n\n', {
|
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: {
|
headers: {
|
||||||
"Content-Type": "text/event-stream",
|
"Content-Type": "text/event-stream",
|
||||||
"Cache-Control": "no-cache, no-transform",
|
"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
|
* responsibility, extracted from the chat-handler so it composes instead of
|
||||||
* implementing. Each takes its dependencies explicitly (storage ports, the
|
* implementing. Each takes its dependencies explicitly (storage ports, the
|
||||||
* job runner) — no module here imports an adapter.
|
* 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 type { ExtractionPipeline } from "../agent/subagents/extraction.ts";
|
||||||
import { ModelsNotConfiguredError, ProviderKeyMissingError } from "../core/settings.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
|
* 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/
|
* 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,
|
* - Plan blocks: one frozen snapshot per successful plan_init call,
|
||||||
* idempotent (persist replays are safe).
|
* 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(
|
export function persistTurn(
|
||||||
chatStore: ChatStore,
|
chatStore: ChatStore,
|
||||||
@@ -56,7 +65,7 @@ export function persistTurn(
|
|||||||
): LhMessage[] {
|
): LhMessage[] {
|
||||||
try {
|
try {
|
||||||
const record = chatStore.getChat(chatId);
|
const record = chatStore.getChat(chatId);
|
||||||
if (!record) return [];
|
if (!record) throw new Error(`Chat not found: ${chatId}`);
|
||||||
const stamped: LhMessage[] = [];
|
const stamped: LhMessage[] = [];
|
||||||
for (const m of turnMessages.slice(baseCount)) {
|
for (const m of turnMessages.slice(baseCount)) {
|
||||||
const message = m as LhMessage;
|
const message = m as LhMessage;
|
||||||
@@ -79,7 +88,7 @@ export function persistTurn(
|
|||||||
return stamped;
|
return stamped;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[chat ${chatId}] persist failed:`, err, { isAborted });
|
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 { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
||||||
import { effectiveSettings } from "../src/server/stores.ts";
|
import { effectiveSettings } from "../src/server/stores.ts";
|
||||||
import { isTurnRunning } from "../src/server/turn-liveness.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 { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||||
import { chatStore } from "../src/store/chats.ts";
|
import { chatStore } from "../src/store/chats.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.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 { projectView } from "../src/core/view.ts";
|
||||||
import type { PiEngine } from "../src/agent/models.ts";
|
import type { PiEngine } from "../src/agent/models.ts";
|
||||||
import type { LhMessage } from "../src/core/native-messages.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).
|
// store imports already resolved under).
|
||||||
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
||||||
const steerRoute = await import("../app/api/chats/[id]/steer/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";
|
import { PATHS } from "../config.ts";
|
||||||
|
|
||||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
// 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)", () => {
|
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 () => {
|
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||||
const { engine } = scriptedTeacher(defaultLesson);
|
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 { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts";
|
||||||
import { deleteChatSession, sessionCoverage } from "../src/server/chats.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 { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||||
import { jobStore } from "../src/store/jobs.ts";
|
import { jobStore } from "../src/store/jobs.ts";
|
||||||
import {
|
import {
|
||||||
@@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
|||||||
const turnedAway = await deleteChatSession(dA.id, "delete-nodes");
|
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: [] });
|
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);
|
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 job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" });
|
||||||
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
|
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
|
||||||
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
|
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
|
||||||
if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]);
|
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");
|
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.
|
// 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.ok(outcome.graphRevision > 0, "the version stamp moved");
|
||||||
}
|
}
|
||||||
assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone");
|
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");
|
assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone");
|
||||||
// And a second call is an honest 404.
|
// 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" });
|
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)", () => {
|
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
|
||||||
const job = (over: Partial<JobRecord>): JobRecord => ({
|
const job = (over: Partial<JobRecord>): JobRecord => ({
|
||||||
id: "j1",
|
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) ────────────────────────────────────────────────
|
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||||
|
|
||||||
describe("resumeStream (the reattach path)", () => {
|
describe("resumeStream (the reattach path)", () => {
|
||||||
|
|||||||
@@ -11,8 +11,19 @@ import assert from "node:assert/strict";
|
|||||||
import { describe, it } from "node:test";
|
import { describe, it } from "node:test";
|
||||||
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||||
import { subscribe, type ServerEvent } from "../src/core/events.ts";
|
import { subscribe, type ServerEvent } from "../src/core/events.ts";
|
||||||
|
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||||
|
|
||||||
describe("turn liveness registry", () => {
|
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", () => {
|
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
|
||||||
const id = "chat_liveness_unit";
|
const id = "chat_liveness_unit";
|
||||||
assert.equal(isTurnRunning(id), false, "initially idle");
|
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