diff --git a/components/chat.tsx b/components/chat.tsx
index f8866f1..af6e585 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -65,9 +65,11 @@ import {
type CompactionDividerData,
} from "@/components/compaction-divider";
import { CoverageRing } from "@/components/extraction-coverage";
+import { Button } from "@/components/ui/button";
import {
activeProblemOf,
dismissedAnswer,
+ endsInFailedTurn,
findPendingToolCall,
phaseAt3,
phaseTimeline,
@@ -458,6 +460,12 @@ export function Chat({
const busy = localStream || turnInFlight;
const busyRef = useRef(busy);
busyRef.current = busy;
+ // A failed model call (issue #110): the banner offers Retry, which
+ // re-runs the turn from the saved input — the only path back, since an
+ // answered question cannot be answered again. Offered from "error" too:
+ // a dropped stream leaves the model there, and send() recovers from it.
+ const retryable = (status === "ready" || status === "error") && !busy && endsInFailedTurn(nativeMessages);
+ const retryFailedTurn = () => void send({ type: "retry" });
// The attach handoff (stream-reattach wave): the moment a resumed
// stream takes over the rendering — the SDK left "ready", so
@@ -1577,8 +1585,13 @@ export function Chat({
)}
{error && (
-
- Something went wrong: {error.message}
+
+ Something went wrong: {error.message}
+ {retryable && (
+
+ Retry
+
+ )}
)}
diff --git a/src/core/input-events.ts b/src/core/input-events.ts
index 42d6985..24f368a 100644
--- a/src/core/input-events.ts
+++ b/src/core/input-events.ts
@@ -16,6 +16,9 @@
* continue — run the next turn on the current record (mount
* continuations, job-settle re-syncs: the record ends at an
* answered question with no teacher reaction yet).
+ * retry — re-run a turn whose model call failed (issue #110): the
+ * trailing failed assistant message(s) leave the record and
+ * the turn re-runs from the input that preceded them.
*
* (The plan verdict input died 2026-09-26 with the verdict itself — the
* learner's post-plan reply is a natural text message.)
@@ -45,6 +48,8 @@ export type ChatInput =
| { type: "text"; text: string }
| { type: "answer"; toolCallId: string; answer: QuestionAnswerData }
| { type: "continue" }
+ /** The failed-turn retry (issue #110): the banner's Retry button. */
+ | { type: "retry" }
/** The practice dock's two buttons (owner, 2026-10-04): the learner's
* thinking on the ACTIVE problem — one lane (owner 2026-10-05):
* whether it claims an answer is read from the text. One
@@ -170,6 +175,8 @@ export function parseChatInput(input: unknown): ChatInput {
return { type: "answer", toolCallId: i.toolCallId, answer: i.answer as QuestionAnswerData };
case "continue":
return { type: "continue" };
+ case "retry":
+ return { type: "retry" };
case "thinking":
if (typeof i.problemCallId !== "string" || i.problemCallId.length === 0) {
throw new ChatInputError("thinking input requires problemCallId");
@@ -210,6 +217,10 @@ export function inputAppends(
}
case "continue":
return dismissalConclusion(record);
+ case "retry":
+ // The failed turn's own input already landed write-ahead; the
+ // handler trims the failure (retryDrops) — nothing new appends.
+ return [];
case "thinking": {
const active = activeProblemOf(record.messages);
if (!active || active.toolCallId !== input.problemCallId) {
@@ -233,6 +244,44 @@ export function inputAppends(
}
}
+/** Does the record end in a failed model call (a retryable turn)? */
+export function endsInFailedTurn(messages: readonly LhMessage[]): boolean {
+ return failedTail(messages).ids.size > 0;
+}
+
+/** The ids a retry drops (issue #110, pure): the failed assistant
+ * message(s) ending the record. A provider failure persists as an
+ * assistant message with stopReason "error", and pi refuses to continue
+ * from an assistant — so neither re-answering (the question is no longer
+ * dangling) nor `continue` can resume the turn. Ids, not a cut index:
+ * UI-only markers may follow the failure, and they stay. Honest 400s
+ * when there is no failure to retry, or when dropping it leaves nothing
+ * to continue from. */
+export function retryDrops(messages: readonly LhMessage[]): Set {
+ const { ids, before } = failedTail(messages);
+ if (ids.size === 0) throw new ChatInputError("retry input requires a failed turn to retry");
+ if (!before || before.role === "assistant") throw new ChatInputError("retry input leaves no input to continue from");
+ return ids;
+}
+
+/** Markers that can land AFTER a failed call and are never model input:
+ * persistTurn's plan block (plan_init succeeded, the next call failed)
+ * and the settle kick's compaction marker. */
+const TRAILING_MARKERS: readonly string[] = ["plan-block", "compaction"];
+
+/** Walk back from the end over failed assistants and trailing markers:
+ * the failures' ids, and the message the run stops at. */
+function failedTail(messages: readonly LhMessage[]): { ids: Set; before: LhMessage | undefined } {
+ const ids = new Set();
+ let i = messages.length - 1;
+ for (; i >= 0; i--) {
+ const m = messages[i]!;
+ if (m.role === "assistant" && m.stopReason === "error") ids.add(m.id);
+ else if (!(m.role === "custom" && TRAILING_MARKERS.includes(m.customType))) break;
+ }
+ return { ids, before: messages[i] };
+}
+
/** The deferred conclude synthesis (owner ruling, 2026-10-04): a
* problem-dismissal marker waits for the user's next input — the pair
* (synthetic conclude call + ack) rides ahead of it, so the agent reads
diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts
index 8415688..2c55f8e 100644
--- a/src/server/chat-handler.ts
+++ b/src/server/chat-handler.ts
@@ -55,6 +55,7 @@ import {
ChatInputError,
inputAppends,
parseChatInput,
+ retryDrops,
type TurnInput,
} from "../core/input-events.ts";
import { steerDelivered } from "../core/contracts.ts";
@@ -223,6 +224,18 @@ export async function handleChatInput(
return Response.json(envelope, { status: 503 });
}
+ // THE RETRY GATE (issue #110): a retry with no failure to retry
+ // refuses here, BEFORE compaction and beginTurn — no effects. The
+ // trim itself re-validates on the record compaction re-reads below.
+ if (input.type === "retry") {
+ try {
+ retryDrops(record.messages);
+ } catch (err) {
+ if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
+ throw err;
+ }
+ }
+
// THE COMPACTION GATE (owner round 14, 2026-10-05 — the structural
// ruling: NOTHING model-facing for this chat interleaves with a
// running compaction): await any in-flight compaction (the settle
@@ -261,6 +274,19 @@ export async function handleChatInput(
const compactedRecord = stores.chatStore.getChat(chatId);
if (!compactedRecord) return Response.json({ error: "Unknown chat" }, { status: 404 });
record = compactedRecord;
+ // THE RETRY TRIM: recomputed on the current record (the awaits above
+ // let other writers land), still before beginTurn. The trimmed record
+ // reaches disk through the write-ahead.
+ if (input.type === "retry") {
+ let drop: Set;
+ try {
+ drop = retryDrops(record.messages);
+ } catch (err) {
+ if (err instanceof ChatInputError) return Response.json({ error: err.message }, { status: 400 });
+ throw err;
+ }
+ record = { ...record, messages: record.messages.filter((m) => !drop.has(m.id)) };
+ }
// The input's native appends (pure; the honest 400 for an answer with no
// pending question call). Computed BEFORE beginTurn so a refused input
diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts
new file mode 100644
index 0000000..dbd7507
--- /dev/null
+++ b/tests/retry-failed-turn.test.ts
@@ -0,0 +1,240 @@
+/**
+ * The failed-turn retry (issue #110): a model call that fails persists as an
+ * assistant message with stopReason "error". The learner's input survives
+ * (write-ahead), but pre-fix nothing could re-run the turn — re-answering
+ * 400'd (the question is no longer dangling) and `continue` died in pi
+ * ("Cannot continue from message role: assistant"). The `retry` input drops
+ * the failure and re-runs the turn from the input that preceded it.
+ *
+ * Drives handleChatRequest with a scripted pi engine (the chat-flow pattern).
+ */
+
+import assert from "node:assert/strict";
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { after, describe, it } from "node:test";
+import { createFauxCore, fauxAssistantMessage, fauxText, fauxToolCall, type FauxResponseStep } from "@earendil-works/pi-ai";
+import { SessionModel } from "../components/session-core.ts";
+import { createTeacherAgent } from "../src/agent/teacher-agent.ts";
+import { handleChatRequest } from "../src/server/chat-handler.ts";
+import { runningTurn } from "../src/server/turn-runs.ts";
+import { chatStore } from "../src/store/chats.ts";
+import { buildAnswer, normalizeOptions, resolveCorrect, type QuestionData } from "../src/core/question-core.ts";
+import { ChatInputError, endsInFailedTurn, parseChatInput, retryDrops } from "../src/core/input-events.ts";
+import { newPlanBlockMessage, newUserMessage, type LhMessage } from "../src/core/native-messages.ts";
+import { newCompactionMarker } from "../src/core/compaction.ts";
+import type { PiEngine } from "../src/agent/models.ts";
+import { PATHS } from "../config.ts";
+
+process.env.OPENROUTER_API_KEY ??= "test-key-presence-gate";
+
+const QUIZ_INPUT = {
+ question: "Which planet is closest to the sun?",
+ options: [{ label: "Venus" }, { label: "Mercury" }],
+ correctAnswer: "Mercury",
+ explanation: "Mercury is the innermost planet.",
+ type: "check",
+ nodeId: "planet-order",
+};
+
+/** A response step that fails the way a dropped connection does. */
+const networkFailure = (() => {
+ throw new Error("fetch failed");
+}) as unknown as FauxResponseStep;
+
+/** Scripted engine; each step records the conversation roles it saw
+ * (the composed system message aside). */
+function scripted(steps: FauxResponseStep[]): { engine: PiEngine; contexts: string[][] } {
+ const contexts: string[][] = [];
+ const faux = createFauxCore({});
+ faux.setResponses(
+ steps.map((step) => ((context: { messages: Array<{ role: string }> }, ...rest: unknown[]) => {
+ contexts.push(context.messages.map((m) => m.role).filter((r) => r !== "system"));
+ return (step as (...a: unknown[]) => unknown)(context, ...rest);
+ }) as unknown as FauxResponseStep),
+ );
+ return { engine: { model: faux.getModel(), streamFn: faux.streamSimple }, contexts };
+}
+
+function post(chatId: string, input: unknown): Request {
+ return new Request("http://test/api/chat", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ chatId, input }),
+ });
+}
+
+/** POST one input event; on a 200 ack, drain the turn to completion. */
+async function turn(engine: PiEngine, chatId: string, input: unknown): Promise {
+ const ack = await handleChatRequest(post(chatId, input), (opts) =>
+ createTeacherAgent(undefined, { engine, messages: opts.messages }),
+ );
+ if (ack.status === 200) {
+ const handle = runningTurn(chatId);
+ if (handle) await new Response(handle.subscribe()).text();
+ }
+ return ack;
+}
+
+const createdChats: string[] = [];
+after(() => {
+ for (const id of createdChats) {
+ try {
+ fs.unlinkSync(path.join(PATHS.chats, `${id}.json`));
+ } catch {
+ /* already gone */
+ }
+ }
+});
+
+function createConfiguredChat(title: string) {
+ const chat = chatStore.createChat(title);
+ createdChats.push(chat.id);
+ chatStore.saveSettings(chat.id, { model: { provider: "openrouter", id: "z-ai/glm-5.3-flash" } });
+ chatStore.saveTree(chat.id, { nodes: [{ id: "planet-order", title: "Planet order" }], edges: [], version: 1 });
+ return chat;
+}
+
+function gradedAnswer() {
+ const options = normalizeOptions(QUIZ_INPUT.options);
+ const qd: QuestionData = {
+ id: "call-1",
+ kind: "quiz",
+ question: QUIZ_INPUT.question,
+ mode: "single-select",
+ options,
+ correctIndices: resolveCorrect(QUIZ_INPUT.correctAnswer, options).indices,
+ explanation: QUIZ_INPUT.explanation,
+ createdAt: new Date().toISOString(),
+ };
+ return buildAnswer(qd, { values: ["Mercury"] });
+}
+
+const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role);
+
+describe("failed-turn retry (issue #110)", () => {
+ it("a failed answer turn re-runs from the saved answer — no re-answering needed", async () => {
+ const { engine, contexts } = scripted([
+ () => fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "call-1" })], { stopReason: "toolUse" }),
+ networkFailure,
+ () => fauxAssistantMessage([fauxText("Right — Mercury it is.")]),
+ ]);
+ const chat = createConfiguredChat("Test: retry after failed answer");
+
+ await turn(engine, chat.id, { type: "text", text: "teach me the planets" });
+ await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() });
+
+ // The failure as #110 met it: answer durable, turn failed, and the
+ // pre-fix paths refused.
+ assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]);
+ assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages));
+ const reanswer = await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer: gradedAnswer() });
+ assert.equal(reanswer.status, 400, "the answered question cannot be answered again");
+
+ const retry = await turn(engine, chat.id, { type: "retry" });
+ assert.equal(retry.status, 200);
+
+ const saved = chatStore.getChat(chat.id)!.messages;
+ assert.deepEqual(saved.map((m) => m.role), ["user", "assistant", "toolResult", "assistant"]);
+ assert.equal(endsInFailedTurn(saved), false, "the failure left the record");
+ assert.match(JSON.stringify(saved.at(-1)), /Mercury it is/);
+ assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"], "the retried call saw the answer, not the failure");
+ });
+
+ it("a failed text turn re-runs from the learner's message", async () => {
+ const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Hello again.")])]);
+ const chat = createConfiguredChat("Test: retry after failed text");
+
+ await turn(engine, chat.id, { type: "text", text: "hello" });
+ assert.deepEqual(roles(chat.id), ["user", "assistant"]);
+ assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages));
+
+ assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
+ assert.deepEqual(roles(chat.id), ["user", "assistant"]);
+ assert.match(JSON.stringify(chatStore.getChat(chat.id)!.messages.at(-1)), /Hello again/);
+ assert.deepEqual(contexts.at(-1), ["user"]);
+ });
+
+ it("refuses a retry with nothing failed — 400, record untouched", async () => {
+ const { engine } = scripted([() => fauxAssistantMessage([fauxText("Fine.")])]);
+ const chat = createConfiguredChat("Test: retry refused");
+
+ await turn(engine, chat.id, { type: "text", text: "hello" });
+ const before = chatStore.getChat(chat.id)!.messages;
+ const res = await turn(engine, chat.id, { type: "retry" });
+ assert.equal(res.status, 400);
+ assert.match(await res.text(), /requires a failed turn/);
+ assert.deepEqual(chatStore.getChat(chat.id)!.messages, before);
+ });
+
+ it("a plan block persisted after the failure stays, and the turn still retries", async () => {
+ const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Planned reply.")])]);
+ const chat = createConfiguredChat("Test: retry past a plan block");
+
+ await turn(engine, chat.id, { type: "text", text: "hello" });
+ // persistTurn's shape when plan_init succeeded and the next call
+ // failed: the plan block lands AFTER the failure.
+ const failedRecord = chatStore.getChat(chat.id)!.messages;
+ chatStore.saveMessages(chat.id, [...failedRecord, newPlanBlockMessage("plan-1", {})]);
+ assert.ok(endsInFailedTurn(chatStore.getChat(chat.id)!.messages), "the failure is still found past the marker");
+
+ assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200);
+ const saved = chatStore.getChat(chat.id)!.messages;
+ assert.deepEqual(saved.map((m) => m.role), ["user", "custom", "assistant"], "failure dropped, plan block kept");
+ assert.match(JSON.stringify(saved.at(-1)), /Planned reply/);
+ assert.deepEqual(contexts.at(-1), ["user"], "the plan block is not model input");
+ });
+});
+
+describe("the Retry button's client path", () => {
+ it("after a dropped request, Retry posts straight from the error status", async () => {
+ const failedTurn = [
+ { ...newUserMessage("hello"), id: "u1" },
+ { ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "fetch failed" }), id: "a1" },
+ ] as unknown as LhMessage[];
+ const posted: string[] = [];
+ let online = false;
+ const fetch = (async (_url: string, init?: RequestInit) => {
+ if (!online) throw new TypeError("Failed to fetch");
+ if (init?.method === "POST") posted.push(String(init.body));
+ return new Response("{}", { status: 500 }); // the reattach is not under test
+ }) as typeof globalThis.fetch;
+ const m = new SessionModel({ chatId: "chat_x", initialMessages: failedTurn, fetch });
+
+ await m.send({ type: "retry" });
+ assert.equal(m.getState().status, "error", "the dropped request leaves the model in error");
+ online = true;
+ await m.send({ type: "retry" });
+ assert.equal(posted.length, 1, "send() recovers from error without stop()");
+ assert.deepEqual(JSON.parse(posted[0]!).input, { type: "retry" });
+ });
+});
+
+describe("retryDrops (pure)", () => {
+ const failed = (id: string): LhMessage =>
+ ({ ...fauxAssistantMessage("", { stopReason: "error", errorMessage: "boom" }), id }) as unknown as LhMessage;
+
+ it("parses the retry input event", () => {
+ assert.deepEqual(parseChatInput({ type: "retry" }), { type: "retry" });
+ });
+
+ it("drops every trailing failure back to the input", () => {
+ const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage;
+ assert.deepEqual([...retryDrops([user, failed("a1"), failed("a2")])].sort(), ["a1", "a2"]);
+ });
+
+ it("looks past trailing UI-only markers, and keeps them", () => {
+ const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage;
+ const block = newPlanBlockMessage("plan-1", {});
+ assert.deepEqual([...retryDrops([user, failed("a1"), block])], ["a1"]);
+ assert.equal(endsInFailedTurn([user, block]), false, "a marker alone is no failure");
+ const compaction = newCompactionMarker("summary", { firstKeptMessageId: "u1", tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" });
+ assert.deepEqual([...retryDrops([user, failed("a1"), compaction])], ["a1"], "the settle kick's marker too");
+ });
+
+ it("throws when the record does not end in a failure, or nothing precedes it", () => {
+ const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage;
+ assert.throws(() => retryDrops([user]), ChatInputError);
+ assert.throws(() => retryDrops([failed("a1")]), ChatInputError);
+ });
+});