Merge bounded session answer and stream recovery
This commit is contained in:
+102
-2
@@ -30,13 +30,14 @@ import { createTeacherTools } from "../src/agent/tools.ts";
|
||||
import { handleChatInput, handleChatRequest } from "../src/server/chat-handler.ts";
|
||||
import { effectiveSettings } from "../src/server/stores.ts";
|
||||
import { isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { deliverJobResult, setTurnRunner } from "../src/server/jobs.ts";
|
||||
import { deliverJobResult, registerLiveAgent, setTurnRunner, steerLiveChat, unregisterLiveAgent } from "../src/server/jobs.ts";
|
||||
import { SessionModel } from "../components/session-core.ts";
|
||||
import { buildSubagentSteer } from "../src/core/contracts.ts";
|
||||
import { abortTurn, runningTurn, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
import { chatStore } from "../src/store/chats.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import { buildAnswer, dismissedAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult } from "../src/core/native-messages.ts";
|
||||
import { danglingQuestionOf, endsInDismissal, needsContinuation, newAnswerResult, newUserMessage } from "../src/core/native-messages.ts";
|
||||
import { projectView } from "../src/core/view.ts";
|
||||
import type { PiEngine } from "../src/agent/models.ts";
|
||||
import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
@@ -46,6 +47,8 @@ import type { LhMessage } from "../src/core/native-messages.ts";
|
||||
// store imports already resolved under).
|
||||
const dismissRoute = await import("../app/api/chats/[id]/dismiss/route.ts");
|
||||
const steerRoute = await import("../app/api/chats/[id]/steer/route.ts");
|
||||
const streamRoute = await import("../app/api/chats/[id]/stream/route.ts");
|
||||
const recordRoute = await import("../app/api/chats/[id]/route.ts");
|
||||
import { PATHS } from "../config.ts";
|
||||
|
||||
// The key-presence turn gate (provider-key interlude, 2026-09-25): scripted
|
||||
@@ -845,6 +848,103 @@ describe("chat store", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("canonical persistence failures", () => {
|
||||
it("refuses a failed input save without building an agent or leaving a live turn", async () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: failed answer write-ahead");
|
||||
createdChats.push(chat.id);
|
||||
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||
const before = chatStore.getChat(chat.id)!.messages;
|
||||
const save = chatStore.saveMessages;
|
||||
let builds = 0;
|
||||
chatStore.saveMessages = () => { throw new Error("synthetic write-ahead failure"); };
|
||||
try {
|
||||
const res = await handleChatRequest(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), (opts) => {
|
||||
builds++;
|
||||
return agentFactory(engine)!(opts);
|
||||
});
|
||||
const running = runningTurn(chat.id);
|
||||
if (running) await new Response(running.subscribe()).text();
|
||||
assert.equal(res.status, 500);
|
||||
const client = new SessionModel({ chatId: chat.id, initialMessages: before, fetch: async () => res.clone() });
|
||||
await client.submitAnswer("quiz", "call-1", gradedAnswer(QUIZ_INPUT, ["Mercury"]));
|
||||
assert.equal(client.getState().error?.message, "Your input could not be saved. Please try again.");
|
||||
assert.equal(await res.text(), "Your input could not be saved. Please try again.");
|
||||
assert.equal(builds, 0);
|
||||
assert.equal(isTurnRunning(chat.id), false);
|
||||
assert.equal(runningTurn(chat.id), undefined);
|
||||
assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
|
||||
} finally { chatStore.saveMessages = save; }
|
||||
});
|
||||
|
||||
it("reports a failed reply save before DONE while keeping the accepted answer durable", async () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
const chat = createConfiguredChat("Test: failed reply persistence");
|
||||
createdChats.push(chat.id);
|
||||
await drainTurn(post(chat.id, { type: "text", text: "quiz me" }), agentFactory(engine));
|
||||
const save = chatStore.saveMessages;
|
||||
let writes = 0;
|
||||
chatStore.saveMessages = (id, messages) => {
|
||||
if (++writes === 2) throw new Error("synthetic reply save failure");
|
||||
return save(id, messages);
|
||||
};
|
||||
try {
|
||||
const body = await drainTurn(post(chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer(QUIZ_INPUT, ["Mercury"]) }), agentFactory(engine));
|
||||
const errors = eventsOf(body).filter(e => e.type === "error");
|
||||
assert.equal(errors.length, 1);
|
||||
assert.equal(errors[0]!.errorText, "The reply could not be saved.");
|
||||
assert.ok(body.indexOf("The reply could not be saved.") < body.indexOf("[DONE]"));
|
||||
assert.equal(isTurnRunning(chat.id), false);
|
||||
assert.equal(runningTurn(chat.id), undefined);
|
||||
assert.equal(chatStore.getChat(chat.id)!.messages.at(-1)?.role, "toolResult");
|
||||
// The normal submitter may not reach GET /stream until AFTER the
|
||||
// failing turn has left the live registry. It must still see it.
|
||||
const late = await streamRoute.GET(new Request("http://test/stream"), { params: Promise.resolve({ id: chat.id }) });
|
||||
assert.equal(eventsOf(await late.text()).find(e => e.type === "error")?.errorText, "The reply could not be saved.");
|
||||
// An already-open idle view can miss both the start and the wire
|
||||
// error, then learn of settlement only via a record synchronization.
|
||||
const snapshot = await (await recordRoute.GET(new Request("http://test/record"), { params: Promise.resolve({ id: chat.id }) })).json();
|
||||
assert.equal(needsContinuation(snapshot.messages), true, "the durable answer alone looks like an owed continuation");
|
||||
let automaticPosts = 0;
|
||||
const missedView = new SessionModel({ chatId: chat.id, initialMessages: [], fetch: async () => {
|
||||
automaticPosts++;
|
||||
return new Response("unexpected automatic retry", { status: 500 });
|
||||
} });
|
||||
missedView.setMessages(snapshot.messages, snapshot.turnError);
|
||||
await missedView.continueIfOwed(snapshot.messages);
|
||||
assert.equal(automaticPosts, 0, "the record notice must block replacement even without a stream attachment");
|
||||
assert.equal(missedView.getState().error?.message, "The reply could not be saved.");
|
||||
} finally { chatStore.saveMessages = save; }
|
||||
});
|
||||
|
||||
it("does not redeliver a persisted learner steer after usage bookkeeping fails", async () => {
|
||||
const { engine } = scriptedTeacher(() => [() => fauxAssistantMessage([fauxText("Synthetic response")])]);
|
||||
const chat = createConfiguredChat("Test: post-save bookkeeping failure");
|
||||
createdChats.push(chat.id);
|
||||
const steer = newUserMessage("Synthetic learner steer");
|
||||
const appendUsage = chatStore.appendTurnUsage;
|
||||
chatStore.appendTurnUsage = () => { throw new Error("synthetic usage write failure"); };
|
||||
try {
|
||||
const body = await drainTurn(post(chat.id, { type: "text", text: "go" }), opts => {
|
||||
const agent = createTeacherAgent(undefined, { engine, messages: opts.messages, onUsage: opts.onUsage });
|
||||
agent.subscribe(event => {
|
||||
if (event.type === "agent_start") assert.equal(steerLiveChat(chat.id, steer), true);
|
||||
});
|
||||
return agent;
|
||||
});
|
||||
assert.equal(eventsOf(body).find(e => e.type === "error")?.errorText, "An error occurred.");
|
||||
assert.ok(chatStore.getChat(chat.id)!.messages.some(m => m.id === steer.id), "the steer was saved before bookkeeping failed");
|
||||
const delivered: LhMessage[] = [];
|
||||
const next = { steer: (message: LhMessage) => delivered.push(message), state: { model: { provider: "faux" } } } as unknown as Parameters<typeof registerLiveAgent>[1];
|
||||
registerLiveAgent(chat.id, next);
|
||||
assert.deepEqual(delivered, [], "an already-saved learner steer must not reach another agent");
|
||||
} finally {
|
||||
chatStore.appendTurnUsage = appendUsage;
|
||||
unregisterLiveAgent(chat.id);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("mid-turn navigation (turn liveness + write-ahead)", () => {
|
||||
it("one turn per chat: a concurrent request is 409'd; the next one after finish is accepted", async () => {
|
||||
const { engine } = scriptedTeacher(defaultLesson);
|
||||
|
||||
+5
-1
@@ -657,6 +657,7 @@ describe("graph ledger store", () => {
|
||||
|
||||
import { deleteChatTraces, type ChatDeletionStats } from "../src/core/graph-deletion.ts";
|
||||
import { deleteChatSession, sessionCoverage } from "../src/server/chats.ts";
|
||||
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
import { beginTurn, endTurn } from "../src/server/turn-liveness.ts";
|
||||
import { jobStore } from "../src/store/jobs.ts";
|
||||
import {
|
||||
@@ -811,11 +812,13 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
const turnedAway = await deleteChatSession(dA.id, "delete-nodes");
|
||||
assert.deepEqual(turnedAway, { ok: false, status: 409, code: "turn_in_flight", error: "A turn is running in this session — try again when it finishes", jobIds: [] });
|
||||
endTurn(dA.id);
|
||||
// 409 — running background jobs.
|
||||
// 409 — running background jobs; a refused delete keeps its notice.
|
||||
rememberTurnFailure(dA.id, "The reply could not be saved.");
|
||||
const job = jobStore.createJob({ kind: "research", chatId: dA.id, toolCallId: "del-call-1" });
|
||||
const jobsBlocked = await deleteChatSession(dA.id, "delete-nodes");
|
||||
assert.ok(!jobsBlocked.ok && jobsBlocked.status === 409, "refused with 409");
|
||||
if (jobsBlocked.code === "jobs_running") assert.deepEqual(jobsBlocked.jobIds, [job.id]);
|
||||
assert.match(await settledTurnResponse(dA.id).text(), /The reply could not be saved\./);
|
||||
assert.equal(jobStore.deleteJobsForChat(dA.id), 1, "test cleanup — the job file is gone");
|
||||
|
||||
// Success — a terminal job rides along; the chat record dies last.
|
||||
@@ -830,6 +833,7 @@ describe("session deletion (deleteChatTraces + deleteChatSession, wave 6)", () =
|
||||
assert.ok(outcome.graphRevision > 0, "the version stamp moved");
|
||||
}
|
||||
assert.equal(chatStore.getChat(dA.id), null, "the chat record is gone");
|
||||
assert.doesNotMatch(await settledTurnResponse(dA.id).text(), /"type":"error"/, "deletion clears its transient notice");
|
||||
assert.equal(jobStore.getJob(doneJob.id), null, "the chat's job records are gone");
|
||||
// And a second call is an honest 404.
|
||||
assert.deepEqual(await deleteChatSession(dA.id, "delete-nodes"), { ok: false, status: 404, code: "not_found", error: "No such session" });
|
||||
|
||||
@@ -78,6 +78,31 @@ function renderDock(props: {
|
||||
);
|
||||
}
|
||||
|
||||
describe("question submission draft restoration", () => {
|
||||
const draft = {
|
||||
selection: { selected: new Set([0]), otherSelected: true, dontKnow: false },
|
||||
thinking: "preserved reasoning",
|
||||
other: "preserved custom answer",
|
||||
text: "preserved free-text answer",
|
||||
confidenceIndex: 4,
|
||||
};
|
||||
|
||||
it("restores a quiz selection, thinking, and confidence when its form remounts", () => {
|
||||
const html = renderToStaticMarkup(<QuestionDock toolName="quiz" toolCallId="q1" input={quizInput} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.match(html, /role="radio" aria-checked="true" data-kbdrow="0"/);
|
||||
assert.ok(html.includes("preserved reasoning"));
|
||||
assert.ok(html.includes("90%"));
|
||||
});
|
||||
|
||||
it("restores an ask's custom answer and a free-text answer", () => {
|
||||
const custom = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask1" input={{ question: "Fixture?", options: [{ label: "Listed answer" }, { label: "Another answer" }], multiSelect: true }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.ok(custom.includes('value="preserved custom answer"'));
|
||||
assert.match(custom, /aria-checked="true" data-testid="other-option"/);
|
||||
const text = renderToStaticMarkup(<QuestionDock toolName="ask_user_question" toolCallId="ask2" input={{ question: "Fixture?" }} expanded onExpand={() => {}} onSubmit={() => {}} initialDraft={draft} />);
|
||||
assert.ok(text.includes("preserved free-text answer"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("DispatchCard — the BYOK refusal tone (owner, 2026-09-22)", () => {
|
||||
const job = (over: Partial<JobRecord>): JobRecord => ({
|
||||
id: "j1",
|
||||
|
||||
@@ -684,6 +684,334 @@ describe("provider-error banner (live and saved)", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("truncated stream recovery", () => {
|
||||
const truncated = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "partial" }] } });
|
||||
const completed = () => sseResponse({ type: "message_start", message: { role: "assistant", content: [{ type: "text", text: "complete" }] } }, { type: "message_end", message: { role: "assistant", content: [{ type: "text", text: "complete" }], stopReason: "stop" } }, "[DONE]");
|
||||
|
||||
it("reattaches once after EOF without DONE and replaces the incomplete replay", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ userText: "go", stream: () => ++streams === 1 ? truncated() : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.equal(m.getState().nativeMessages.filter(m => m.role === "assistant").length, 1);
|
||||
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
|
||||
});
|
||||
|
||||
it("surfaces an error after a second truncation without retrying the input", async () => {
|
||||
let streams = 0;
|
||||
let posts = 0;
|
||||
const m = model({ fetch: dietFetch({ onPost: () => posts++, stream: () => { streams++; return truncated(); } }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("reports the approved connection error when the recovery record read fails", async () => {
|
||||
let reads = 0;
|
||||
const m = model({ fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
if (String(url).endsWith("/stream")) return truncated();
|
||||
if (++reads === 1) return Response.json({ messages: [] });
|
||||
throw new TypeError("synthetic offline");
|
||||
} });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("accepts turn-settled when the original turn finished during the disconnection", async () => {
|
||||
let streams = 0;
|
||||
let settled = 0;
|
||||
const m = model({ onReattachSettled: () => { settled++; }, fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : sseResponse({ type: "turn-settled" }, "[DONE]") }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(settled, 1);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("also recovers a rejected body read but does not relabel event-application bugs", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic read failure")); } })) : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
streams = 0;
|
||||
const broken = model({ fetch: dietFetch({ stream: () => { streams++; return completed(); } }) });
|
||||
broken.applyWireEvent = () => { throw new Error("synthetic event application bug"); };
|
||||
await broken.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 1);
|
||||
assert.equal(broken.getState().error?.message, "synthetic event application bug");
|
||||
});
|
||||
|
||||
it("recovers an AbortError body rejection unless the view was actually cancelled", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
|
||||
start(controller) { controller.error(new DOMException("synthetic unexpected read abort", "AbortError")); },
|
||||
})) : completed() }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("uses the approved drop message when recovery record headers arrive but its body read fails", async () => {
|
||||
let reads = 0;
|
||||
const m = model({ fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
if (String(url).endsWith("/stream")) return truncated();
|
||||
if (++reads === 1) return Response.json({ messages: [] });
|
||||
return new Response(new ReadableStream({ start(controller) { controller.error(new TypeError("synthetic record-body transport failure")); } }));
|
||||
} });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("preserves malformed-event diagnostics rather than calling them network drops", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => { streams++; return new Response("data: {broken}\n\n"); } }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 1);
|
||||
assert.equal(m.status, "error");
|
||||
assert.notEqual(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
assert.match(m.getState().error!.message, /JSON|property name/);
|
||||
});
|
||||
|
||||
it("reports a failed recovery stream request without attempting a third attachment", async () => {
|
||||
let streams = 0;
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? truncated() : new Response("synthetic unavailable", { status: 503 }) }) });
|
||||
await m.send({ type: "text", text: "go" });
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().error?.message, "The connection dropped before the turn completed.");
|
||||
});
|
||||
|
||||
it("a superseded consume cannot recover or overwrite its replacement's state", async () => {
|
||||
let streams = 0;
|
||||
let closeFirst!: () => void;
|
||||
let beganReading!: () => void;
|
||||
const reading = new Promise<void>(resolve => { beganReading = resolve; });
|
||||
const m = model({ fetch: dietFetch({ stream: () => ++streams === 1 ? new Response(new ReadableStream({
|
||||
start(controller) { closeFirst = () => controller.close(); },
|
||||
pull() { beganReading(); },
|
||||
}, { highWaterMark: 0 })) : completed() }) });
|
||||
const first = m.resumeStream(false);
|
||||
await reading;
|
||||
await m.resumeStream(false);
|
||||
closeFirst();
|
||||
await first;
|
||||
assert.equal(streams, 2);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.equal(m.getState().error, null);
|
||||
assert.equal(m.getState().messages.at(-1)!.parts.at(-1)!.text, "complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("automatic continuation after recovery failure", () => {
|
||||
const question = { ...assistantMsg("question", ""), content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }] } as LhMessage;
|
||||
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true });
|
||||
const answered = [question, newAnswerResult("q1", "quiz", answer)];
|
||||
|
||||
it("a retained failure seeds the banner and blocks continuation even after banner dismissal", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, initialError: "The reply could not be saved.", fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
assert.equal(SessionModel.needsContinuation(answered), true);
|
||||
assert.equal(m.getState().error?.message, "The reply could not be saved.");
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0);
|
||||
m.clearError();
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0, "closing the banner is not permission to retry the model");
|
||||
await m.send({ type: "text", text: "explicit learner input" });
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(m.getState().error, null);
|
||||
});
|
||||
|
||||
it("a new server-owned turn clears the retained banner without releasing Thinking", () => {
|
||||
const m = model({ initialMessages: answered, initialError: "The reply could not be saved." });
|
||||
assert.equal(m.getState().error?.message, "The reply could not be saved.");
|
||||
assert.equal(m.applyWireEvent({ type: "agent_start" }), false);
|
||||
assert.equal(m.getState().error, null, "positive new-turn evidence replaces the old transient notice");
|
||||
});
|
||||
|
||||
it("a live failed save cannot auto-continue the accepted answer during canonical sync", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
m.applyWireEvent({ type: "error", errorText: "The reply could not be saved." });
|
||||
m.setMessages(answered);
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 0);
|
||||
});
|
||||
|
||||
it("a healthy owed continuation still starts once through the normal send gate", async () => {
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: answered, fetch: dietFetch({ onPost: () => posts++, stream: () => sseResponse("[DONE]") }) });
|
||||
await m.continueIfOwed(answered);
|
||||
assert.equal(posts, 1);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── failed answer submissions (no replay without canonical reconciliation) ──
|
||||
|
||||
describe("answer submission recovery", () => {
|
||||
const question = {
|
||||
...assistantMsg("question", "Q?"),
|
||||
content: [{ type: "toolCall", id: "q1", name: "quiz", arguments: { question: "Q?" } }],
|
||||
} as LhMessage;
|
||||
const answer = buildAnswer({ id: "q1", kind: "quiz", question: "Q?", mode: "single-select", options: [], createdAt: "fixture" }, { dontKnow: true, thinking: "keep my reasoning" });
|
||||
|
||||
it("restores the question after a failed POST and checks the record before another POST", async () => {
|
||||
const calls: string[] = [];
|
||||
let posts = 0;
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url) === "/api/chat") {
|
||||
posts++;
|
||||
return posts === 1 ? new Response("request refused", { status: 500 }) : Response.json({ ok: true });
|
||||
}
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [question], turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.getState().messages[0]!.parts.find(p => p.toolCallId === "q1")!.state, "input-available");
|
||||
assert.equal(m.status, "error");
|
||||
m.clearError(); // dismissing the banner must not disable recovery
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 2, "error must not permanently close the send gate");
|
||||
assert.deepEqual(calls.slice(0, 3), ["/api/chat", "/api/chats/chat_x", "/api/chat"]);
|
||||
});
|
||||
|
||||
it("retains all submitted draft channels until the canonical answer resolves the call", async () => {
|
||||
const form = { selection: { selected: new Set([1]), otherSelected: true, dontKnow: false }, thinking: "reasoning", other: "custom answer", text: "free text", confidenceIndex: 4 };
|
||||
const m = model({ initialMessages: [question], fetch: async () => { throw new TypeError("offline"); } });
|
||||
m.saveQuestionDraft("q1", { form });
|
||||
m.saveQuestionDraft("q1", { readingThinking: "reading reasoning" });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
|
||||
m.setMessages([question]);
|
||||
assert.deepEqual(m.questionDraft("q1"), { form, readingThinking: "reading reasoning" });
|
||||
assert.equal(m.questionDraft("q2"), undefined, "a different question never inherits the draft");
|
||||
m.adoptRecord([question, newAnswerResult("q1", "quiz", answer)]);
|
||||
assert.equal(m.questionDraft("q1"), undefined, "only confirmed native resolution clears it");
|
||||
});
|
||||
|
||||
it("a final 409 restores the question instead of leaving an optimistic answer", async () => {
|
||||
const m = model({ initialMessages: [question], waitMaxMs: 0, fetch: async () => new Response("busy", { status: 409 }) });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.getState().nativeMessages.length, 1);
|
||||
assert.equal(m.status, "error");
|
||||
});
|
||||
|
||||
it("does not repost an answer whose acceptance acknowledgement was lost", async () => {
|
||||
let posts = 0;
|
||||
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
|
||||
return Response.json({ messages: saved, turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(m.status, "ready");
|
||||
assert.deepEqual(m.getState().nativeMessages, saved);
|
||||
});
|
||||
|
||||
it("reattaches to an accepted running turn instead of posting another answer", async () => {
|
||||
const calls: string[] = [];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
calls.push(String(url));
|
||||
if (String(url) === "/api/chat") throw new TypeError("failed fetch");
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [question, newAnswerResult("q1", "quiz", answer)], turnInFlight: true });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x", "/api/chats/chat_x/stream"]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
|
||||
it("keeps the question and makes no further POST while reconciliation is unreachable", async () => {
|
||||
const calls: string[] = [];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => { calls.push(String(url)); throw new TypeError("offline"); } });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.deepEqual(calls, ["/api/chat", "/api/chats/chat_x"]);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
|
||||
});
|
||||
|
||||
it("does not undo an acknowledged dismissal when an unrelated send's record read fails", async () => {
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url).endsWith("/dismiss") || String(url) === "/api/chat") return Response.json({ ok: true });
|
||||
throw new TypeError("record read failed");
|
||||
} });
|
||||
await m.dismissQuestion("q1", dismissedAnswer("quiz", "Q?"));
|
||||
await m.send({ type: "text", text: "move on" });
|
||||
assert.equal(m.status, "error");
|
||||
const part = m.getState().messages.flatMap(m => m.parts).find(p => p.toolCallId === "q1")!;
|
||||
assert.equal(part.state, "output-available");
|
||||
assert.equal((part.output as { dismissed: boolean }).dismissed, true);
|
||||
});
|
||||
|
||||
it("restores an unconfirmed optimistic answer when the post-ack record read fails", async () => {
|
||||
let posts = 0;
|
||||
let reads = 0;
|
||||
const saved = JSON.parse(JSON.stringify([question, newAnswerResult("q1", "quiz", answer)])) as LhMessage[];
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; return Response.json({ ok: true }); }
|
||||
if (++reads === 1) throw new TypeError("record read failed");
|
||||
return Response.json({ messages: saved, turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(m.status, "error");
|
||||
assert.equal(m.getState().nativeMessages.some(m => m.role === "toolResult"), false);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1, "the acknowledged answer must not be reposted");
|
||||
assert.equal(m.status, "ready");
|
||||
assert.deepEqual(m.getState().nativeMessages, saved);
|
||||
});
|
||||
|
||||
for (const next of [{ type: "text", text: "a distinct new message" }, { type: "thinking", problemCallId: "problem1", text: "new practice thinking" }] as const) {
|
||||
it(`preserves the existing POST/409-wait path for a new ${next.type} submission after an error`, async () => {
|
||||
const posted: ChatInput[] = [];
|
||||
const m = model({ pollMs: 0, fetch: async (url, init) => {
|
||||
if (String(url) === "/api/chat") {
|
||||
posted.push(JSON.parse(String(init?.body)).input);
|
||||
if (posted.length === 1) throw new TypeError("failed fetch");
|
||||
if (posted.length === 2) return new Response("busy", { status: 409 });
|
||||
return Response.json({ ok: true });
|
||||
}
|
||||
if (String(url).startsWith("/api/jobs")) return Response.json({ turnInFlight: false });
|
||||
if (String(url).endsWith("/stream")) return sseResponse("[DONE]");
|
||||
return Response.json({ messages: [], turnInFlight: true });
|
||||
} });
|
||||
await m.send({ type: "text", text: "original input" });
|
||||
await m.send(next);
|
||||
assert.deepEqual(posted, [{ type: "text", text: "original input" }, next, next]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
}
|
||||
|
||||
it("adopts a changed pending question instead of sending the stale answer", async () => {
|
||||
let posts = 0;
|
||||
const newer = { ...question, id: "new-question", content: [{ type: "toolCall", id: "q2", name: "quiz", arguments: {} }] } as LhMessage;
|
||||
const m = model({ initialMessages: [question], fetch: async (url) => {
|
||||
if (String(url) === "/api/chat") { posts++; throw new TypeError("failed fetch"); }
|
||||
return Response.json({ messages: [question, newer], turnInFlight: false });
|
||||
} });
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
await m.submitAnswer("quiz", "q1", answer);
|
||||
assert.equal(posts, 1);
|
||||
assert.deepEqual(m.getState().nativeMessages, [question, newer]);
|
||||
assert.equal(m.status, "ready");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── reattach (resumeStream) ────────────────────────────────────────────────
|
||||
|
||||
describe("resumeStream (the reattach path)", () => {
|
||||
|
||||
@@ -11,8 +11,19 @@ import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import { beginTurn, endTurn, isTurnRunning } from "../src/server/turn-liveness.ts";
|
||||
import { subscribe, type ServerEvent } from "../src/core/events.ts";
|
||||
import { rememberTurnFailure, settledTurnResponse } from "../src/server/turn-runs.ts";
|
||||
|
||||
describe("turn liveness registry", () => {
|
||||
it("retains a late-attach failure notice until the next accepted begin", async () => {
|
||||
const id = "chat_failure_notice_unit";
|
||||
rememberTurnFailure(id, "The reply could not be saved.");
|
||||
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./);
|
||||
assert.match(await settledTurnResponse(id).text(), /The reply could not be saved\./, "reading does not consume the notice");
|
||||
assert.equal(beginTurn(id), true);
|
||||
assert.doesNotMatch(await settledTurnResponse(id).text(), /"type":"error"/);
|
||||
endTurn(id);
|
||||
});
|
||||
|
||||
it("begin/end round trip; a second begin is rejected until the turn ends", () => {
|
||||
const id = "chat_liveness_unit";
|
||||
assert.equal(isTurnRunning(id), false, "initially idle");
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
import type { Agent, AgentMessage } from "@earendil-works/pi-agent-core";
|
||||
import type { ChatStore } from "../src/core/ports.ts";
|
||||
import { runPiTurn } from "../src/server/pi/ui-stream.ts";
|
||||
import { persistTurn } from "../src/server/turn-service.ts";
|
||||
|
||||
/** No engine or network: exercise the runner's awaited listener seam. */
|
||||
function agentEndingWith(loopFailure?: Error): Agent {
|
||||
const listeners: Array<(event: never) => unknown> = [];
|
||||
return {
|
||||
state: { messages: [] },
|
||||
subscribe(listener: (event: never) => unknown) { listeners.push(listener); return () => {}; },
|
||||
async continue() {
|
||||
await Promise.resolve(); // let the view subscribe before the terminal
|
||||
if (loopFailure) throw loopFailure;
|
||||
for (const listener of listeners) await listener({ type: "agent_end", messages: [] } as never);
|
||||
},
|
||||
abort() {},
|
||||
} as unknown as Agent;
|
||||
}
|
||||
|
||||
function wireFrames(wire: string): string[] {
|
||||
return wire.split("\n\n").filter(Boolean).map(frame => frame.slice("data: ".length));
|
||||
}
|
||||
|
||||
describe("turn settlement recovery", () => {
|
||||
it("reports a failed canonical save before DONE and cleans up before reporting", async () => {
|
||||
const order: string[] = [];
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(),
|
||||
onFinish() { order.push("save"); throw new Error("synthetic disk failure"); },
|
||||
onSettleFailure() { order.push("cleanup"); },
|
||||
onSettleError() { order.push("report"); return "The reply could not be saved."; },
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.deepEqual(order, ["save", "cleanup", "report"]);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
assert.ok(frames.at(-2)?.includes("The reply could not be saved."));
|
||||
});
|
||||
|
||||
it("still reports settlement failure when cleanup itself throws", async () => {
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(),
|
||||
onFinish() { throw new Error("synthetic save failure"); },
|
||||
onSettleFailure() { throw new Error("synthetic cleanup failure"); },
|
||||
onSettleError: () => "The reply could not be saved.",
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
});
|
||||
|
||||
it("does not replace an already reported loop failure with a second settlement error", async () => {
|
||||
let cleanups = 0;
|
||||
const handle = runPiTurn({
|
||||
agent: agentEndingWith(new Error("synthetic loop failure")),
|
||||
onError: () => "An error occurred.",
|
||||
onFinish() { throw new Error("synthetic save failure"); },
|
||||
onSettleFailure(error, reportedErrorText) {
|
||||
cleanups++;
|
||||
assert.match(String(error), /synthetic save failure/);
|
||||
assert.equal(reportedErrorText, "An error occurred.");
|
||||
},
|
||||
});
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(cleanups, 1);
|
||||
assert.equal(frames.filter(f => f.includes('"type":"error"')).length, 1);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
});
|
||||
|
||||
it("a healthy terminal follows persistence and emits no error", async () => {
|
||||
let saves = 0;
|
||||
const handle = runPiTurn({ agent: agentEndingWith(), onFinish() { saves++; } });
|
||||
const frames = wireFrames(await new Response(handle.subscribe()).text());
|
||||
assert.equal(saves, 1);
|
||||
assert.equal(frames.at(-1), "[DONE]");
|
||||
assert.equal(frames.some(f => f.includes('"type":"error"')), false);
|
||||
});
|
||||
|
||||
it("does not contain a canonical write failure as an empty successful persist", () => {
|
||||
const store = {
|
||||
getChat: () => ({ messages: [] }),
|
||||
saveMessages() { throw new Error("synthetic disk failure"); },
|
||||
} as unknown as ChatStore;
|
||||
const message = { role: "assistant", content: [{ type: "text", text: "Synthetic reply" }], stopReason: "stop", timestamp: 1 } as AgentMessage;
|
||||
assert.throws(() => persistTurn(store, "fixture", 0, [message], false), /The reply could not be saved\./);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user