Persist error/interrupt markers in history; add Retry on failed turns

Engine appends a display-only notice message on error/interrupted; providers never see it.
New retry frame re-runs a failed turn with no new user message, guarded on the error tail.
GUI renders persisted notices on reload and a Retry button on the trailing error.
This commit is contained in:
Rohit C Prasad
2026-07-22 13:45:43 -07:00
committed by Rohit P
parent d8903bc927
commit 878b858ece
12 changed files with 242 additions and 13 deletions
+27
View File
@@ -0,0 +1,27 @@
// Model-layer roadmap item 1 (2026-07-22): a turn that dies on a provider error leaves a
// visible, persistent marker with a Retry affordance. Retry re-runs the failed turn with NO
// new user bubble; once the turn recovers, the button disappears (the notice is history).
import { expect } from "@playwright/test";
import { test } from "./fixtures";
test("provider error shows a retriable notice; Retry re-runs without a new user message", async ({
page,
}) => {
await page.goto("/");
await page.getByText("Draft the launch note").first().click();
const box = page.getByPlaceholder(/Ask the coworker/);
await box.fill("please fail the turn");
await box.press("Enter");
await expect(page.getByText("Error: model unreachable").first()).toBeVisible({ timeout: 10_000 });
const retry = page.getByTestId("notice-retry");
await expect(retry).toBeVisible();
await retry.click();
await expect(page.getByText("Recovered after retry.").first()).toBeVisible({ timeout: 10_000 });
// No fake user bubble from the retry turn, exactly one real one…
await expect(page.locator(".bubble-user")).toHaveCount(1);
// …and the button is gone now that the error notice is no longer the transcript tail.
await expect(page.getByTestId("notice-retry")).toHaveCount(0);
});
+11
View File
@@ -646,6 +646,12 @@ export async function mockApi(page: import("@playwright/test").Page) {
});
return;
}
// A turn that dies on a provider error; the follow-up {type:"retry"} recovers.
if (/fail the turn/i.test(msg.text)) {
send("error", { error: "model unreachable" });
send("turn_done");
return;
}
// A deliberately SLOW multi-second stream (~40 ticks × 120ms) so specs can
// interact mid-turn — the follow/pin scroll contract (FB-004) is untestable
// against the instant echo below.
@@ -697,6 +703,11 @@ export async function mockApi(page: import("@playwright/test").Page) {
}
send("interrupted", {});
send("turn_done");
} else if (msg.type === "retry") {
// Like the real engine: re-runs with NO new user message (turn_start input is empty).
send("turn_start", { input: "" });
send("assistant_message", { text: "Recovered after retry." });
send("turn_done");
}
});
});
+10 -1
View File
@@ -644,7 +644,10 @@ export function App() {
break;
case "error":
flushPartialStream();
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Error: " + (d.error || "unknown") }]);
setItems((p) => [
...p,
{ kind: "notice", tone: "warn", text: "Error: " + (d.error || "unknown"), retriable: true },
]);
break;
case "turn_done":
setRunning(false);
@@ -799,6 +802,11 @@ export function App() {
const prefillComposer = (text: string, attachments?: Attachment[]) =>
setComposerPrefill((p) => ({ text, attachments, nonce: (p?.nonce ?? 0) + 1 }));
const interrupt = () => sessionRef.current?.interrupt();
const retry = () => {
// Optimistic running: turn_start confirms; a rejected retry still ends in turn_done.
setRunning(true);
sessionRef.current?.retry();
};
const changeMode = (m: string) => {
setMode(m);
sessionRef.current?.setMode(m);
@@ -1400,6 +1408,7 @@ export function App() {
items={items}
onApprove={approve}
running={running}
onRetry={retry}
// §33 ref #3: sub-threshold streamed text renders INSIDE the live turn
// group (header when collapsed, quiet line when expanded) — never as a
// floating paragraph.
+6
View File
@@ -1746,6 +1746,12 @@ export class Session {
this.send({ type: "interrupt" });
}
// Re-run a turn that ended in a provider error — no new user message; the server
// guards on the history tail so a stray frame is a no-op.
retry() {
this.send({ type: "retry" });
}
setMode(mode: string) {
this.send({ type: "set_mode", mode });
}
+9 -1
View File
@@ -280,9 +280,12 @@ interface Props {
running?: boolean;
// Sub-threshold streamed text (streamGate mode "quiet") — handed to the live turn group.
streamingText?: string;
// Re-run the failed turn (no new user message). Offered only on a retriable notice that
// is the transcript tail of an idle session — anywhere else the error is history.
onRetry?: () => void;
}
export function Transcript({ items, running, streamingText }: Props) {
export function Transcript({ items, running, streamingText, onRetry }: Props) {
// §33 grouping: a turn = the maximal run of assistant/tool/resolved-approval items between
// breakers (user, connector, notices, plan/dir requests…). Trailing assistant texts are the
// ANSWER and render as bubbles after the group; interior assistant texts are narration and
@@ -396,6 +399,11 @@ export function Transcript({ items, running, streamingText }: Props) {
return (
<div className={"notice " + (item.tone === "warn" ? "warn" : "")} key={bi}>
{item.text}
{item.retriable && !running && onRetry && block.i === items.length - 1 && (
<button className="btn ml-2" data-testid="notice-retry" onClick={onRetry}>
Retry
</button>
)}
</div>
);
default:
@@ -48,3 +48,23 @@ describe("itemsFromMessages timestamps", () => {
]);
});
});
describe("itemsFromMessages notices", () => {
it("replays persisted error/interrupted markers; only errors are retriable", () => {
const items = itemsFromMessages([
{ role: "user", content: "hi" },
{ role: "assistant", content: "partial ans" },
{ role: "notice", kind: "interrupted", ts: 1752969720 },
{ role: "user", content: "again" },
{ role: "notice", kind: "error", text: "model down", ts: 1752969724 },
] as any);
expect(items).toEqual([
{ kind: "user", text: "hi" },
{ kind: "assistant", text: "partial ans" },
{ kind: "notice", tone: "warn", text: "Interrupted." },
{ kind: "user", text: "again" },
{ kind: "notice", tone: "warn", text: "Error: model down", retriable: true },
]);
});
});
+9
View File
@@ -58,6 +58,15 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
...(hidden ? { hidden } : {}),
});
}
} else if (m.role === "notice") {
// Persisted turn-ending marker (engine `_append_notice`): error/interrupted survive
// reload exactly like the live view rendered them. An error notice is retriable —
// the Transcript only offers the button when it's the transcript tail.
items.push(
m.kind === "interrupted"
? { kind: "notice", tone: "warn", text: "Interrupted." }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
);
}
// system messages are omitted; tool-result messages are folded into the tool row above
}
+1 -1
View File
@@ -114,4 +114,4 @@ export type Item =
multi?: boolean;
resolved?: string;
}
| { kind: "notice"; tone: "info" | "warn"; text: string };
| { kind: "notice"; tone: "info" | "warn"; text: string; retriable?: boolean };