diff --git a/packages/app/src/agent-stream/history-start-pagination.test.ts b/packages/app/src/agent-stream/history-start-pagination.test.ts index d5f0d5910d..af45b48f74 100644 --- a/packages/app/src/agent-stream/history-start-pagination.test.ts +++ b/packages/app/src/agent-stream/history-start-pagination.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, @@ -131,6 +132,17 @@ describe("history start pagination", () => { expect(retried.shouldLoad).toBe(true); }); + it("latches an attempt that becomes invalid before entering flight", () => { + const requested = evaluateHistoryStartPagination( + createHistoryStartPaginationState(), + visibleHistoryStart, + ); + + expect(abandonHistoryStartPaginationRequest(requested.state, "epoch-1:20")).toEqual({ + status: "latched", + }); + }); + it("does not mistake repeated edge observations for a finished request", () => { const first = evaluateHistoryStartPagination( createHistoryStartPaginationState(), diff --git a/packages/app/src/agent-stream/history-start-pagination.ts b/packages/app/src/agent-stream/history-start-pagination.ts index 5547c7df77..317753b1ab 100644 --- a/packages/app/src/agent-stream/history-start-pagination.ts +++ b/packages/app/src/agent-stream/history-start-pagination.ts @@ -33,6 +33,20 @@ export function rearmHistoryStartPagination( return state.status === "latched" ? { status: "ready" } : state; } +export function abandonHistoryStartPaginationRequest( + state: HistoryStartPaginationState, + requestedProgressKey: string, +): HistoryStartPaginationState { + if ( + state.status !== "loading" || + state.requestObserved || + state.requestedProgressKey !== requestedProgressKey + ) { + return state; + } + return { status: "latched" }; +} + export function evaluateHistoryStartPagination( state: HistoryStartPaginationState, input: HistoryStartPaginationInput, diff --git a/packages/app/src/agent-stream/strategy-native.tsx b/packages/app/src/agent-stream/strategy-native.tsx index dfc9ef36e7..09948b354d 100644 --- a/packages/app/src/agent-stream/strategy-native.tsx +++ b/packages/app/src/agent-stream/strategy-native.tsx @@ -32,6 +32,7 @@ import { resolveBottomAnchorTransportBehavior, } from "./strategy"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, @@ -176,7 +177,23 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat setHistoryStartPaginationState(transition.state); } if (transition.shouldLoad) { - onNearHistoryStart(); + const requestedProgressKey = olderHistoryProgressKey; + if (requestedProgressKey === null) { + return; + } + void (async () => { + const started = await onNearHistoryStart(); + if (started !== false) { + return; + } + applyHistoryStartPaginationTransition({ + state: abandonHistoryStartPaginationRequest( + historyStartPaginationStateRef.current, + requestedProgressKey, + ), + shouldLoad: false, + }); + })(); } }, ); diff --git a/packages/app/src/agent-stream/strategy-web.test.tsx b/packages/app/src/agent-stream/strategy-web.test.tsx index 6e7013b0ef..99a3a7ab11 100644 --- a/packages/app/src/agent-stream/strategy-web.test.tsx +++ b/packages/app/src/agent-stream/strategy-web.test.tsx @@ -370,10 +370,10 @@ describe("createWebStreamStrategy", () => { expect(onNearHistoryStart).not.toHaveBeenCalled(); }); - it("retries a latched history request when the user scrolls up in a short timeline", async () => { + it("retries a skipped history request when the user scrolls up in a short timeline", async () => { const strategy = createWebStreamStrategy({ isMobileBreakpoint: true }); const viewportRef = React.createRef(); - const onNearHistoryStart = vi.fn(); + const onNearHistoryStart = vi.fn(async () => false); const renderInput: StreamRenderInput = { agentId: "agent", segments: { @@ -417,23 +417,13 @@ describe("createWebStreamStrategy", () => { Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 }); await act(async () => { await new Promise((resolve) => requestAnimationFrame(resolve)); - }); - act(() => { - scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 })); - scrollContainer.dispatchEvent(new Event("scroll")); + await Promise.resolve(); }); expect(onNearHistoryStart).toHaveBeenCalledTimes(1); - act(() => { - root?.render(strategy.render({ ...renderInput, isLoadingOlderHistory: true })); - }); - act(() => { - root?.render(strategy.render(renderInput)); - }); - expect(onNearHistoryStart).toHaveBeenCalledTimes(1); - - act(() => { + await act(async () => { scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -120 })); + await Promise.resolve(); }); expect(onNearHistoryStart).toHaveBeenCalledTimes(2); }); diff --git a/packages/app/src/agent-stream/strategy-web.tsx b/packages/app/src/agent-stream/strategy-web.tsx index c6f51c69f0..0c6f79b228 100644 --- a/packages/app/src/agent-stream/strategy-web.tsx +++ b/packages/app/src/agent-stream/strategy-web.tsx @@ -17,6 +17,7 @@ import { estimateStreamItemHeight } from "./web-virtualization"; import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./strategy"; import { createStreamStrategy } from "./strategy"; import { + abandonHistoryStartPaginationRequest, createHistoryStartPaginationState, evaluateHistoryStartPagination, isHistoryStartLoadingOperation, @@ -260,7 +261,20 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool anchorElement.getBoundingClientRect().top - scrollContainer.getBoundingClientRect().top, }; historyStartPrependAnchorActiveRef.current = false; - onNearHistoryStart(); + const requestedProgressKey = olderHistoryProgressKey; + void (async () => { + const started = await onNearHistoryStart(); + if (started !== false) { + return; + } + applyHistoryStartPaginationTransition({ + state: abandonHistoryStartPaginationRequest( + historyStartPaginationStateRef.current, + requestedProgressKey, + ), + shouldLoad: false, + }); + })(); }, ); const evaluateHistoryStart = useStableEvent(() => { diff --git a/packages/app/src/agent-stream/strategy.ts b/packages/app/src/agent-stream/strategy.ts index 9a467c578a..12ded1aecd 100644 --- a/packages/app/src/agent-stream/strategy.ts +++ b/packages/app/src/agent-stream/strategy.ts @@ -69,7 +69,7 @@ export interface StreamRenderInput { routeBottomAnchorRequest: BottomAnchorRouteRequest | null; isAuthoritativeHistoryReady: boolean; onNearBottomChange: (value: boolean) => void; - onNearHistoryStart: () => void; + onNearHistoryStart: () => void | Promise; isLoadingOlderHistory: boolean; hasOlderHistory: boolean; olderHistoryProgressKey: string | null; diff --git a/packages/app/src/assistant-image/retry.test.ts b/packages/app/src/assistant-image/retry.test.ts new file mode 100644 index 0000000000..9777663bed --- /dev/null +++ b/packages/app/src/assistant-image/retry.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from "vitest"; +import { runAssistantImageOperationWithRetry } from "./retry"; + +describe("assistant image retry", () => { + it("recovers from transient failures while the image remains mounted", async () => { + const waits: number[] = []; + let attempts = 0; + + const result = await runAssistantImageOperationWithRetry({ + operation: async () => { + attempts += 1; + if (attempts < 3) { + throw new Error("transient"); + } + return "loaded"; + }, + delaysMs: [10, 20, 30], + wait: async (delayMs) => { + waits.push(delayMs); + }, + }); + + expect({ attempts, waits, result }).toEqual({ + attempts: 3, + waits: [10, 20], + result: "loaded", + }); + }); + + it("stops retrying after the image is released", async () => { + let stopped = false; + let attempts = 0; + + await expect( + runAssistantImageOperationWithRetry({ + operation: async () => { + attempts += 1; + throw new Error("transient"); + }, + delaysMs: [10, 20], + shouldStop: () => stopped, + wait: async () => { + stopped = true; + }, + }), + ).rejects.toThrow("transient"); + expect(attempts).toBe(1); + }); +}); diff --git a/packages/app/src/assistant-image/retry.ts b/packages/app/src/assistant-image/retry.ts new file mode 100644 index 0000000000..4a2cf5c58a --- /dev/null +++ b/packages/app/src/assistant-image/retry.ts @@ -0,0 +1,31 @@ +export const ASSISTANT_IMAGE_RETRY_DELAYS_MS = [100, 400, 1_200] as const; + +export async function runAssistantImageOperationWithRetry(input: { + operation: () => Promise; + delaysMs?: readonly number[]; + shouldStop?: () => boolean; + wait?: (delayMs: number) => Promise; +}): Promise { + const delays = input.delaysMs ?? ASSISTANT_IMAGE_RETRY_DELAYS_MS; + const shouldStop = input.shouldStop ?? (() => false); + const wait = + input.wait ?? + (async (delayMs: number) => { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + }); + + for (let attempt = 0; ; attempt += 1) { + try { + return await input.operation(); + } catch (error) { + const delay = delays[attempt]; + if (delay === undefined || shouldStop()) { + throw error; + } + await wait(delay); + if (shouldStop()) { + throw error; + } + } + } +} diff --git a/packages/app/src/assistant-image/use-assistant-image.ts b/packages/app/src/assistant-image/use-assistant-image.ts index 22ebf66256..32362b9cef 100644 --- a/packages/app/src/assistant-image/use-assistant-image.ts +++ b/packages/app/src/assistant-image/use-assistant-image.ts @@ -30,6 +30,7 @@ import { type AssistantImageLifecycle, type AssistantImageLifecycleEvent, } from "./lifecycle"; +import { runAssistantImageOperationWithRetry } from "./retry"; interface AssistantImageRenderBinding { uri: string; @@ -104,7 +105,10 @@ function useAttachmentAcquisition( setState({ status: "loading" }); void (async () => { try { - const attachment = await acquireAttachment(acquisition); + const attachment = await runAssistantImageOperationWithRetry({ + operation: async () => await acquireAttachment(acquisition), + shouldStop: () => disposed, + }); if (!disposed) { setState({ status: "loaded", attachment }); } @@ -207,7 +211,10 @@ function usePreviewUrl(attachment: AttachmentMetadata | null | undefined): Previ setState({ status: "loading" }); void (async () => { try { - const uri = await resolveAttachmentPreviewUrl(current); + const uri = await runAssistantImageOperationWithRetry({ + operation: async () => await resolveAttachmentPreviewUrl(current), + shouldStop: () => disposed, + }); if (disposed) { await releaseAttachmentPreviewUrl({ attachment: current, url: uri }); return; diff --git a/packages/app/src/hooks/use-load-older-agent-history.test.ts b/packages/app/src/hooks/use-load-older-agent-history.test.ts index 28efe17fc1..292b54ab87 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.test.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.test.ts @@ -84,7 +84,7 @@ describe("loadOlderAgentHistory", () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: undefined, hasOlder: true, @@ -94,13 +94,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([false]); + expect(started).toBe(false); }); it("no-ops when the daemon says no older history exists", async () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: false, @@ -110,13 +111,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([false]); + expect(started).toBe(false); }); it("no-ops when a request is already in flight", async () => { const client = createClient(); const inFlight = createInFlight(true); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -126,13 +128,14 @@ describe("loadOlderAgentHistory", () => { expect(client.calls).toEqual([]); expect(inFlight.values).toEqual([true]); + expect(started).toBe(true); }); it("requests the page before the current start cursor and clears in-flight on success", async () => { const client = createClient(); const inFlight = createInFlight(); - await loadOlderAgentHistory(agentId, { + const started = await loadOlderAgentHistory(agentId, { client, cursor: someCursor, hasOlder: true, @@ -152,6 +155,7 @@ describe("loadOlderAgentHistory", () => { }, ]); expect(inFlight.values).toEqual([false, true, false]); + expect(started).toBe(true); }); it("shows a panel toast, warns, and clears in-flight on failure", async () => { diff --git a/packages/app/src/hooks/use-load-older-agent-history.ts b/packages/app/src/hooks/use-load-older-agent-history.ts index 6de39066d1..2bc3f5c9df 100644 --- a/packages/app/src/hooks/use-load-older-agent-history.ts +++ b/packages/app/src/hooks/use-load-older-agent-history.ts @@ -40,11 +40,14 @@ export interface LoadOlderAgentHistoryDeps { export async function loadOlderAgentHistory( agentId: string, deps: LoadOlderAgentHistoryDeps, -): Promise { +): Promise { const { client, cursor, hasOlder, isLoadingOlder, setInFlight, toast, logger, failedMessage } = deps; - if (!client || !cursor || !hasOlder || isLoadingOlder) { - return; + if (isLoadingOlder) { + return true; + } + if (!client || !cursor || !hasOlder) { + return false; } setInFlight(true); @@ -62,6 +65,7 @@ export async function loadOlderAgentHistory( } finally { setInFlight(false); } + return true; } export function useLoadOlderAgentHistory({ @@ -105,10 +109,10 @@ export function useLoadOlderAgentHistory({ [agentId, serverId, setOlderFetchInFlight], ); - const loadOlder = useCallback(() => { + const loadOlder = useCallback(async (): Promise => { const session = useSessionStore.getState().sessions[serverId]; const timeline = selectAgentTimelineState(session, agentId); - void loadOlderAgentHistory(agentId, { + return await loadOlderAgentHistory(agentId, { client: session?.client ? { fetchAgentTimeline: (timelineAgentId, request) =>