diff --git a/components/chat.tsx b/components/chat.tsx index af6e585..203a09e 100644 --- a/components/chat.tsx +++ b/components/chat.tsx @@ -460,13 +460,6 @@ 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 // submitted/streaming means chunks are flowing — localStream covers @@ -689,6 +682,12 @@ export function Chat({ // server's registry mutex is the structural gate; this is the // affordance). const [compacting, setCompacting] = useState(initiallyCompacting); + // A failed model response (issue #110): Retry keeps the saved input + // and completed work, without another learner message. Offered from + // "error" too: a dropped stream leaves the model there, and send() + // recovers from it. Later compaction requires new input instead. + const retryable = (status === "ready" || status === "error") && !busy && !compacting && endsInFailedTurn(nativeMessages); + const retryFailedTurn = () => void send({ type: "retry" }); const canSend = (text.trim().length > 0 || dictation.active) && !pending && !activeProblem && !compacting; diff --git a/components/session-core.ts b/components/session-core.ts index 0ac5d3f..c0ef90f 100644 --- a/components/session-core.ts +++ b/components/session-core.ts @@ -19,6 +19,7 @@ import { needsContinuation, danglingQuestionOf, answeredCallIds, + failedResponseAtEnd, measuredContextTokens, newAnswerResult, newNextMessage, @@ -286,12 +287,12 @@ export class SessionModel { ? { executions: this.executions, inFlight: true, streamingIds: this.streamingIds } : undefined; const messages = [...this.base, ...this.tail]; - const latest = messages.at(-1); - // One rule for live and restored failures: only the final completed - // assistant can own the banner. While trying again, hide the old - // failure from the first Thinking frame, but show a new failure at end. + const latest = failedResponseAtEnd(messages); + // Presentation/compaction markers do not resolve a failure. While + // trying again, hide the old banner from the first Thinking frame, + // but show a new completed failure from this consume immediately. const providerError = - latest?.role === "assistant" && latest.stopReason === "error" && + latest !== undefined && !this.incompleteUsageIds.has(latest.id) && (!live || latest.id === this.liveFailureId) ? { message: latest.errorMessage ?? "An error occurred." } : null; diff --git a/src/core/input-events.ts b/src/core/input-events.ts index 24f368a..714d7fb 100644 --- a/src/core/input-events.ts +++ b/src/core/input-events.ts @@ -16,9 +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. + * retry — retry a failed model response (issue #110), without new + * input. Failed attempts stay in the record; only the retry's + * temporary agent history omits the trailing failures. * * (The plan verdict input died 2026-09-26 with the verdict itself — the * learner's post-plan reply is a natural text message.) @@ -218,8 +218,8 @@ 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. + // The input already landed write-ahead. retryDrops affects only + // the temporary agent history — nothing new appends here. return []; case "thinking": { const active = activeProblemOf(record.messages); @@ -244,40 +244,57 @@ 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 failure owning the ephemeral banner. Plan presentation and + * compaction do not resolve an error; substantive later input/output does. + * This is deliberately broader than Retry eligibility below. */ +export function failedResponseAtEnd(messages: readonly LhMessage[]): Extract | undefined { + for (let i = messages.length - 1; i >= 0; i--) { + const m = messages[i]!; + if (m.role === "custom" && (m.customType === "plan-block" || m.customType === "compaction")) continue; + return m.role === "assistant" && m.stopReason === "error" ? m : undefined; + } + return undefined; } -/** 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. */ +/** Does the record end in a failure with input that can be retried? + * Aborts are deliberate stops; compaction after a failure requires new + * learner input rather than a retry using a changed context. */ +export function endsInFailedTurn(messages: readonly LhMessage[]): boolean { + const { ids, before } = failedTail(messages); + return ids.size > 0 && before !== undefined && before.role !== "assistant"; +} + +/** The ids omitted ONLY from the retry's temporary agent history. + * Failed attempts (including partial text and their ids) stay on disk. + * Pi's provider conversion already skips error/aborted responses, but + * continue() rejects an assistant tail BEFORE conversion. Omitting the + * trailing failures lets that continuation start from the saved input or + * completed tool results without replaying tools or resubmitting input. + * Plan blocks may follow the failure and stay; later compaction blocks + * Retry without changing compaction itself. */ 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 (ids.size === 0) { + const failure = failedResponseAtEnd(messages); + if (failure) { + throw new ChatInputError(`${failure.errorMessage ?? "An error occurred."}\n\nThis response cannot be retried after compaction. Send a new message to continue.`); + } + 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. */ +/** Walk back over failed assistants and UI-only plan blocks. A compaction + * is a boundary, not a marker Retry may cross. A failure AFTER a compaction + * can still retry: that compacted context was already its starting state. */ 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; + else if (!(m.role === "custom" && m.customType === "plan-block")) break; } return { ids, before: messages[i] }; } diff --git a/src/core/native-messages.ts b/src/core/native-messages.ts index 63f2720..871e462 100644 --- a/src/core/native-messages.ts +++ b/src/core/native-messages.ts @@ -551,9 +551,10 @@ function resultIdsFrom(messages: readonly AgentMessage[]): Set { * - markers flatten to a user message carrying their content (the * canonical text single-sourced in core/contracts); plan blocks are * UI-only and filtered. - * - dangling tool calls (an aborted turn's unanswered questions) get the - * synthesized interrupted result — the provider protocol's hard - * invariant: a tool call must be followed by its result. + * - dangling calls from successfully generated assistant messages (for + * example, an unanswered question) get a synthesized interrupted result. + * Error/aborted generations do not: Pi drops those assistant messages, + * so manufacturing their results would leave orphan results on the wire. * * Contract (pi-agent-core): never throws — an unknown shape passes * through untouched rather than killing the loop. @@ -619,11 +620,12 @@ export function toLlmMessages(messages: readonly AgentMessage[]): Message[] { // Strip the record id: the wire carries the native shape only. const { id: _id, ...native } = m as LhMessage; out.push(native as Message); - // Dangling-call synthesis: an assistant message whose toolCalls have - // no results in the remaining tail (abort mid-question) — the - // provider rejects unanswered calls outright. + // Repair unanswered calls from completed generations, not calls in + // failed generations. Pi filters error/aborted assistants itself; + // adding results for their unexecuted calls would leave orphans. if (role === "assistant") { const assistant = m as AssistantMessage; + if (assistant.stopReason === "error" || assistant.stopReason === "aborted") continue; const calls = assistant.content.filter((b) => b.type === "toolCall"); if (calls.length === 0) continue; const answered = resultIdsFrom(messages.slice(i + 1)); diff --git a/src/server/chat-handler.ts b/src/server/chat-handler.ts index 2c55f8e..b0eef2a 100644 --- a/src/server/chat-handler.ts +++ b/src/server/chat-handler.ts @@ -187,6 +187,17 @@ export async function handleChatInput( return Response.json({ error: "Unknown chat" }, { status: 404 }); } + // Refuse an ineligible Retry before catalog refresh, compaction, or any + // turn/write effects. Revalidate after the awaited gates below too. + 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 UNIVERSAL TURN GATE (BYOK, owner, 2026-09-22): a turn needs the // CONVERSATION chain — the chat's own model override over the strong // slot — and there is no factory floor; when both are empty the turn @@ -224,18 +235,6 @@ 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 @@ -274,18 +273,17 @@ 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. + // Recompute Retry eligibility on current truth, still before beginTurn. + // A compaction marker or another writer may have landed during awaits. + // These omissions affect ONLY the agent context, never the saved record. + let retryOmissions = new Set(); if (input.type === "retry") { - let drop: Set; try { - drop = retryDrops(record.messages); + retryOmissions = 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 @@ -344,8 +342,13 @@ export async function handleChatInput( // ride this same write-ahead - the pump de-queued them at fire, so this // is the ONLY copy ever appended.) const merged = [...record.messages, ...appends]; + // Retry preserves every persisted attempt. Pi needs a non-assistant + // tail to start continue(), so its temporary history omits the trailing + // failures only. Count that SAME temporary prefix at settlement, or the + // retained failures would make persistTurn skip genuinely new output. + const agentMessages = input.type === "retry" ? merged.filter((m) => !retryOmissions.has(m.id)) : merged; const stagedAutomatic = isSubscriptionProvider(conversation.provider) && input.type === "steers"; - let persistBaseCount = stagedAutomatic ? record.messages.length : merged.length; + let persistBaseCount = stagedAutomatic ? record.messages.length : agentMessages.length; try { if (!stagedAutomatic) stores.chatStore.saveMessages(chatId, merged); } catch (err) { @@ -384,7 +387,7 @@ export async function handleChatInput( // the session is still probing: build the PROBER (quiz/ask + probe_done). // Both share this whole composition (write-ahead persist, liveness, // steers, usage, extraction) — only the agent differs. - const teaching = hasProbeDone(merged); + const teaching = hasProbeDone(agentMessages); // BYOK (owner, 2026-09-22): the conversation chain was resolved by the // turn gate above (chat.model override → strong slot → refused) — the // engine build is pure from here; thinking levels clamp natively. @@ -403,7 +406,7 @@ export async function handleChatInput( // sub-agent JOB tools (research/demo/visual/pdf; adjudicated with the // user: quiz/ask/fs/plan/probe_done are always-on). if (isSubscriptionProvider(conversation.provider)) requestCycle = new SubscriptionRequestCycle({ - initialMessages: asAgentMessages(merged), + initialMessages: asAgentMessages(agentMessages), staged: stagedAutomatic ? asAgentMessages(appends) : undefined, foregroundOwed: !callPurpose.background, purpose: callPurpose, @@ -419,7 +422,7 @@ export async function handleChatInput( engine, callPurpose, requestCycle, - messages: asAgentMessages(merged), + messages: asAgentMessages(agentMessages), onUsage: collectUsage, toolToggles: effective.agent.tools, // Debug (owner, 2026-10-04): teacher/prober raw provider events under @@ -515,7 +518,7 @@ export async function handleChatInput( // Debug recorder run marker (owner, 2026-10-04): correlates the // JSONL event stream with the record's turns; no-op unless // LH_PROVIDER_EVENTS=1. - providerEventNote(chatId, "turn", `run start (base: ${merged.length} messages)`); + providerEventNote(chatId, "turn", `run start (base: ${agentMessages.length} messages)`); const handle = runPiTurn({ agent: constructed, requestCycle, @@ -555,8 +558,8 @@ export async function handleChatInput( settleSteers(chatId, persisted, true, constructed); }, onFinish: (turnMessages, isAborted) => { - // Wave B: the record write is the agent's own final state — - // the new messages are the slice past the merged base. + // Wave B: append only messages past the temporary agent prefix. + // Retry's saved record still includes its earlier failed attempts. const stamped = persistTurn(stores.chatStore, chatId, persistBaseCount, turnMessages, isAborted); noteSteersDurable(chatId, stores.chatStore.getChat(chatId)?.messages ?? stamped); // usage log append (idempotent per turn id) — the stats bar diff --git a/tests/retry-failed-turn.test.ts b/tests/retry-failed-turn.test.ts index dbd7507..38ba054 100644 --- a/tests/retry-failed-turn.test.ts +++ b/tests/retry-failed-turn.test.ts @@ -1,32 +1,47 @@ /** - * 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). + * Failed-response Retry (issue #110). Input is already durable. Retry + * omits the trailing failures from the temporary agent context, never + * deletes them from the transcript, and appends only genuinely new output. + * Scripted faux engines: no real provider calls or learning data. */ import assert from "node:assert/strict"; import * as fs from "node:fs"; +import * as os from "node:os"; import * as path from "node:path"; -import { after, describe, it } from "node:test"; +import { after, describe, it, mock } 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 { transformMessages } from "@earendil-works/pi-ai/api/transform-messages"; +import { Type } from "typebox"; +import type { AgentBuildOptions, TeachingAgent } from "../src/agent/teacher-agent.ts"; import type { PiEngine } from "../src/agent/models.ts"; -import { PATHS } from "../config.ts"; +import type { LhMessage } from "../src/core/native-messages.ts"; +import type { QuestionData } from "../src/core/question-core.ts"; -process.env.OPENROUTER_API_KEY ??= "test-key-presence-gate"; +// Set the storage/auth seams BEFORE any app/config-loading imports. +assert.equal(process.env.NODE_ENV, "test", "Run with NODE_ENV=test; these tests must not invoke real providers."); +const scratch = fs.mkdtempSync(path.join(os.tmpdir(), "lh-retry-failed-turn-")); +process.env.LH_DATA_DIR = path.join(scratch, "data"); +process.env.LH_AUTH_FILE = path.join(scratch, "auth.json"); +process.env.OPENROUTER_API_KEY = "test-key-presence-gate"; +fs.writeFileSync(process.env.LH_AUTH_FILE, "{}"); + +const { SessionModel } = await import("../components/session-core.ts"); +const { createPracticeAgent, createProberAgent, createTeacherAgent } = await import("../src/agent/teacher-agent.ts"); +const { handleChatRequest } = await import("../src/server/chat-handler.ts"); +const { clearTurnFailure, rememberTurnFailure, runningTurn, turnFailure } = await import("../src/server/turn-runs.ts"); +const { isTurnRunning } = await import("../src/server/turn-liveness.ts"); +const { chatStore } = await import("../src/store/chats.ts"); +const { jobStore } = await import("../src/store/jobs.ts"); +const { subscribe } = await import("../src/core/events.ts"); +const { buildAnswer, normalizeOptions, resolveCorrect } = await import("../src/core/question-core.ts"); +const { ChatInputError, endsInFailedTurn, failedResponseAtEnd, inputAppends, parseChatInput, retryDrops } = await import("../src/core/input-events.ts"); +const { INTERRUPTED_TEXT, asAgentMessages, newHandoffMessage, newPlanBlockMessage, newUserMessage, reconstructRequestCause, toLlmMessages } = await import("../src/core/native-messages.ts"); +const { newCompactionMarker } = await import("../src/core/compaction.ts"); + +after(() => { + fs.rmSync(scratch, { recursive: true, force: true }); +}); const QUIZ_INPUT = { question: "Which planet is closest to the sun?", @@ -37,23 +52,20 @@ const QUIZ_INPUT = { 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; +const networkFailure: FauxResponseStep = () => { throw new Error("fetch failed"); }; -/** Scripted engine; each step records the conversation roles it saw - * (the composed system message aside). */ -function scripted(steps: FauxResponseStep[]): { engine: PiEngine; contexts: string[][] } { +/** Record the actual model context after the app's native conversion. */ +function scripted(steps: FauxResponseStep[]) { const contexts: string[][] = []; + const histories: 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 }; + faux.setResponses(steps.map((step) => (context, options, state, model) => { + contexts.push(context.messages.map((m) => m.role).filter((role) => role !== "system")); + histories.push(JSON.stringify(context.messages)); + return typeof step === "function" ? step(context, options, state, model) : step; + })); + const engine: PiEngine = { model: faux.getModel(), streamFn: faux.streamSimple }; + return { engine, contexts, histories }; } function post(chatId: string, input: unknown): Request { @@ -64,10 +76,13 @@ function post(chatId: string, input: unknown): Request { }); } -/** 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 }), +type AgentFactory = (opts: AgentBuildOptions & { teaching: boolean }) => TeachingAgent; + +/** POST one event; after acceptance drain the server-owned turn. */ +async function turn(engine: PiEngine, chatId: string, input: unknown, buildAgent?: AgentFactory): Promise { + const ack = await handleChatRequest( + post(chatId, input), + buildAgent ?? ((opts) => createTeacherAgent(undefined, { ...opts, engine })), ); if (ack.status === 200) { const handle = runningTurn(chatId); @@ -76,20 +91,8 @@ async function turn(engine: PiEngine, chatId: string, input: unknown): Promise { - 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); +function createConfiguredChat(title: string, kind?: "practice") { + const chat = chatStore.createChat(title, kind); 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; @@ -107,134 +110,445 @@ function gradedAnswer() { explanation: QUIZ_INPUT.explanation, createdAt: new Date().toISOString(), }; - return buildAnswer(qd, { values: ["Mercury"] }); + return buildAnswer(qd, { values: ["Mercury"], thinking: "Mercury is the innermost", readingThinking: "I followed the planet ordering", confidence: 80 }); } const roles = (chatId: string) => chatStore.getChat(chatId)!.messages.map((m) => m.role); +const failed = (id: string, text = ""): LhMessage => ({ + ...fauxAssistantMessage(text, { stopReason: "error", errorMessage: "fetch failed" }), id, +}); +const compaction = (firstKeptMessageId: string) => newCompactionMarker("summary", { + firstKeptMessageId, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z", +}); -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([ +function assertPreserved(before: LhMessage[], saved: LhMessage[]): void { + assert.deepEqual(saved.slice(0, before.length), before, "all previous messages, ids and partial content stay intact"); +} + +describe("failed-response Retry (issue #110)", () => { + it("retries from the saved answer, preserving the full input and failed attempt", async () => { + const { engine, contexts, histories } = 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"); + const answer = gradedAnswer(); 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. + await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer }); + const before = chatStore.getChat(chat.id)!.messages; 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); + assert.ok(endsInFailedTurn(before)); + assert.equal((await turn(engine, chat.id, { type: "answer", toolCallId: "call-1", answer })).status, 400); + 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", "assistant", "toolResult", "assistant"]); - assert.equal(endsInFailedTurn(saved), false, "the failure left the record"); + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant", "assistant"]); + assert.equal(endsInFailedTurn(saved), false, "a new successful response supersedes the retained failure"); + const results = saved.filter((m) => m.role === "toolResult" && m.toolCallId === "call-1"); + assert.equal(results.length, 1, "the learner's answer was not submitted twice"); + const result = results[0]!; + assert.ok(result.role === "toolResult"); + assert.deepEqual(result.details, JSON.parse(JSON.stringify(answer))); 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"); + assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]); + assert.match(histories.at(-1)!, /Mercury is the innermost/); }); - it("a failed text turn re-runs from the learner's message", async () => { - const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage([fauxText("Hello again.")])]); + it("a failed text turn retries without adding another learner message", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("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)); - + const before = chatStore.getChat(chat.id)!.messages; + assert.ok(endsInFailedTurn(before)); 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/); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "assistant"]); + assert.deepEqual(contexts.at(-1), ["user"]); + assert.match(JSON.stringify(saved.at(-1)), /Hello again/); + }); + + it("preserves partial text and its usage reference before the replacement succeeds", { timeout: 5_000 }, async () => { + const chat = createConfiguredChat("Test: retained partial reply"); + const before = [newUserMessage("hello"), failed("partial", "A partial explanation already shown.")]; + chatStore.saveMessages(chat.id, before); + const usage = { id: "original-turn", messageId: "partial", at: "2026-01-01T00:00:00Z", model: "faux:fixture", steps: [] }; + chatStore.appendTurnUsage(chat.id, usage); + let release!: () => void; + let started!: () => void; + const waiting = new Promise((resolve) => { release = resolve; }); + const entered = new Promise((resolve) => { started = resolve; }); + const { engine, histories } = scripted([async () => { + started(); + await waiting; + return fauxAssistantMessage("Replacement response."); + }]); + const retry = turn(engine, chat.id, { type: "retry" }); + await entered; + try { + assert.deepEqual(chatStore.getChat(chat.id)!.messages, before, "write-ahead must not delete the failed attempt"); + assert.deepEqual(chatStore.getChat(chat.id)!.turns, [usage]); + assert.doesNotMatch(histories.at(-1)!, /partial explanation/); + } finally { + release(); + await retry; + } + const saved = chatStore.getChat(chat.id)!; + assertPreserved(before, saved.messages); + assert.ok(saved.turns?.some((entry) => entry.id === usage.id && entry.messageId === "partial")); + assert.equal(saved.messages.length, before.length + 1, "the persistence offset counts the temporary prefix, not the full saved record"); + }); + + it("keeps every failed attempt across repeated retries, then appends success once", async () => { + const { engine, contexts } = scripted([networkFailure, networkFailure, () => fauxAssistantMessage("Recovered.")]); + const chat = createConfiguredChat("Test: repeated model failure"); + await turn(engine, chat.id, { type: "text", text: "hello" }); + const first = chatStore.getChat(chat.id)!.messages; + await turn(engine, chat.id, { type: "retry" }); + const second = chatStore.getChat(chat.id)!.messages; + assertPreserved(first, second); + assert.equal(second.length, first.length + 1); + assert.ok(endsInFailedTurn(second)); + await turn(engine, chat.id, { type: "retry" }); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(second, saved); + assert.equal(saved.length, second.length + 1); + assert.deepEqual(contexts, [["user"], ["user"], ["user"]]); + assert.equal(new SessionModel({ chatId: chat.id, initialMessages: saved }).getState().error, null); + }); + + it("preserves a trailing plan block while retrying from the preceding input", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Planned reply.")]); + const chat = createConfiguredChat("Test: retry past a plan block"); + await turn(engine, chat.id, { type: "text", text: "hello" }); + const before = [...chatStore.getChat(chat.id)!.messages, newPlanBlockMessage("plan-1", {})]; + chatStore.saveMessages(chat.id, before); + assert.ok(endsInFailedTurn(before)); + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 200); + const saved = chatStore.getChat(chat.id)!.messages; + assertPreserved(before, saved); + assert.deepEqual(roles(chat.id), ["user", "assistant", "custom", "assistant"]); + assert.deepEqual(contexts.at(-1), ["user"], "the plan block remains UI-only"); + }); + + it("does not reexecute completed tools before a later model failure", async () => { + let executions = 0; + const { engine, contexts } = scripted([ + () => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "note-1" })], { stopReason: "toolUse" }), + networkFailure, + () => fauxAssistantMessage("The saved note is ready."), + ]); + const tools: AgentBuildOptions["tools"] = { + save_note: { + name: "save_note", label: "save_note", description: "Record one fixture side effect.", parameters: Type.Object({}), + execute: async () => { + executions++; + return { content: [{ type: "text", text: "Note saved" }], details: { saved: true } }; + }, + }, + }; + const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { ...opts, engine, tools }); + const chat = createConfiguredChat("Test: no completed tool replay"); + await turn(engine, chat.id, { type: "text", text: "save a note" }, factory); + assert.equal(executions, 1); + const before = chatStore.getChat(chat.id)!.messages; + assert.deepEqual(roles(chat.id), ["user", "assistant", "toolResult", "assistant"]); + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.equal(executions, 1, "Retry requests another response, not execution of stored calls"); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + assert.deepEqual(contexts.at(-1), ["user", "assistant", "toolResult"]); + }); + + it("never executes tool calls carried by a failed partial response", async () => { + let executions = 0; + const { engine, contexts } = scripted([ + () => fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "unexecuted" })], { stopReason: "error", errorMessage: "cut off" }), + () => fauxAssistantMessage("Recovered without running the partial call."), + ]); + const factory: AgentFactory = (opts) => createTeacherAgent(undefined, { + ...opts, engine, + tools: { + save_note: { + name: "save_note", label: "save_note", description: "Must not execute.", parameters: Type.Object({}), + execute: async () => { executions++; return { content: [], details: {} }; }, + }, + }, + }); + const chat = createConfiguredChat("Test: unexecuted partial tool call"); + await turn(engine, chat.id, { type: "text", text: "hello" }, factory); + assert.equal(executions, 0); + const before = chatStore.getChat(chat.id)!.messages; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.equal(executions, 0); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); 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"); + for (const kind of ["prober", "teacher", "practice"] as const) { + it(`uses the same retained-history retry boundary for the ${kind}`, async () => { + const { engine, contexts } = scripted([() => fauxAssistantMessage("Recovered response.")]); + const chat = createConfiguredChat(`Test: ${kind} retry`, kind === "practice" ? "practice" : undefined); + const before: LhMessage[] = [newUserMessage("hello")]; + if (kind === "teacher") { + before.push({ ...fauxAssistantMessage([fauxToolCall("probe_done", {}, { id: "probe-1" })], { stopReason: "toolUse" }), id: "probe-response" }); + before.push({ id: "probe-result", role: "toolResult", toolCallId: "probe-1", toolName: "probe_done", content: [], details: { ok: true }, isError: false, timestamp: 1 }); + } + before.push(failed("failed-response", "Partial reply.")); + chatStore.saveMessages(chat.id, before); + const factory: AgentFactory = (opts) => { + assert.equal(opts.teaching, kind === "teacher"); + assert.ok(opts.messages?.every((m) => m.role !== "assistant" || m.stopReason !== "error")); + if (kind === "practice") return createPracticeAgent({ chatId: chat.id, chatStore }, { ...opts, engine }); + if (kind === "prober") return createProberAgent({ chatId: chat.id, chatStore, jobStore, deliver: () => {}, spawn: () => {} }, { ...opts, engine }); + return createTeacherAgent(undefined, { ...opts, engine }); + }; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + assert.equal(chatStore.getChat(chat.id)!.messages.length, before.length + 1); + assert.deepEqual(contexts.at(-1), kind === "teacher" ? ["user", "assistant", "toolResult"] : ["user"]); + }); + } - 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("treats an explicit Retry of an automatic main-agent response as foreground", async () => { + const { engine } = scripted([() => fauxAssistantMessage("Explicitly requested response.")]); + const chat = createConfiguredChat("Test: foreground manual retry"); + const before = [newUserMessage("hello"), newHandoffMessage(chat.id), failed("automatic-failure")]; + chatStore.saveMessages(chat.id, before); + assert.equal(reconstructRequestCause(before), "background"); + let built = false; + const factory: AgentFactory = (opts) => { + built = true; + assert.equal(opts.callPurpose?.background, false); + return createTeacherAgent(undefined, { ...opts, engine }); + }; + assert.equal((await turn(engine, chat.id, { type: "retry" }, factory)).status, 200); + assert.ok(built); + assertPreserved(before, chatStore.getChat(chat.id)!.messages); + }); +}); + +describe("retained generations and Pi's native provider filtering", () => { + for (const stopReason of ["error", "aborted"] as const) { + it(`does not fabricate results for ${stopReason} partial tool calls on later requests`, () => { + const faux = createFauxCore({}); + const partial: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "partial-call" })], { stopReason }), id: "partial", + }; + const recovered: LhMessage = { ...fauxAssistantMessage("Recovered."), id: "recovered" }; + const messages = [newUserMessage("hello"), partial, recovered, newUserMessage("next question")]; + const before = JSON.parse(JSON.stringify(messages)); + const converted = toLlmMessages(asAgentMessages(messages)); + assert.ok(converted.some((m) => m.role === "assistant" && m.stopReason === stopReason), "the app still leaves failed-generation filtering to Pi"); + assert.equal(converted.filter((m) => m.role === "toolResult").length, 0, "no orphan synthetic result for an unexecuted partial call"); + const providerHistory = transformMessages(converted, faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "user"]); + assert.doesNotMatch(JSON.stringify(providerHistory), /partial-call/); + assert.deepEqual(messages, before, "model conversion never changes the saved attempts"); + }); + } + + it("keeps an actual tool-cancellation result and its completed calling message", () => { + const faux = createFauxCore({}); + const call: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("save_note", {}, { id: "completed-call" })], { stopReason: "toolUse" }), id: "caller", + }; + const cancellation: LhMessage = { + id: "cancelled-result", role: "toolResult", toolCallId: "completed-call", toolName: "save_note", + content: [{ type: "text", text: "Tool execution was aborted." }], isError: true, timestamp: 1, + }; + const messages = [newUserMessage("hello"), call, cancellation, newUserMessage("continue")]; + const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]); + const result = providerHistory.find((m) => m.role === "toolResult"); + assert.ok(result?.role === "toolResult"); + assert.equal(result.toolCallId, "completed-call"); + assert.equal(result.isError, true); + assert.deepEqual(result.content, cancellation.content); }); - 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"); + it("still repairs an unanswered call from a completed generation", () => { + const faux = createFauxCore({}); + const question: LhMessage = { + ...fauxAssistantMessage([fauxToolCall("quiz", QUIZ_INPUT, { id: "unanswered-question" })], { stopReason: "toolUse" }), id: "question", + }; + const messages = [newUserMessage("hello"), question, newUserMessage("move on")]; + const before = JSON.parse(JSON.stringify(messages)); + const providerHistory = transformMessages(toLlmMessages(asAgentMessages(messages)), faux.getModel()); + assert.deepEqual(providerHistory.map((m) => m.role), ["user", "assistant", "toolResult", "user"]); + const result = providerHistory.find((m) => m.role === "toolResult"); + assert.ok(result?.role === "toolResult"); + assert.equal(result.toolCallId, "unanswered-question"); + assert.deepEqual(result.content, [{ type: "text", text: INTERRUPTED_TEXT }]); + assert.deepEqual(messages, before, "the repair is temporary, never persisted"); + }); +}); +describe("Retry refusal and revalidation", () => { + it("rejects an invalid Retry before provider gates, turn claims or writes", async () => { + const chat = chatStore.createChat("Test: retry refused before subscription refresh"); + chatStore.saveSettings(chat.id, { model: { provider: "chatgpt", id: "not-a-fixture-model" } }); + const before = chatStore.getChat(chat.id)!; + rememberTurnFailure(chat.id, "Existing settlement notice"); + const events: string[] = []; + const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); }); + try { + const res = await handleChatRequest(post(chat.id, { type: "retry" }), () => { throw new Error("must not construct an agent"); }); + assert.equal(res.status, 400, "invalid retry wins even with an unavailable subscription model"); + assert.match(await res.text(), /requires a failed turn/); + assert.deepEqual(chatStore.getChat(chat.id), before); + assert.equal(turnFailure(chat.id), "Existing settlement notice", "beginTurn did not clear recovery state"); + assert.equal(isTurnRunning(chat.id), false); + assert.deepEqual(events, []); + } finally { + unsubscribe(); + clearTurnFailure(chat.id); + } + }); + + it("rejects Retry after later compaction without removing the error or its boundary", async () => { + const { engine, contexts } = scripted([]); + const chat = createConfiguredChat("Test: compacted failure"); + const failure = failed("partial", "Partial explanation."); + const marker = compaction(failure.id); + const before = [newUserMessage("hello"), failure, marker]; + chatStore.saveMessages(chat.id, before); + const snapshot = chatStore.getChat(chat.id)!; + const res = await turn(engine, chat.id, { type: "retry" }); + assert.equal(res.status, 400); + const body = await res.json() as { error: string }; + assert.equal(body.error, "fetch failed\n\nThis response cannot be retried after compaction. Send a new message to continue."); + assert.deepEqual(chatStore.getChat(chat.id), snapshot); + assert.equal(contexts.length, 0); + assert.equal(new SessionModel({ chatId: chat.id, initialMessages: before }).getState().error?.message, "fetch failed"); + assert.equal(endsInFailedTurn(before), false); + }); + + for (const later of ["compaction", "learner-input"] as const) { + it(`revalidates against ${later} landing on the post-compaction reread`, async () => { + const { engine, contexts } = scripted([]); + const chat = createConfiguredChat(`Test: retry race with ${later}`); + const before = [newUserMessage("hello"), failed("failed-response")]; + chatStore.saveMessages(chat.id, before); + const appended = later === "compaction" ? compaction("failed-response") : newUserMessage("A newer input"); + const read = chatStore.getChat; + let reads = 0; + // Simulate another writer landing during the awaited gate. Do not + // change or invoke the compaction implementation to exercise Retry. + const reader = mock.method(chatStore, "getChat", (id: string) => { + if (id === chat.id && ++reads === 2) chatStore.saveMessages(id, [...before, appended]); + return read(id); + }); + const events: string[] = []; + const unsubscribe = subscribe((event) => { if ("chatId" in event && event.chatId === chat.id) events.push(event.type); }); + try { + const res = await turn(engine, chat.id, { type: "retry" }); + assert.equal(res.status, 400); + if (later === "compaction") assert.match(await res.text(), /fetch failed.*cannot be retried after compaction/); + assert.deepEqual(read(chat.id)!.messages, [...before, appended]); + assert.equal(contexts.length, 0); + assert.equal(isTurnRunning(chat.id), false); + assert.deepEqual(events, [], "no turn was claimed after final validation refused"); + } finally { + reader.mock.restore(); + unsubscribe(); + } + }); + } + + it("a sibling's successful retry makes a later stale Retry refuse without changing history", async () => { + const { engine, contexts } = scripted([networkFailure, () => fauxAssistantMessage("Recovered.")]); + const chat = createConfiguredChat("Test: stale sibling Retry"); 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"); + await turn(engine, chat.id, { type: "retry" }); + const before = chatStore.getChat(chat.id)!; + assert.equal((await turn(engine, chat.id, { type: "retry" })).status, 400); + assert.deepEqual(chatStore.getChat(chat.id), before); + assert.equal(contexts.length, 2); }); }); describe("the Retry button's client path", () => { + it("keeps the original model error plus the explanation when compaction wins the click race", async () => { + const messages = [newUserMessage("hello"), failed("failed-response")]; + const compacted = [...messages, compaction("failed-response")]; + const explanation = "This response cannot be retried after compaction. Send a new message to continue."; + const refused = `fetch failed\n\n${explanation}`; + const m = new SessionModel({ + chatId: "chat_x", initialMessages: messages, + fetch: async (_url, init) => { + assert.equal(init?.method, "POST"); + assert.deepEqual(JSON.parse(String(init?.body)).input, { type: "retry" }); + m.adoptRecord(compacted); + return Response.json({ error: refused }, { status: 400 }); + }, + }); + await m.send({ type: "retry" }); + assert.equal(m.getState().status, "error"); + assert.equal(JSON.parse(m.getState().error!.message).error, refused, "the existing refusal-envelope path carries both messages"); + assert.deepEqual(m.getState().nativeMessages, compacted); + assert.equal(endsInFailedTurn(m.getState().nativeMessages), false); + }); + 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 failedTurn = [newUserMessage("hello"), failed("a1")]; 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 + return new Response("{}", { status: 500 }); }) 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"); + assert.equal(m.getState().status, "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" }); + assert.deepEqual(m.getState().nativeMessages, failedTurn, "there is no optimistic learner message or deletion"); }); }); -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" }); +describe("Retry eligibility and temporary omissions (pure)", () => { + it("parses Retry without appending input", () => { + const input = parseChatInput({ type: "retry" }); + assert.deepEqual(input, { type: "retry" }); + assert.deepEqual(inputAppends({ messages: [newUserMessage("hi"), failed("a1")] }, input), []); }); - 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("omits consecutive failures back to their input without mutating the record", () => { + const messages = [newUserMessage("hi"), failed("a1"), failed("a2")]; + const before = JSON.parse(JSON.stringify(messages)); + assert.deepEqual([...retryDrops(messages)].sort(), ["a1", "a2"]); + assert.deepEqual(messages, before); }); - it("looks past trailing UI-only markers, and keeps them", () => { - const user = { ...newUserMessage("hi"), id: "u1" } as LhMessage; + it("looks past plan blocks, but compaction after the latest failure blocks Retry", () => { + const user = newUserMessage("hi"); + const failure = failed("a1"); 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"); + const marker = compaction(user.id); + assert.deepEqual([...retryDrops([user, failure, block])], ["a1"]); + assert.equal(endsInFailedTurn([user, block]), false); + for (const messages of [[user, failure, marker], [user, failure, marker, block], [user, failure, block, marker]]) { + assert.equal(endsInFailedTurn(messages), false); + assert.equal(failedResponseAtEnd(messages)?.id, failure.id, "only the Retry button disappears, not the banner"); + assert.throws(() => retryDrops(messages), /fetch failed[\s\S]*cannot be retried after compaction/); + } + assert.deepEqual([...retryDrops([user, failed("old"), marker, failed("new")])], ["new"], "a failure after compaction can retry its already-compacted starting state"); }); - 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); + it("excludes aborts, healthy responses, missing input and assistant-only predecessors", () => { + const user = newUserMessage("hi"); + const aborted = { ...failed("aborted"), stopReason: "aborted" } as LhMessage; + const healthy = { ...fauxAssistantMessage("Done"), id: "healthy" } as LhMessage; + for (const messages of [[user], [failed("a1")], [user, aborted], [user, healthy], [healthy, failed("a1")]]) { + assert.equal(endsInFailedTurn(messages), false); + assert.throws(() => retryDrops(messages), ChatInputError); + } + assert.equal(failedResponseAtEnd([user, failed("old"), aborted]), undefined); }); }); diff --git a/tests/session-core.test.ts b/tests/session-core.test.ts index 27c3110..0e98e80 100644 --- a/tests/session-core.test.ts +++ b/tests/session-core.test.ts @@ -25,7 +25,7 @@ import { absorbed, pendingCallTool, SessionModel } from "../components/session-c import type { ChatInput } from "../src/core/input-events.ts"; import type { LhMessage } from "../src/core/native-messages.ts"; import { newUserMessage, newAnswerResult, newThinkingMessage, needsContinuation, endsInDismissal } from "../src/core/native-messages.ts"; -import { newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts"; +import { endsInFailedTurn, newCompactionMarker, newPlanBlockMessage, projectView } from "../src/core/index.ts"; import { dismissedAnswer } from "../src/core/question-core.ts"; import { buildAnswer, type QuestionData } from "../src/core/question-core.ts"; @@ -483,7 +483,7 @@ it("Wave C: markers injected mid-run (custom role, follow-up delivery) append to }); }); -// ─── provider failures: one final-entry rule for live and saved records ────── +// ─── provider failures: one marker-aware rule for live and saved records ──── describe("provider-error banner (live and saved)", () => { function failed(id = "failed", errorMessage: string | undefined = "Google 500: fixture failure"): LhMessage { @@ -512,7 +512,7 @@ describe("provider-error banner (live and saved)", () => { assert.equal(model().getState().error, null); }); - it("any later entry supersedes the failure, including plan/compaction markers and aborts", () => { + it("substantive later input/output supersedes the failure, including aborts", () => { const failure = failed(); const later: LhMessage[] = [ newUserMessage("retry"), @@ -520,8 +520,6 @@ describe("provider-error banner (live and saved)", () => { { ...assistantMsg("aborted", ""), stopReason: "aborted", errorMessage: "Aborted" } as LhMessage, newThinkingMessage("problem", "my attempt"), resultMsg("call"), - newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }), - newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }), ]; for (const entry of later) { const m = model({ initialMessages: [failure] }); @@ -533,6 +531,31 @@ describe("provider-error banner (live and saved)", () => { } }); + it("plan and compaction markers preserve the banner on sync, adoption and reopen", async () => { + const failure = failed(); + const record = [newUserMessage("topic"), failure]; + const markers = [ + newPlanBlockMessage("plan", { kind: "full", nodes: [], edges: [], current: null }), + newCompactionMarker("summary", { firstKeptMessageId: failure.id, tokensBefore: 100, model: "fixture", at: "2026-01-01T00:00:00Z" }), + ]; + for (const marker of markers) { + const saved = [...record, marker]; + let posts = 0; + const m = model({ initialMessages: record, fetch: async () => { posts++; throw new Error("unexpected request"); } }); + m.setMessages(saved); + assert.equal(m.getState().error?.message, failure.role === "assistant" ? failure.errorMessage : undefined); + assert.equal(model({ initialMessages: saved }).getState().error?.message, m.getState().error?.message); + const adopted = model({ initialMessages: record }); + adopted.adoptRecord(saved); + assert.equal(adopted.getState().error?.message, m.getState().error?.message); + assert.equal(endsInFailedTurn(saved), marker.customType === "plan-block", "later compaction removes Retry eligibility, not the error notice"); + await m.continueIfOwed(saved); + assert.equal(posts, 0, "restoring the banner is never an automatic retry"); + m.setMessages([...saved, newUserMessage("continue from here")]); + assert.equal(m.getState().error, null, "new learner input supersedes the notice"); + } + }); + it("a newer live message supersedes the failure before its response finishes", () => { const m = model(); m.applyWireEvent({ type: "message_end", message: failed() }); @@ -554,7 +577,7 @@ describe("provider-error banner (live and saved)", () => { assert.equal(m.getState().error?.message, "Google 500: fixture failure"); }); - for (const input of [{ type: "text", text: "retry" }, { type: "continue" }] as const) { + for (const input of [{ type: "text", text: "retry" }, { type: "continue" }, { type: "retry" }] as const) { it(`hides the old failure during the optimistic ${input.type} wait, then shows a new live failure`, async () => { let acknowledge!: (response: Response) => void; const ack = new Promise((resolve) => { acknowledge = resolve; });