fix: preserve failed responses when retrying model calls

Keep failed attempts in the saved transcript and omit only trailing failures from Retry's temporary agent history. Use the matching temporary prefix length when persisting genuinely new output.

Preserve the error notice across plan/compaction markers, refuse Retry after later compaction, and avoid synthesizing results for tool calls inside failed generations while retaining Pi's native filtering.

Verified: 173 focused Retry/session/compaction-projection/practice-record tests, typecheck, changed-file lint, and diff --check. No app launch or real provider calls.
This commit is contained in:
amosblomqvist
2026-10-27 16:49:29 +02:00
parent a572675900
commit 82c6999a63
7 changed files with 559 additions and 200 deletions
+6 -7
View File
@@ -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;
+6 -5
View File
@@ -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;