From 2346c12119527f7d4715d02a1392f746d1c911ba Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:27:37 -0400
Subject: [PATCH 1/6] fix(ui): guard chat adoption against stale end-of-turn
snapshots
---
.../src/components/chat/chat-session-pane.tsx | 2 +
frontend/src/hooks/use-chat.test.tsx | 350 ++++++++++++++++++
frontend/src/hooks/use-chat.ts | 253 ++++++++++++-
frontend/tests/chat-session-pane.test.tsx | 35 +-
4 files changed, 607 insertions(+), 33 deletions(-)
create mode 100644 frontend/src/hooks/use-chat.test.tsx
diff --git a/frontend/src/components/chat/chat-session-pane.tsx b/frontend/src/components/chat/chat-session-pane.tsx
index f1f1a9a5f..a87b4bf22 100644
--- a/frontend/src/components/chat/chat-session-pane.tsx
+++ b/frontend/src/components/chat/chat-session-pane.tsx
@@ -429,6 +429,8 @@ export function ChatSessionPane({
const displayedError = lastError ?? persistedError
useAdoptServerTranscript({
+ chatId: chat?.id,
+ workspaceId,
status,
serverMessages: uiMessages,
liveMessages: messages,
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
new file mode 100644
index 000000000..7933b79b4
--- /dev/null
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -0,0 +1,350 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query"
+import { act, renderHook } from "@testing-library/react"
+import type { ChatStatus, UIMessage } from "ai"
+import type { ReactNode } from "react"
+import {
+ decideServerTranscriptAdoption,
+ useAdoptServerTranscript,
+} from "@/hooks/use-chat"
+
+function textMessage(
+ id: string,
+ role: UIMessage["role"],
+ text: string
+): UIMessage {
+ return { id, role, parts: [{ type: "text", text }] }
+}
+
+function approvalMessage(id: string): UIMessage {
+ return {
+ id,
+ role: "assistant",
+ parts: [
+ {
+ type: "data-approval-request",
+ data: [],
+ } as UIMessage["parts"][number],
+ ],
+ }
+}
+
+function toolMessage(id: string, toolCallId: string): UIMessage {
+ return {
+ id,
+ role: "assistant",
+ parts: [
+ {
+ type: "dynamic-tool",
+ toolName: "lookup",
+ toolCallId,
+ state: "input-available",
+ input: {},
+ },
+ ],
+ }
+}
+
+function createWrapper(queryClient: QueryClient) {
+ return function QueryWrapper({ children }: { children: ReactNode }) {
+ return (
+ {children}
+ )
+ }
+}
+
+async function advanceTimersBy(ms: number): Promise {
+ await act(async () => {
+ jest.advanceTimersByTime(ms)
+ await Promise.resolve()
+ })
+}
+
+describe("useAdoptServerTranscript", () => {
+ beforeEach(() => {
+ jest.useFakeTimers()
+ })
+
+ afterEach(() => {
+ jest.clearAllTimers()
+ jest.useRealTimers()
+ })
+
+ it("retains final assistant text missing from an equal-length snapshot and schedules a refetch", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Final streamed answer"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Previous answer"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ expect(setMessages).not.toHaveBeenCalled()
+ expect(jest.getTimerCount()).toBe(3)
+
+ await advanceTimersBy(1_000)
+
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
+ expect(invalidateQueries).toHaveBeenCalledWith({
+ queryKey: ["chat", "chat-1", "workspace-1", "vercel"],
+ })
+ expect(setMessages).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it("adopts a later split-row snapshot and cancels remaining retries", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Final streamed answer"),
+ ]
+ const staleMessages = [
+ textMessage("stale-user", "user", "Question"),
+ textMessage("stale-assistant", "assistant", "Previous answer"),
+ ]
+ const caughtUpMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant-1", "assistant", "Final streamed "),
+ textMessage("server-assistant-2", "assistant", "answer"),
+ ]
+
+ const { rerender, unmount } = renderHook(
+ ({ serverMessages }: { serverMessages: UIMessage[] }) =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ {
+ initialProps: { serverMessages: staleMessages },
+ wrapper: createWrapper(queryClient),
+ }
+ )
+
+ await advanceTimersBy(1_000)
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
+
+ rerender({ serverMessages: caughtUpMessages })
+
+ expect(setMessages).toHaveBeenCalledWith(caughtUpMessages)
+ expect(jest.getTimerCount()).toBe(0)
+
+ await advanceTimersBy(8_000)
+ expect(invalidateQueries).toHaveBeenCalledTimes(1)
+ unmount()
+ })
+
+ it("adopts a resolved-approval snapshot after its approval card is dropped", () => {
+ const queryClient = new QueryClient()
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Run the action"),
+ textMessage("live-assistant", "assistant", "Approval required"),
+ approvalMessage("live-approval"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Run the action"),
+ textMessage("server-assistant", "assistant", "Approval required"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ expect(setMessages).toHaveBeenCalledWith(serverMessages)
+ expect(jest.getTimerCount()).toBe(0)
+ unmount()
+ })
+
+ it("adopts a count-covered snapshot after the bounded retries are exhausted", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Stream-only wording"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Canonical wording"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ await advanceTimersBy(1_000)
+ expect(setMessages).not.toHaveBeenCalled()
+ await advanceTimersBy(2_000)
+ expect(setMessages).not.toHaveBeenCalled()
+ await advanceTimersBy(5_000)
+
+ expect(invalidateQueries).toHaveBeenCalledTimes(3)
+ expect(setMessages).toHaveBeenCalledWith(serverMessages)
+ expect(jest.getTimerCount()).toBe(0)
+ unmount()
+ })
+
+ it("uses only the count guard for a textless tool-only final assistant message", () => {
+ const liveMessages = [
+ textMessage("live-user", "user", "Look this up"),
+ toolMessage("live-tool", "live-call"),
+ ]
+ const countCoveredServerMessages = [
+ textMessage("server-user", "user", "Look this up"),
+ toolMessage("server-tool", "server-call"),
+ ]
+
+ expect(
+ decideServerTranscriptAdoption({
+ serverMessages: countCoveredServerMessages,
+ liveMessages,
+ })
+ ).toBe("adopt")
+ expect(
+ decideServerTranscriptAdoption({
+ serverMessages: countCoveredServerMessages.slice(0, 1),
+ liveMessages,
+ })
+ ).toBe("reject-count")
+ })
+
+ it("adopts cancelled-turn marker rows that retain the streamed partial text", () => {
+ const queryClient = new QueryClient()
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Long request"),
+ textMessage("live-assistant", "assistant", "Partial response"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Long request"),
+ textMessage("server-assistant", "assistant", "Partial response"),
+ textMessage("server-cancelled", "assistant", "[Turn cancelled]"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ expect(setMessages).toHaveBeenCalledWith(serverMessages)
+ expect(jest.getTimerCount()).toBe(0)
+ unmount()
+ })
+
+ it("cancels retries when a new turn starts streaming", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Final streamed answer"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Previous answer"),
+ ]
+
+ const { rerender, unmount } = renderHook(
+ ({ status }: { status: ChatStatus }) =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status,
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ {
+ initialProps: { status: "ready" as ChatStatus },
+ wrapper: createWrapper(queryClient),
+ }
+ )
+
+ expect(jest.getTimerCount()).toBe(3)
+ rerender({ status: "streaming" })
+ expect(jest.getTimerCount()).toBe(0)
+
+ await advanceTimersBy(8_000)
+ expect(invalidateQueries).not.toHaveBeenCalled()
+ expect(setMessages).not.toHaveBeenCalled()
+ unmount()
+ })
+
+ it("cancels retries when the hook unmounts", () => {
+ const queryClient = new QueryClient()
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Final streamed answer"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Previous answer"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ expect(jest.getTimerCount()).toBe(3)
+ unmount()
+ expect(jest.getTimerCount()).toBe(0)
+ })
+})
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index 18bc275c5..b51a681d2 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -700,6 +700,118 @@ function isApprovalCardMessage(m: UIMessage): boolean {
)
}
+/** Delays between a rejected transcript adoption and its bounded refetches. */
+export const ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS = [
+ 1_000, 3_000, 8_000,
+] as const
+
+/** The reason a server transcript should be adopted or retained. */
+export type ServerTranscriptAdoptionDecision =
+ | "already-current"
+ | "adopt"
+ | "reject-content"
+ | "reject-count"
+
+/**
+ * Concatenate the text parts of the final non-approval assistant message.
+ *
+ * `null` means there is no comparable text, so callers must fall back to the
+ * count guard instead of comparing tool or data parts that serialize
+ * differently between the stream and the database.
+ */
+export function getFinalLiveAssistantText(
+ liveMessages: UIMessage[]
+): string | null {
+ for (let index = liveMessages.length - 1; index >= 0; index -= 1) {
+ const message = liveMessages[index]
+ if (message.role !== "assistant" || isApprovalCardMessage(message)) {
+ continue
+ }
+
+ let hasTextPart = false
+ let text = ""
+ for (const part of message.parts) {
+ if (part.type === "text") {
+ hasTextPart = true
+ text += part.text
+ }
+ }
+ return hasTextPart ? text : null
+ }
+ return null
+}
+
+/**
+ * Whether the server snapshot contains the live final assistant text.
+ *
+ * Server text is concatenated across every message so a live assistant bubble
+ * can match several database-backed rows. A textless final assistant message
+ * is deliberately treated as covered and left to the count guard.
+ */
+export function serverTranscriptCoversLiveFinalAssistantText(
+ serverMessages: UIMessage[],
+ liveMessages: UIMessage[]
+): boolean {
+ const finalAssistantText = getFinalLiveAssistantText(liveMessages)
+ if (finalAssistantText === null) {
+ return true
+ }
+
+ let serverText = ""
+ for (const message of serverMessages) {
+ for (const part of message.parts) {
+ if (part.type === "text") {
+ serverText += part.text
+ }
+ }
+ }
+ return serverText.includes(finalAssistantText)
+}
+
+/**
+ * Decide whether a quiescent live transcript can be replaced by the server.
+ *
+ * Count coverage always applies. Content coverage additionally protects the
+ * final streamed assistant text unless a bounded retry episode is exhausted.
+ */
+export function decideServerTranscriptAdoption({
+ serverMessages,
+ liveMessages,
+ allowContentMismatch = false,
+}: {
+ serverMessages: UIMessage[]
+ liveMessages: UIMessage[]
+ allowContentMismatch?: boolean
+}): ServerTranscriptAdoptionDecision {
+ if (
+ transcriptSignature(liveMessages) === transcriptSignature(serverMessages)
+ ) {
+ return "already-current"
+ }
+
+ // A resolved approval drops its card from the server transcript, so exclude
+ // approval-card-only messages from the finalize-race length guard.
+ const liveComparableLength = liveMessages.filter(
+ (message) => !isApprovalCardMessage(message)
+ ).length
+ if (serverMessages.length < liveComparableLength) {
+ return "reject-count"
+ }
+ if (
+ !allowContentMismatch &&
+ !serverTranscriptCoversLiveFinalAssistantText(serverMessages, liveMessages)
+ ) {
+ return "reject-content"
+ }
+ return "adopt"
+}
+
+type TranscriptRetryEpisode = {
+ completedAttempts: number
+ key: string
+ timers: Set>
+}
+
/**
* Adopt the server transcript wholesale at quiescent boundaries.
*
@@ -708,42 +820,143 @@ function isApprovalCardMessage(m: UIMessage): boolean {
* Message ids differ between the DB serialization and the live stream, so
* merging is impossible by design — we replace, never merge.
*
- * The backend guarantees this is always safe: an approval pause returns 204 on
- * resume, DB history already includes the paused partial turn, and any
- * continuation stream carries only the suffix. A normal turn can still finish
- * before curr_run_id is cleared, though, making the immediate onFinish refetch
- * omit the just-finished rows. Keep the longer live transcript until a later
- * server snapshot catches up. Never adopt while streaming/submitted either.
+ * The approval/continuation contract makes wholesale replacement safe once the
+ * DB snapshot is current: an approval pause returns 204 on resume, DB history
+ * already includes the paused partial turn, and any continuation stream carries
+ * only the suffix. A normal turn can still finish before curr_run_id is cleared,
+ * though, making the immediate onFinish refetch omit the just-finished rows.
+ * Keep the live transcript until a later server snapshot covers its final
+ * assistant text and comparable message count.
+ * Rejected snapshots receive a bounded refetch series; after the final retry,
+ * a snapshot that passes the count guard is adopted even if its text still
+ * differs because the server is canonical at rest. Never adopt or refetch from
+ * these timers while streaming/submitted.
*/
export function useAdoptServerTranscript({
+ chatId,
+ workspaceId,
status,
serverMessages,
liveMessages,
setMessages,
}: {
+ chatId?: string
+ workspaceId: string
status: ChatStatus
serverMessages: UIMessage[]
liveMessages: UIMessage[]
setMessages: (messages: UIMessage[]) => void
}) {
- const adoptedSignatureRef = useRef(null)
+ const queryClient = useQueryClient()
+ const retryEpisodeRef = useRef(null)
+ const statusRef = useRef(status)
+ const [retryRevision, setRetryRevision] = useState(0)
+ statusRef.current = status
+
+ const cancelRetryEpisode = useCallback(function cancelRetryEpisode(): void {
+ const episode = retryEpisodeRef.current
+ if (!episode) return
+ for (const timer of episode.timers) {
+ clearTimeout(timer)
+ }
+ retryEpisodeRef.current = null
+ }, [])
+
+ const scheduleRetryEpisode = useCallback(
+ function scheduleRetryEpisode(key: string): void {
+ cancelRetryEpisode()
+ if (!chatId) return
+
+ const episode: TranscriptRetryEpisode = {
+ completedAttempts: 0,
+ key,
+ timers: new Set(),
+ }
+ retryEpisodeRef.current = episode
+
+ for (const [
+ attemptIndex,
+ delayMs,
+ ] of ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS.entries()) {
+ const timer = setTimeout(() => {
+ episode.timers.delete(timer)
+ if (
+ retryEpisodeRef.current !== episode ||
+ statusRef.current !== "ready"
+ ) {
+ return
+ }
+
+ void queryClient
+ .invalidateQueries({
+ queryKey: ["chat", chatId, workspaceId, "vercel"],
+ })
+ .catch(() => undefined)
+ .then(() => {
+ if (
+ retryEpisodeRef.current !== episode ||
+ statusRef.current !== "ready"
+ ) {
+ return
+ }
+ episode.completedAttempts = Math.max(
+ episode.completedAttempts,
+ attemptIndex + 1
+ )
+ setRetryRevision((revision) => revision + 1)
+ })
+ }, delayMs)
+ episode.timers.add(timer)
+ }
+ },
+ [cancelRetryEpisode, chatId, queryClient, workspaceId]
+ )
+
+ useEffect(() => cancelRetryEpisode, [cancelRetryEpisode])
+
useEffect(() => {
- if (status !== "ready") return
- const serverSignature = transcriptSignature(serverMessages)
- if (adoptedSignatureRef.current === serverSignature) return
- if (transcriptSignature(liveMessages) === serverSignature) {
- adoptedSignatureRef.current = serverSignature
+ if (status !== "ready") {
+ cancelRetryEpisode()
+ return
+ }
+
+ const liveSignature = transcriptSignature(liveMessages)
+ const episodeKey = JSON.stringify([chatId, workspaceId, liveSignature])
+ const retryEpisode = retryEpisodeRef.current
+ const allowContentMismatch =
+ retryEpisode?.key === episodeKey &&
+ retryEpisode.completedAttempts >=
+ ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS.length
+ const decision = decideServerTranscriptAdoption({
+ serverMessages,
+ liveMessages,
+ allowContentMismatch,
+ })
+
+ if (decision === "already-current") {
+ cancelRetryEpisode()
+ return
+ }
+ if (decision === "reject-content" || decision === "reject-count") {
+ if (retryEpisode?.key !== episodeKey) {
+ scheduleRetryEpisode(episodeKey)
+ }
return
}
- // A resolved approval drops its card from the server transcript, so exclude
- // approval-card-only messages from the finalize-race length guard.
- const liveComparableLength = liveMessages.filter(
- (m) => !isApprovalCardMessage(m)
- ).length
- if (serverMessages.length < liveComparableLength) return
- adoptedSignatureRef.current = serverSignature
+
+ cancelRetryEpisode()
setMessages(serverMessages)
- }, [status, serverMessages, liveMessages, setMessages])
+ }, [
+ cancelRetryEpisode,
+ chatId,
+ liveMessages,
+ retryRevision,
+ scheduleRetryEpisode,
+ serverMessages,
+ setMessages,
+ status,
+ workspaceId,
+ ])
}
/**
diff --git a/frontend/tests/chat-session-pane.test.tsx b/frontend/tests/chat-session-pane.test.tsx
index b670d8177..50c01d20b 100644
--- a/frontend/tests/chat-session-pane.test.tsx
+++ b/frontend/tests/chat-session-pane.test.tsx
@@ -1647,17 +1647,21 @@ describe("ChatSessionPane", () => {
})
// Ownership swap at quiescent boundaries: while a turn streams the stream
- // owns the transcript; the moment status returns to `ready` the pane adopts
- // the server copy WHOLESALE (replace, never merge — DB and stream message ids
- // differ by design). The backend guarantees DB history and any continuation
- // stream never overlap, so adopting a shorter, longer, or same-length copy is
- // uniformly correct.
+ // owns the transcript; once status returns to `ready` the pane adopts a
+ // caught-up server copy WHOLESALE (replace, never merge — DB and stream
+ // message ids differ by design). A finalize-race snapshot can still omit the
+ // final streamed assistant content, so length alone cannot prove it is safe.
describe("adopt-on-ready ownership swap", () => {
const userTurn = (id: string, text: string) => ({
id,
role: "user" as const,
parts: [{ type: "text" as const, text }],
})
+ const assistantTurn = (id: string, text: string) => ({
+ id,
+ role: "assistant" as const,
+ parts: [{ type: "text" as const, text }],
+ })
function mockLiveMessages(
messages: UIMessage[],
@@ -1736,17 +1740,22 @@ describe("ChatSessionPane", () => {
expect(setMessages).not.toHaveBeenCalled()
})
- // Same length but different content (ids/text) is a distinct transcript and
- // must be adopted too.
- it("adopts a same-length server copy with different content", () => {
+ // DB row segmentation can make a stale snapshot equal-or-longer in count.
+ // Missing final assistant text still makes it unsafe to adopt immediately.
+ it("does not adopt a same-length copy missing final assistant content", () => {
const setMessages = jest.fn()
- mockLiveMessages([userTurn("m1", "hello")], setMessages)
+ mockLiveMessages(
+ [assistantTurn("live-assistant", "Final streamed answer")],
+ setMessages
+ )
- render(renderSubject([userTurn("s1", "server hello")]))
+ render(
+ renderSubject([
+ assistantTurn("server-assistant", "Previous server answer"),
+ ])
+ )
- expect(setMessages).toHaveBeenCalledTimes(1)
- expect(setMessages.mock.calls[0][0]).toHaveLength(1)
- expect(setMessages.mock.calls[0][0][0].id).toBe("s1")
+ expect(setMessages).not.toHaveBeenCalled()
})
// The stream owns the current turn: while status is streaming the pane must
From f0f0ca83c52ce74d339f0d872df5368abc66daa1 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:40:19 -0400
Subject: [PATCH 2/6] fix(ui): scope adopt-guard coverage check to the final
turn
---
frontend/src/hooks/use-chat.test.tsx | 44 ++++++++++++++++++++++++++++
frontend/src/hooks/use-chat.ts | 34 ++++++++++++++++-----
2 files changed, 71 insertions(+), 7 deletions(-)
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
index 7933b79b4..4fbb3db5f 100644
--- a/frontend/src/hooks/use-chat.test.tsx
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -248,6 +248,50 @@ describe("useAdoptServerTranscript", () => {
).toBe("reject-count")
})
+ it("rejects a stale snapshot whose earlier turn repeats the final answer text", () => {
+ const liveMessages = [
+ textMessage("live-user-1", "user", "Question"),
+ textMessage("live-assistant-1", "assistant", "Same answer"),
+ textMessage("live-user-2", "user", "Question again"),
+ textMessage("live-assistant-2", "assistant", "Same answer"),
+ ]
+ // Stale finalize-race snapshot: the earlier turn already contains the
+ // identical text and DB row segmentation makes the snapshot as long as the
+ // live transcript, but the final turn's rows are still hidden. Coverage
+ // must be scoped to the final turn or this would be adopted and the final
+ // bubble dropped.
+ const serverMessages = [
+ textMessage("server-user-1", "user", "Question"),
+ textMessage("server-assistant-1a", "assistant", "Same "),
+ textMessage("server-assistant-1b", "assistant", "answer"),
+ textMessage("server-user-2", "user", "Question again"),
+ ]
+
+ expect(
+ decideServerTranscriptAdoption({ serverMessages, liveMessages })
+ ).toBe("reject-content")
+ })
+
+ it("uses only the count guard when the live transcript ends with a user message", () => {
+ // The finalize race protects the final streamed assistant turn; when the
+ // last live message is a user prompt there is no such turn, so a previous
+ // turn's assistant text must not be used as the coverage probe.
+ const liveMessages = [
+ textMessage("live-user-1", "user", "Question"),
+ textMessage("live-assistant-1", "assistant", "Answer"),
+ textMessage("live-user-2", "user", "New question"),
+ ]
+ const serverMessages = [
+ textMessage("server-user-1", "user", "Question"),
+ textMessage("server-assistant-1", "assistant", "Different text"),
+ textMessage("server-user-2", "user", "New question"),
+ ]
+
+ expect(
+ decideServerTranscriptAdoption({ serverMessages, liveMessages })
+ ).toBe("adopt")
+ })
+
it("adopts cancelled-turn marker rows that retain the streamed partial text", () => {
const queryClient = new QueryClient()
const setMessages = jest.fn()
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index b51a681d2..1a9aff74b 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -712,17 +712,30 @@ export type ServerTranscriptAdoptionDecision =
| "reject-content"
| "reject-count"
+/** Index of the last user-role message, or -1 when there is none. */
+function lastUserMessageIndex(messages: UIMessage[]): number {
+ for (let index = messages.length - 1; index >= 0; index -= 1) {
+ if (messages[index].role === "user") {
+ return index
+ }
+ }
+ return -1
+}
+
/**
* Concatenate the text parts of the final non-approval assistant message.
*
- * `null` means there is no comparable text, so callers must fall back to the
- * count guard instead of comparing tool or data parts that serialize
+ * Only the final turn qualifies: the scan stops at the last user message, so
+ * an assistant answer from a previous turn is never used as the coverage
+ * probe. `null` means there is no comparable text, so callers must fall back
+ * to the count guard instead of comparing tool or data parts that serialize
* differently between the stream and the database.
*/
export function getFinalLiveAssistantText(
liveMessages: UIMessage[]
): string | null {
- for (let index = liveMessages.length - 1; index >= 0; index -= 1) {
+ const turnStart = lastUserMessageIndex(liveMessages) + 1
+ for (let index = liveMessages.length - 1; index >= turnStart; index -= 1) {
const message = liveMessages[index]
if (message.role !== "assistant" || isApprovalCardMessage(message)) {
continue
@@ -744,9 +757,14 @@ export function getFinalLiveAssistantText(
/**
* Whether the server snapshot contains the live final assistant text.
*
- * Server text is concatenated across every message so a live assistant bubble
- * can match several database-backed rows. A textless final assistant message
- * is deliberately treated as covered and left to the count guard.
+ * Both sides are scoped to the final turn: the probe is the live assistant
+ * text after the last live user message, and it must appear in the server
+ * text after the last server user message. Comparing whole transcripts would
+ * let a final answer that repeats earlier conversation text mask a snapshot
+ * that still omits the new turn. Text is concatenated across the server
+ * turn's messages so one live assistant bubble can match several
+ * database-backed rows. A textless final turn is deliberately treated as
+ * covered and left to the count guard.
*/
export function serverTranscriptCoversLiveFinalAssistantText(
serverMessages: UIMessage[],
@@ -758,7 +776,9 @@ export function serverTranscriptCoversLiveFinalAssistantText(
}
let serverText = ""
- for (const message of serverMessages) {
+ for (const message of serverMessages.slice(
+ lastUserMessageIndex(serverMessages) + 1
+ )) {
for (const part of message.parts) {
if (part.type === "text") {
serverText += part.text
From 4a78a46499ab6b85ece776047659fa1b244a6cb9 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 22 Jul 2026 18:50:34 -0400
Subject: [PATCH 3/6] fix(ui): anchor adopt-guard coverage on the final turn's
user prompt
---
frontend/src/hooks/use-chat.test.tsx | 23 ++++++++
frontend/src/hooks/use-chat.ts | 66 +++++++++++++++++------
frontend/tests/chat-session-pane.test.tsx | 6 ++-
3 files changed, 77 insertions(+), 18 deletions(-)
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
index 4fbb3db5f..3275cf81f 100644
--- a/frontend/src/hooks/use-chat.test.tsx
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -272,6 +272,29 @@ describe("useAdoptServerTranscript", () => {
).toBe("reject-content")
})
+ it("rejects a stale snapshot that omits the whole final turn including its prompt", () => {
+ const liveMessages = [
+ textMessage("live-user-1", "user", "Question"),
+ textMessage("live-assistant-1", "assistant", "Same answer"),
+ textMessage("live-user-2", "user", "Question again"),
+ textMessage("live-assistant-2", "assistant", "Same answer"),
+ ]
+ // The mid-turn DB filter hides every row of the active run, so the stale
+ // snapshot lacks even the final turn's user prompt. Old-turn segmentation
+ // satisfies the count guard and the previous answer repeats the final
+ // text, so only the prompt anchor can prove the turn is missing.
+ const serverMessages = [
+ textMessage("server-user-1", "user", "Question"),
+ textMessage("server-assistant-1a", "assistant", "Same "),
+ textMessage("server-assistant-1b", "assistant", "ans"),
+ textMessage("server-assistant-1c", "assistant", "wer"),
+ ]
+
+ expect(
+ decideServerTranscriptAdoption({ serverMessages, liveMessages })
+ ).toBe("reject-content")
+ })
+
it("uses only the count guard when the live transcript ends with a user message", () => {
// The finalize race protects the final streamed assistant turn; when the
// last live message is a user prompt there is no such turn, so a previous
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index 1a9aff74b..8b54625e8 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -722,6 +722,17 @@ function lastUserMessageIndex(messages: UIMessage[]): number {
return -1
}
+/** Concatenated text parts of a message ("" when it has none). */
+function messageText(message: UIMessage): string {
+ let text = ""
+ for (const part of message.parts) {
+ if (part.type === "text") {
+ text += part.text
+ }
+ }
+ return text
+}
+
/**
* Concatenate the text parts of the final non-approval assistant message.
*
@@ -755,35 +766,56 @@ export function getFinalLiveAssistantText(
}
/**
- * Whether the server snapshot contains the live final assistant text.
+ * Whether the server snapshot contains the live transcript's final turn.
+ *
+ * The mid-turn DB filter hides EVERY row tagged with the active run —
+ * including the turn's user prompt — so a stale snapshot omits the whole
+ * final turn, and neither whole-transcript text search nor "after the
+ * server's last user row" reliably identifies the current turn. Coverage is
+ * therefore anchored on the live final turn's user prompt: the last server
+ * user row with identical text. A missing anchor means the snapshot predates
+ * the final turn. Given an anchor, the server text after it must contain the
+ * live final assistant text (concatenated across messages so one live bubble
+ * can match several database-backed rows).
*
- * Both sides are scoped to the final turn: the probe is the live assistant
- * text after the last live user message, and it must appear in the server
- * text after the last server user message. Comparing whole transcripts would
- * let a final answer that repeats earlier conversation text mask a snapshot
- * that still omits the new turn. Text is concatenated across the server
- * turn's messages so one live assistant bubble can match several
- * database-backed rows. A textless final turn is deliberately treated as
- * covered and left to the count guard.
+ * A promptless final turn (no live user message, or one without text) and a
+ * textless final assistant message are deliberately treated as covered and
+ * left to the count guard — tool, data, and approval parts serialize too
+ * differently between the stream and the database to compare.
*/
export function serverTranscriptCoversLiveFinalAssistantText(
serverMessages: UIMessage[],
liveMessages: UIMessage[]
): boolean {
+ const liveUserIndex = lastUserMessageIndex(liveMessages)
+ if (liveUserIndex === -1) {
+ return true
+ }
+ const liveUserText = messageText(liveMessages[liveUserIndex])
+ if (liveUserText === "") {
+ return true
+ }
+
+ let anchorIndex = -1
+ for (let index = serverMessages.length - 1; index >= 0; index -= 1) {
+ const message = serverMessages[index]
+ if (message.role === "user" && messageText(message) === liveUserText) {
+ anchorIndex = index
+ break
+ }
+ }
+ if (anchorIndex === -1) {
+ return false
+ }
+
const finalAssistantText = getFinalLiveAssistantText(liveMessages)
if (finalAssistantText === null) {
return true
}
let serverText = ""
- for (const message of serverMessages.slice(
- lastUserMessageIndex(serverMessages) + 1
- )) {
- for (const part of message.parts) {
- if (part.type === "text") {
- serverText += part.text
- }
- }
+ for (const message of serverMessages.slice(anchorIndex + 1)) {
+ serverText += messageText(message)
}
return serverText.includes(finalAssistantText)
}
diff --git a/frontend/tests/chat-session-pane.test.tsx b/frontend/tests/chat-session-pane.test.tsx
index 50c01d20b..edb038ee7 100644
--- a/frontend/tests/chat-session-pane.test.tsx
+++ b/frontend/tests/chat-session-pane.test.tsx
@@ -1745,12 +1745,16 @@ describe("ChatSessionPane", () => {
it("does not adopt a same-length copy missing final assistant content", () => {
const setMessages = jest.fn()
mockLiveMessages(
- [assistantTurn("live-assistant", "Final streamed answer")],
+ [
+ userTurn("live-user", "Question"),
+ assistantTurn("live-assistant", "Final streamed answer"),
+ ],
setMessages
)
render(
renderSubject([
+ userTurn("server-user", "Question"),
assistantTurn("server-assistant", "Previous server answer"),
])
)
From af38a29ddcd593fcc391312172715a05f9a4fddb Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Wed, 22 Jul 2026 19:29:48 -0400
Subject: [PATCH 4/6] fix(ui): harden adopt-guard retry counting and prompt
anchoring
---
frontend/src/hooks/use-chat.test.tsx | 83 +++++++++++++++++++++++++++-
frontend/src/hooks/use-chat.ts | 49 ++++++++++++----
2 files changed, 119 insertions(+), 13 deletions(-)
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
index 3275cf81f..11eca34df 100644
--- a/frontend/src/hooks/use-chat.test.tsx
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -190,6 +190,7 @@ describe("useAdoptServerTranscript", () => {
const queryClient = new QueryClient()
const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
const setMessages = jest.fn()
+ const queryKey = ["chat", "chat-1", "workspace-1", "vercel"]
const liveMessages = [
textMessage("live-user", "user", "Question"),
textMessage("live-assistant", "assistant", "Stream-only wording"),
@@ -212,15 +213,70 @@ describe("useAdoptServerTranscript", () => {
{ wrapper: createWrapper(queryClient) }
)
- await advanceTimersBy(1_000)
+ // Each retry only counts toward guard exhaustion when the query delivered
+ // fresh data, so simulate a completed refetch before each timer fires.
+ await advanceTimersBy(1)
+ act(() => {
+ queryClient.setQueryData(queryKey, serverMessages)
+ })
+ await advanceTimersBy(999)
expect(setMessages).not.toHaveBeenCalled()
+
+ act(() => {
+ queryClient.setQueryData(queryKey, serverMessages)
+ })
await advanceTimersBy(2_000)
expect(setMessages).not.toHaveBeenCalled()
+
+ act(() => {
+ queryClient.setQueryData(queryKey, serverMessages)
+ })
await advanceTimersBy(5_000)
expect(invalidateQueries).toHaveBeenCalledTimes(3)
expect(setMessages).toHaveBeenCalledWith(serverMessages)
- expect(jest.getTimerCount()).toBe(0)
+ // No further retry invalidations after adoption (remaining timers belong
+ // to the query cache's own GC, not the retry episode).
+ await advanceTimersBy(10_000)
+ expect(invalidateQueries).toHaveBeenCalledTimes(3)
+ unmount()
+ })
+
+ it("does not exhaust the content guard when refetches deliver no fresh data", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Stream-only wording"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Canonical wording"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ // No query data ever lands (transient outage): invalidations run but must
+ // not advance the guard toward stale adoption.
+ await advanceTimersBy(1_000)
+ await advanceTimersBy(2_000)
+ await advanceTimersBy(5_000)
+ await advanceTimersBy(10_000)
+
+ expect(invalidateQueries).toHaveBeenCalledTimes(3)
+ expect(setMessages).not.toHaveBeenCalled()
unmount()
})
@@ -295,6 +351,29 @@ describe("useAdoptServerTranscript", () => {
).toBe("reject-content")
})
+ it("rejects a stale snapshot when a repeated prompt hides the missing final turn", () => {
+ // The user asked the same question twice and got the same answer. The
+ // stale snapshot hides the whole final turn, but an earlier identical
+ // prompt would anchor-match and its identical answer would cover the
+ // probe. Only the occurrence count proves the final turn is missing.
+ const liveMessages = [
+ textMessage("live-user-1", "user", "Question"),
+ textMessage("live-assistant-1", "assistant", "Same answer"),
+ textMessage("live-user-2", "user", "Question"),
+ textMessage("live-assistant-2", "assistant", "Same answer"),
+ ]
+ const serverMessages = [
+ textMessage("server-user-1", "user", "Question"),
+ textMessage("server-assistant-1a", "assistant", "Same "),
+ textMessage("server-assistant-1b", "assistant", "ans"),
+ textMessage("server-assistant-1c", "assistant", "wer"),
+ ]
+
+ expect(
+ decideServerTranscriptAdoption({ serverMessages, liveMessages })
+ ).toBe("reject-content")
+ })
+
it("uses only the count guard when the live transcript ends with a user message", () => {
// The finalize race protects the final streamed assistant turn; when the
// last live message is a user prompt there is no such turn, so a previous
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index 8b54625e8..a137edfaa 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -796,15 +796,27 @@ export function serverTranscriptCoversLiveFinalAssistantText(
return true
}
+ // The anchor prompt may not be unique (a user can repeat a question
+ // verbatim), so presence alone cannot prove the final turn is in the
+ // snapshot. Require at least as many occurrences of the prompt as the live
+ // transcript has: a stale snapshot that hides the final turn is missing its
+ // occurrence and fails the count even when an earlier identical prompt —
+ // and an identical earlier answer — would otherwise match.
+ let liveOccurrences = 0
+ for (const message of liveMessages) {
+ if (message.role === "user" && messageText(message) === liveUserText) {
+ liveOccurrences += 1
+ }
+ }
+ let serverOccurrences = 0
let anchorIndex = -1
- for (let index = serverMessages.length - 1; index >= 0; index -= 1) {
- const message = serverMessages[index]
+ for (const [index, message] of serverMessages.entries()) {
if (message.role === "user" && messageText(message) === liveUserText) {
+ serverOccurrences += 1
anchorIndex = index
- break
}
}
- if (anchorIndex === -1) {
+ if (anchorIndex === -1 || serverOccurrences < liveOccurrences) {
return false
}
@@ -861,6 +873,8 @@ export function decideServerTranscriptAdoption({
type TranscriptRetryEpisode = {
completedAttempts: number
key: string
+ /** `dataUpdatedAt` of the transcript query when the last counted attempt ran. */
+ lastDataUpdatedAt: number
timers: Set>
}
@@ -879,10 +893,12 @@ type TranscriptRetryEpisode = {
* though, making the immediate onFinish refetch omit the just-finished rows.
* Keep the live transcript until a later server snapshot covers its final
* assistant text and comparable message count.
- * Rejected snapshots receive a bounded refetch series; after the final retry,
- * a snapshot that passes the count guard is adopted even if its text still
- * differs because the server is canonical at rest. Never adopt or refetch from
- * these timers while streaming/submitted.
+ * Rejected snapshots receive a bounded refetch series; once every retry has
+ * delivered fresh data, a snapshot that passes the count guard is adopted even
+ * if its text still differs because the server is canonical at rest. Failed
+ * refetches do not count toward that bound — an outage must not hand the
+ * transcript to a stale cached snapshot. Never adopt or refetch from these
+ * timers while streaming/submitted.
*/
export function useAdoptServerTranscript({
chatId,
@@ -919,9 +935,12 @@ export function useAdoptServerTranscript({
cancelRetryEpisode()
if (!chatId) return
+ const queryKey = ["chat", chatId, workspaceId, "vercel"]
const episode: TranscriptRetryEpisode = {
completedAttempts: 0,
key,
+ lastDataUpdatedAt:
+ queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0,
timers: new Set(),
}
retryEpisodeRef.current = episode
@@ -940,9 +959,7 @@ export function useAdoptServerTranscript({
}
void queryClient
- .invalidateQueries({
- queryKey: ["chat", chatId, workspaceId, "vercel"],
- })
+ .invalidateQueries({ queryKey })
.catch(() => undefined)
.then(() => {
if (
@@ -951,6 +968,16 @@ export function useAdoptServerTranscript({
) {
return
}
+ // invalidateQueries resolves even when the refetch fails, so a
+ // transient outage must not exhaust the content guard and hand
+ // the transcript to a stale cached snapshot. Count an attempt
+ // only when the query actually delivered fresh data.
+ const dataUpdatedAt =
+ queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
+ if (dataUpdatedAt <= episode.lastDataUpdatedAt) {
+ return
+ }
+ episode.lastDataUpdatedAt = dataUpdatedAt
episode.completedAttempts = Math.max(
episode.completedAttempts,
attemptIndex + 1
From 7bac3ab54bd1bb4f82596d5e97cbf6543accfd98 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:01:06 -0400
Subject: [PATCH 5/6] fix(ui): count only successful refetches toward
adopt-guard bound
---
frontend/src/hooks/use-chat.test.tsx | 42 ++++++++++++++++++++++++++++
frontend/src/hooks/use-chat.ts | 15 ++++------
2 files changed, 47 insertions(+), 10 deletions(-)
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
index 11eca34df..644919fef 100644
--- a/frontend/src/hooks/use-chat.test.tsx
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -304,6 +304,48 @@ describe("useAdoptServerTranscript", () => {
).toBe("reject-count")
})
+ it("does not adopt when only the final retry delivered fresh data", async () => {
+ const queryClient = new QueryClient()
+ const invalidateQueries = jest.spyOn(queryClient, "invalidateQueries")
+ const setMessages = jest.fn()
+ const queryKey = ["chat", "chat-1", "workspace-1", "vercel"]
+ const liveMessages = [
+ textMessage("live-user", "user", "Question"),
+ textMessage("live-assistant", "assistant", "Stream-only wording"),
+ ]
+ const serverMessages = [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Canonical wording"),
+ ]
+
+ const { unmount } = renderHook(
+ () =>
+ useAdoptServerTranscript({
+ chatId: "chat-1",
+ workspaceId: "workspace-1",
+ status: "ready",
+ serverMessages,
+ liveMessages,
+ setMessages,
+ }),
+ { wrapper: createWrapper(queryClient) }
+ )
+
+ // The first two refetches fail (no fresh data); only the third delivers.
+ // One successful refetch must not exhaust a guard that promises three —
+ // counting the timer slot index would retroactively credit the failures.
+ await advanceTimersBy(1_000)
+ await advanceTimersBy(2_000)
+ act(() => {
+ queryClient.setQueryData(queryKey, serverMessages)
+ })
+ await advanceTimersBy(5_000)
+
+ expect(invalidateQueries).toHaveBeenCalledTimes(3)
+ expect(setMessages).not.toHaveBeenCalled()
+ unmount()
+ })
+
it("rejects a stale snapshot whose earlier turn repeats the final answer text", () => {
const liveMessages = [
textMessage("live-user-1", "user", "Question"),
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index a137edfaa..3f569d169 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -945,10 +945,7 @@ export function useAdoptServerTranscript({
}
retryEpisodeRef.current = episode
- for (const [
- attemptIndex,
- delayMs,
- ] of ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS.entries()) {
+ for (const delayMs of ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS) {
const timer = setTimeout(() => {
episode.timers.delete(timer)
if (
@@ -970,18 +967,16 @@ export function useAdoptServerTranscript({
}
// invalidateQueries resolves even when the refetch fails, so a
// transient outage must not exhaust the content guard and hand
- // the transcript to a stale cached snapshot. Count an attempt
- // only when the query actually delivered fresh data.
+ // the transcript to a stale cached snapshot. Count only the
+ // attempts that actually delivered fresh data — never the timer
+ // slot index, which would retroactively credit failed slots.
const dataUpdatedAt =
queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
if (dataUpdatedAt <= episode.lastDataUpdatedAt) {
return
}
episode.lastDataUpdatedAt = dataUpdatedAt
- episode.completedAttempts = Math.max(
- episode.completedAttempts,
- attemptIndex + 1
- )
+ episode.completedAttempts += 1
setRetryRevision((revision) => revision + 1)
})
}, delayMs)
From 72f038dcf3559d885b50070666d3dac2e3234572 Mon Sep 17 00:00:00 2001
From: Daryl Lim <5508348+daryllimyt@users.noreply.github.com>
Date: Thu, 23 Jul 2026 10:20:59 -0400
Subject: [PATCH 6/6] fix(ui): retire failed adopt-guard retry series so
recovery stays possible
---
frontend/src/hooks/use-chat.test.tsx | 23 +++++++++++++++++++----
frontend/src/hooks/use-chat.ts | 23 +++++++++++++++++++----
2 files changed, 38 insertions(+), 8 deletions(-)
diff --git a/frontend/src/hooks/use-chat.test.tsx b/frontend/src/hooks/use-chat.test.tsx
index 644919fef..9a055d068 100644
--- a/frontend/src/hooks/use-chat.test.tsx
+++ b/frontend/src/hooks/use-chat.test.tsx
@@ -318,17 +318,20 @@ describe("useAdoptServerTranscript", () => {
textMessage("server-assistant", "assistant", "Canonical wording"),
]
- const { unmount } = renderHook(
- () =>
+ const { rerender, unmount } = renderHook(
+ ({ server }: { server: UIMessage[] }) =>
useAdoptServerTranscript({
chatId: "chat-1",
workspaceId: "workspace-1",
status: "ready",
- serverMessages,
+ serverMessages: server,
liveMessages,
setMessages,
}),
- { wrapper: createWrapper(queryClient) }
+ {
+ initialProps: { server: serverMessages },
+ wrapper: createWrapper(queryClient),
+ }
)
// The first two refetches fail (no fresh data); only the third delivers.
@@ -343,6 +346,18 @@ describe("useAdoptServerTranscript", () => {
expect(invalidateQueries).toHaveBeenCalledTimes(3)
expect(setMessages).not.toHaveBeenCalled()
+
+ // The failed series retires the episode: a later snapshot change starts a
+ // fresh retry series instead of leaving eventual adoption unreachable.
+ rerender({
+ server: [
+ textMessage("server-user", "user", "Question"),
+ textMessage("server-assistant", "assistant", "Rewritten wording"),
+ ],
+ })
+ await advanceTimersBy(1_000)
+ expect(invalidateQueries).toHaveBeenCalledTimes(4)
+ expect(setMessages).not.toHaveBeenCalled()
unmount()
})
diff --git a/frontend/src/hooks/use-chat.ts b/frontend/src/hooks/use-chat.ts
index 3f569d169..856e9a15e 100644
--- a/frontend/src/hooks/use-chat.ts
+++ b/frontend/src/hooks/use-chat.ts
@@ -972,12 +972,27 @@ export function useAdoptServerTranscript({
// slot index, which would retroactively credit failed slots.
const dataUpdatedAt =
queryClient.getQueryState(queryKey)?.dataUpdatedAt ?? 0
- if (dataUpdatedAt <= episode.lastDataUpdatedAt) {
+ const delivered = dataUpdatedAt > episode.lastDataUpdatedAt
+ if (delivered) {
+ episode.lastDataUpdatedAt = dataUpdatedAt
+ episode.completedAttempts += 1
+ }
+ const exhausted =
+ episode.completedAttempts >=
+ ADOPT_SERVER_TRANSCRIPT_RETRY_DELAYS_MS.length
+ if (episode.timers.size === 0 && !exhausted) {
+ // The series ran out of timers without enough delivered
+ // refetches. Retire the episode so a future server snapshot
+ // change can start a fresh series — otherwise eventual
+ // adoption would be permanently unreachable. No revision bump:
+ // there is nothing to re-decide until new data arrives, so a
+ // quiet outage does not turn into an endless polling loop.
+ retryEpisodeRef.current = null
return
}
- episode.lastDataUpdatedAt = dataUpdatedAt
- episode.completedAttempts += 1
- setRetryRevision((revision) => revision + 1)
+ if (delivered) {
+ setRetryRevision((revision) => revision + 1)
+ }
})
}, delayMs)
episode.timers.add(timer)