fix: recover session submissions and failed turn settlement

This commit is contained in:
amosblomqvist
2026-10-25 19:45:17 +02:00
parent b277ee3e0f
commit 7d65fa8754
21 changed files with 944 additions and 94 deletions
+102 -2
View File
@@ -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);