diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index cb814dace2e..0f4b60c90f8 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -18,6 +18,7 @@ import { buildThreadTurnInterruptInput, createLocalDispatchSnapshot, deriveComposerSendState, + deriveTurnFailureRecoveryAction, dismissBranchMismatchForSession, ENVIRONMENT_RECONNECT_WARNING_GRACE_MS, getStartedThreadModelChangeBlockReason, @@ -727,6 +728,137 @@ describe("startNewThreadForProject", () => { }); }); +describe("deriveTurnFailureRecoveryAction", () => { + const base = { + hasPendingSnapshot: true, + preSendTurnId: TurnId.make("turn-1"), + preSendLatestTurnCompletedAt: null, + preSendSessionUpdatedAt: "2026-01-01T00:00:00.000Z", + preSendSessionStatus: "running" as const, + sessionStatus: "running" as const, + sessionUpdatedAt: "2026-01-01T00:00:01.000Z", + latestTurnId: TurnId.make("turn-2"), + latestTurnCompletedAt: null, + composerHasContent: false, + }; + + it("waits while there is no pending snapshot", () => { + expect(deriveTurnFailureRecoveryAction({ ...base, hasPendingSnapshot: false })).toBe("wait"); + }); + + it("restores when the turn fails asynchronously and the composer is empty", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "error", + sessionUpdatedAt: "2026-01-01T00:00:05.000Z", + }), + ).toBe("restore"); + }); + + it("drops without restoring when the user already retyped", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "error", + sessionUpdatedAt: "2026-01-01T00:00:05.000Z", + composerHasContent: true, + }), + ).toBe("drop"); + }); + + it("waits on a stale pre-send error that has not advanced since the send", () => { + // The session was already in "error" when the user sent (lastError is + // carried forward until "ready"); with neither the session timestamp nor + // the turn id advancing, this must not spuriously restore before the new + // turn has begun. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-1"), + latestTurnId: TurnId.make("turn-1"), + preSendSessionStatus: "error", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("wait"); + }); + + it("restores an accept-then-fail landing in the same millisecond via a new turn id", () => { + // The failing session.set can share the pre-send millisecond timestamp; a + // freshly advanced turn id is enough to know the failure is this send's. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-1"), + latestTurnId: TurnId.make("turn-2"), + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("restore"); + }); + + it("drops the snapshot once a fresh turn completes cleanly", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + sessionStatus: "idle", + latestTurnCompletedAt: "2026-01-01T00:00:09.000Z", + }), + ).toBe("drop"); + }); + + it("restores a steered accept-then-fail that reuses the pre-send turn id and millisecond", () => { + // Steering folds the message into the running turn, so the turn id does not + // change; a same-millisecond failure also leaves `sessionUpdatedAt` equal to + // the pre-send value. The status crossing from a non-error pre-send into + // "error" is what proves this send's turn failed, so the snapshot restores. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendSessionStatus: "running", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + }), + ).toBe("restore"); + }); + + it("drops a steered same-ms failure without clobbering text the user retyped", () => { + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendSessionStatus: "running", + sessionStatus: "error", + sessionUpdatedAt: base.preSendSessionUpdatedAt, + composerHasContent: true, + }), + ).toBe("drop"); + }); + + it("drops the snapshot after a steered turn completes under the same turn id", () => { + // Steering folds the message into the running turn, so the turn id does not + // change; the moved completion marker is what signals a clean finish. + expect( + deriveTurnFailureRecoveryAction({ + ...base, + preSendTurnId: TurnId.make("turn-2"), + latestTurnId: TurnId.make("turn-2"), + preSendLatestTurnCompletedAt: null, + latestTurnCompletedAt: "2026-01-01T00:00:09.000Z", + sessionStatus: "idle", + }), + ).toBe("drop"); + }); + + it("keeps waiting while the accepted turn is still running", () => { + expect(deriveTurnFailureRecoveryAction(base)).toBe("wait"); + }); +}); + describe("hasServerAcknowledgedLocalDispatch", () => { it("does not acknowledge unchanged server state", () => { const localDispatch = createLocalDispatchSnapshot( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 83bea23b65e..f7f20039479 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -612,6 +612,74 @@ export function createLocalDispatchSnapshot( }; } +// A message that was submitted and cleared from the composer must never be +// lost when its turn fails. The synchronous send-failure path (a rejected +// start-turn RPC) restores the composer inline, but a turn that the server +// ACCEPTS and then fails asynchronously — a runtime stream error, a stale +// pending provider callback, a `runtime.error` — surfaces only later via +// `session.lastError`/`session.status`, long after `onSend` returned. This +// decides, from the in-flight snapshot captured at send time, whether that +// later failure should push the text back into an (empty) composer. +// +// The failure signal is `session.status === "error"`, not `lastError`: +// `lastError` is carried forward across a fresh turn until the session next +// reaches "ready", so keying on it would restore a stale prior-turn error. +// Freshness (that the error is this send's, not a prior one still showing) is +// established by any of: a changed `sessionUpdatedAt`, a changed `latestTurnId`, +// or the pre-send session status not yet being "error". The last covers a +// steered send, which keeps the running turn's id and can reuse the pre-send +// millisecond, so neither of the first two advances on an accept-then-fail. A +// session that was already "error" when the user sent (and has not transitioned) +// is excluded from all three, so it cannot trigger a spurious restore. +export function deriveTurnFailureRecoveryAction(input: { + hasPendingSnapshot: boolean; + preSendTurnId: TurnId | null; + preSendLatestTurnCompletedAt: string | null; + preSendSessionUpdatedAt: string | null; + preSendSessionStatus: NonNullable["status"] | null; + sessionStatus: NonNullable["status"] | null; + sessionUpdatedAt: string | null; + latestTurnId: TurnId | null; + latestTurnCompletedAt: string | null; + composerHasContent: boolean; +}): "restore" | "drop" | "wait" { + if (!input.hasPendingSnapshot) { + return "wait"; + } + // "Fresh" = the session moved past the pre-send snapshot, so an error now is + // this send's, not a prior one still showing. A changed `sessionUpdatedAt` + // is the usual signal; a changed `latestTurnId` also counts, covering an + // accept-then-fail that lands in the same millisecond as the pre-send state. + const sessionAdvanced = + (input.sessionUpdatedAt !== null && input.sessionUpdatedAt !== input.preSendSessionUpdatedAt) || + (input.latestTurnId !== null && input.latestTurnId !== input.preSendTurnId); + // A steered send folds into the already-running turn, so its turn id does not + // change; if the accept-then-fail also reuses the pre-send millisecond, + // neither `sessionAdvanced` clause fires. The session status crossing into + // "error" from a non-error pre-send value is itself proof this send's turn + // failed: a stale prior-turn error would already have been "error" at send + // time (the case guarded below), so it cannot masquerade as this transition. + const failedSincePreSend = + input.preSendSessionStatus !== "error" && input.sessionStatus === "error"; + if (input.sessionStatus === "error" && (sessionAdvanced || failedSincePreSend)) { + // Never clobber text the user has started typing since the send; the + // failed attempt is still in the transcript for them to copy from. + return input.composerHasContent ? "drop" : "restore"; + } + // Our turn ran to completion without error, so the snapshot is spent. A fresh + // turn advances `latestTurnId`; a steered follow-up folds into the turn that + // was already running and keeps the same id but moves `completedAt` from its + // pre-send value, so both must count as completion. + const turnCompleted = + input.latestTurnCompletedAt !== null && + (input.latestTurnId !== input.preSendTurnId || + input.latestTurnCompletedAt !== input.preSendLatestTurnCompletedAt); + if (turnCompleted && input.sessionStatus !== "error") { + return "drop"; + } + return "wait"; +} + export function hasServerAcknowledgedLocalDispatch(input: { localDispatch: LocalDispatchSnapshot | null; phase: SessionPhase; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f0188af478c..e3f2591d504 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -337,6 +337,7 @@ import { collectUserMessageBlobPreviewUrls, createLocalDispatchSnapshot, deriveComposerSendState, + deriveTurnFailureRecoveryAction, dismissBranchMismatchForSession, hasEnvironmentReconnectWarningGraceElapsed, scheduleEnvironmentReconnectWarning, @@ -1490,6 +1491,27 @@ function ChatViewContent(props: ChatViewProps) { const attachmentPreviewHandoffByMessageIdRef = useRef>({}); const attachmentPreviewPromotionInFlightByMessageIdRef = useRef>({}); const sendInFlightRef = useRef(false); + // Snapshot of a submitted message kept alive until its turn is confirmed + // accepted-and-clean, so an asynchronous turn failure (runtime stream error, + // stale pending provider callback, `runtime.error`) can restore the text to + // the composer instead of losing it. See `deriveTurnFailureRecoveryAction`. + const pendingSendRecoveryRef = useRef<{ + threadId: ThreadId; + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; + reviewComments: ReviewCommentContext[]; + preSendTurnId: TurnId | null; + preSendLatestTurnCompletedAt: string | null; + preSendSessionUpdatedAt: string | null; + preSendSessionStatus: NonNullable["status"] | null; + } | null>(null); + // Bumped after a snapshot is armed so the recovery effect re-evaluates even + // when `activeServerThread` has not changed since it last ran (e.g. the + // failing session state already landed while the send RPCs were awaiting). + const [recoveryReevaluateTick, setRecoveryReevaluateTick] = useState(0); const feedbackUploadsInFlightRef = useRef(new Set()); const terminalUiOpenByThreadRef = useRef>({}); @@ -5327,6 +5349,94 @@ function ChatViewContent(props: ChatViewProps) { ], ); + // Push a captured pre-send snapshot back into the composer. Shared by the + // synchronous send-failure path and the asynchronous turn-failure recovery + // effect so both restore identically. Only ever called when the composer is + // empty, so it never clobbers text the user typed after the send. + const restoreComposerContentFromSnapshot = useCallback( + (snapshot: { + prompt: string; + images: ComposerImageAttachment[]; + terminalContexts: TerminalContextDraft[]; + elementContexts: ElementContextDraft[]; + previewAnnotations: PreviewAnnotationPayload[]; + reviewComments: ReviewCommentContext[]; + }) => { + promptRef.current = snapshot.prompt; + const retryComposerImages = snapshot.images.map(cloneComposerImageForRetry); + composerImagesRef.current = retryComposerImages; + composerTerminalContextsRef.current = snapshot.terminalContexts; + composerElementContextsRef.current = snapshot.elementContexts; + setComposerDraftPrompt(composerDraftTarget, snapshot.prompt); + addComposerDraftImages(composerDraftTarget, retryComposerImages); + setComposerDraftTerminalContexts(composerDraftTarget, snapshot.terminalContexts); + setComposerDraftElementContexts(composerDraftTarget, snapshot.elementContexts); + setComposerDraftPreviewAnnotations(composerDraftTarget, snapshot.previewAnnotations); + setComposerDraftReviewComments(composerDraftTarget, snapshot.reviewComments); + composerRef.current?.resetCursorState({ + cursor: collapseExpandedComposerCursor(snapshot.prompt, snapshot.prompt.length), + prompt: snapshot.prompt, + detectTrigger: true, + }); + }, + [ + addComposerDraftImages, + composerDraftTarget, + setComposerDraftElementContexts, + setComposerDraftPreviewAnnotations, + setComposerDraftPrompt, + setComposerDraftReviewComments, + setComposerDraftTerminalContexts, + ], + ); + + // Recover a submitted message whose turn was accepted but then failed + // asynchronously (runtime stream error, stale pending provider callback, + // `runtime.error`). Those surface via session status/lastError well after + // `onSend` returned, so the inline send-failure path cannot catch them; watch + // the active server thread and restore the pre-send snapshot into an empty + // composer instead of losing the text. + useEffect(() => { + const pending = pendingSendRecoveryRef.current; + if (!pending || !activeServerThread || activeServerThread.id !== pending.threadId) { + return; + } + const session = activeServerThread.session ?? null; + const latestTurn = activeServerThread.latestTurn ?? null; + const draft = useComposerDraftStore.getState().getComposerDraft(composerDraftTarget); + const composerHasContent = + promptRef.current.trim().length > 0 || + composerImagesRef.current.length > 0 || + composerTerminalContextsRef.current.length > 0 || + composerElementContextsRef.current.length > 0 || + (draft?.previewAnnotations.length ?? 0) > 0 || + (draft?.reviewComments.length ?? 0) > 0; + const action = deriveTurnFailureRecoveryAction({ + hasPendingSnapshot: true, + preSendTurnId: pending.preSendTurnId, + preSendLatestTurnCompletedAt: pending.preSendLatestTurnCompletedAt, + preSendSessionUpdatedAt: pending.preSendSessionUpdatedAt, + preSendSessionStatus: pending.preSendSessionStatus, + sessionStatus: session?.status ?? null, + sessionUpdatedAt: session?.updatedAt ?? null, + latestTurnId: latestTurn?.turnId ?? null, + latestTurnCompletedAt: latestTurn?.completedAt ?? null, + composerHasContent, + }); + if (action === "wait") { + return; + } + if (action === "restore") { + restoreComposerContentFromSnapshot(pending); + } + pendingSendRecoveryRef.current = null; + }, [ + activeServerThread, + composerDraftTarget, + recoveryReevaluateTick, + restoreComposerContentFromSnapshot, + ]); + const onSend = async ( e?: { preventDefault: () => void }, submissionIntent: ComposerSubmissionIntent = "foreground", @@ -5537,6 +5647,7 @@ function ChatViewContent(props: ChatViewProps) { if (composerRef.current?.validateProviderInput(outgoingFollowUpText) === false) { return; } + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5559,6 +5670,7 @@ function ChatViewContent(props: ChatViewProps) { : null; if (standaloneSlashCommand) { handleInteractionModeChange(standaloneSlashCommand); + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5611,6 +5723,12 @@ function ChatViewContent(props: ChatViewProps) { const composerElementContextsSnapshot = [...composerElementContexts]; const composerPreviewAnnotationsSnapshot = [...composerPreviewAnnotations]; const composerReviewCommentsSnapshot: ReviewCommentContext[] = [...composerReviewComments]; + // Turn/session markers captured before this send so an async failure can be + // distinguished from a stale prior-turn error (see the recovery effect). + const preSendLatestTurnId = activeThread.latestTurn?.turnId ?? null; + const preSendLatestTurnCompletedAt = activeThread.latestTurn?.completedAt ?? null; + const preSendSessionUpdatedAt = activeThread.session?.updatedAt ?? null; + const preSendSessionStatus = activeThread.session?.status ?? null; const messageTextWithContexts = appendElementContextsToPrompt( appendTerminalContextsToPrompt(promptForSend, composerTerminalContextsSnapshot), composerElementContextsSnapshot, @@ -5750,6 +5868,11 @@ function ChatViewContent(props: ChatViewProps) { }), ); } + // A new send supersedes any earlier recovery snapshot: the previous turn's + // text must not restore into this send's freshly cleared composer (which + // would then be treated as non-empty and drop this send's own text on a + // later failure). This send re-arms its own snapshot on accept below. + pendingSendRecoveryRef.current = null; promptRef.current = ""; clearComposerDraftContent(composerDraftTarget); composerRef.current?.resetCursorState(); @@ -5885,6 +6008,25 @@ function ChatViewContent(props: ChatViewProps) { releaseAttachmentUploads(composerImagesSnapshot); } acknowledgeActiveThreadWoke(); + // The turn was accepted; keep the composed text recoverable until the + // turn is confirmed clean, so an async failure can restore it. Bump the + // tick so the recovery effect re-evaluates even if the failing session + // state already arrived while these RPCs were in flight (a ref write + // alone would not re-run the effect). + pendingSendRecoveryRef.current = { + threadId: threadIdForSend, + prompt: promptForSend, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, + preSendTurnId: preSendLatestTurnId, + preSendLatestTurnCompletedAt, + preSendSessionUpdatedAt, + preSendSessionStatus, + }; + setRecoveryReevaluateTick((tick) => tick + 1); if (backgroundThreadRef) { markPromotedDraftThreadByRef(backgroundThreadRef); try { @@ -5946,6 +6088,7 @@ function ChatViewContent(props: ChatViewProps) { (useComposerDraftStore.getState().getComposerDraft(composerDraftTarget)?.reviewComments .length ?? 0) === 0 ) { + // The turn never started, so the optimistic user message must go too. setOptimisticUserMessages((existing) => { const removed = existing.filter((message) => message.id === messageIdForSend); for (const message of removed) { @@ -5954,21 +6097,13 @@ function ChatViewContent(props: ChatViewProps) { const next = existing.filter((message) => message.id !== messageIdForSend); return next.length === existing.length ? existing : next; }); - promptRef.current = promptForSend; - const retryComposerImages = composerImagesSnapshot.map(cloneComposerImageForRetry); - composerImagesRef.current = retryComposerImages; - composerTerminalContextsRef.current = composerTerminalContextsSnapshot; - composerElementContextsRef.current = composerElementContextsSnapshot; - setComposerDraftPrompt(composerDraftTarget, promptForSend); - addComposerDraftImages(composerDraftTarget, retryComposerImages); - setComposerDraftTerminalContexts(composerDraftTarget, composerTerminalContextsSnapshot); - setComposerDraftElementContexts(composerDraftTarget, composerElementContextsSnapshot); - setComposerDraftPreviewAnnotations(composerDraftTarget, composerPreviewAnnotationsSnapshot); - setComposerDraftReviewComments(composerDraftTarget, composerReviewCommentsSnapshot); - composerRef.current?.resetCursorState({ - cursor: collapseExpandedComposerCursor(promptForSend, promptForSend.length), + restoreComposerContentFromSnapshot({ prompt: promptForSend, - detectTrigger: true, + images: composerImagesSnapshot, + terminalContexts: composerTerminalContextsSnapshot, + elementContexts: composerElementContextsSnapshot, + previewAnnotations: composerPreviewAnnotationsSnapshot, + reviewComments: composerReviewCommentsSnapshot, }); } if (!isAtomCommandInterrupted(failure)) {