fix(app): paginate canonical timeline catch-up to avoid relay 1009 disconnect loop (#657)

@fireblue diagnosed the production failure mode: long canonical timeline catch-up could exceed the relay WebSocket frame cap, close with 1009, and send clients into a reconnect loop. Their seatbelt fix also removed the `limit: 0` footgun that let app callers request an unbounded frame.

This maintainer pass keeps that diagnosis and redirects the UX around bounded projected pages:

- initial agent load fetches the latest canonical tail
- app resume/reconnect re-fetches the latest tail instead of chaining after-cursor pages
- in-stream gaps still use bounded after-cursor catch-up
- older history loads explicitly when the user scrolls to the oldest edge
- shared page size is TIMELINE_FETCH_PAGE_SIZE = 100 projected timeline items, matching the daemon's canonical projection semantics rather than raw delta chunks

Original diagnosis and fix by @fireblue.

Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
fireblue
2026-05-02 19:53:50 +08:00
committed by GitHub
co-authored by Mohamed Boudra
parent 906205f97a
commit 654439005a
18 changed files with 728 additions and 185 deletions
@@ -19,8 +19,12 @@ const mockSessionState = vi.hoisted(() => ({
client: null,
agentStreamHead: new Map<string, StreamItem[]>(),
workspaces: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
},
},
setAgentTimelineOlderFetchInFlight: () => {},
}));
vi.mock("react-native-unistyles", () => ({
@@ -56,6 +56,8 @@ import type {
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import type { ToastApi } from "@/components/toast-host";
import type { DaemonClient } from "@server/client/daemon-client";
import { ToolCallDetailsContent } from "./tool-call-details";
import { QuestionFormCard } from "./question-form-card";
@@ -145,6 +147,7 @@ export interface AgentStreamViewProps {
pendingPermissions: Map<string, PendingPermission>;
routeBottomAnchorRequest?: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady?: boolean;
toast?: ToastApi | null;
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}
@@ -158,6 +161,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
pendingPermissions,
routeBottomAnchorRequest = null,
isAuthoritativeHistoryReady = true,
toast,
onOpenWorkspaceFile,
},
ref,
@@ -198,6 +202,11 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
workspaceId: workspaceId ?? undefined,
workspaceRoot,
});
const { isLoadingOlder, hasOlder, loadOlder } = useLoadOlderAgentHistory({
serverId: resolvedServerId,
agentId,
toast,
});
const openWorkspaceFile = useStableEvent(function openWorkspaceFile(input: {
filePath: string;
}) {
@@ -796,6 +805,9 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange: setIsNearBottom,
onNearHistoryStart: loadOlder,
isLoadingOlderHistory: isLoadingOlder,
hasOlderHistory: hasOlder,
scrollEnabled: streamScrollEnabled,
listStyle: stylesheet.list,
baseListContentContainerStyle: stylesheet.listContentContainer,
@@ -9,7 +9,9 @@ import {
} from "react";
import {
FlatList,
ActivityIndicator,
Keyboard,
View,
type LayoutChangeEvent,
type ListRenderItemInfo,
type NativeScrollEvent,
@@ -29,6 +31,7 @@ const DEFAULT_MAINTAIN_VISIBLE_CONTENT_POSITION = Object.freeze({
minIndexForVisible: 0,
autoscrollToTopThreshold: 0,
});
const HISTORY_START_THRESHOLD_PX = 96;
function keyExtractor(item: { id: string }): string {
return item.id;
@@ -45,6 +48,9 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
listStyle,
baseListContentContainerStyle,
@@ -65,6 +71,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
const programmaticScrollEventBudgetRef = useRef(0);
const [isNativeViewportSettling, setIsNativeViewportSettling] = useState(false);
const nativeViewportSettlingFrameIdRef = useRef<number | null>(null);
const historyStartReadyRef = useRef(false);
const historyRows = useMemo(() => {
if (segments.historyVirtualized.length === 0) {
@@ -155,6 +162,13 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
scrollOffsetYRef.current = 0;
clearNativeViewportSettling();
setIsNativeViewportSettling(false);
historyStartReadyRef.current = false;
const frame = requestAnimationFrame(() => {
historyStartReadyRef.current = true;
});
return () => {
cancelAnimationFrame(frame);
};
}, [agentId, clearNativeViewportSettling]);
useEffect(() => {
@@ -227,6 +241,18 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
});
onNearBottomChange(nearBottom);
const distanceFromOldestEdge =
streamViewportMetricsRef.current.contentHeight -
streamViewportMetricsRef.current.viewportHeight -
contentOffset.y;
if (
historyStartReadyRef.current &&
hasOlderHistory &&
distanceFromOldestEdge <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
if (programmaticScrollEventBudgetRef.current > 0 && contentOffset.y <= 8) {
programmaticScrollEventBudgetRef.current -= 1;
} else {
@@ -307,6 +333,17 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
);
}, [boundary, listEmptyComponent, renderLiveAuxiliary, renderLiveHeadRow, segments.liveHead]);
const historyFooterContent = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<View testID="load-older-history-spinner">
<ActivityIndicator size="small" />
</View>
);
}, [isLoadingOlderHistory]);
return (
<FlatList
ref={flatListRef}
@@ -316,6 +353,7 @@ function NativeStreamViewport(props: StreamRenderInput & { strategy: StreamStrat
testID="agent-chat-scroll"
nativeID="agent-chat-scroll-native-virtualized"
ListHeaderComponent={liveHeaderContent ?? undefined}
ListFooterComponent={historyFooterContent ?? undefined}
contentContainerStyle={baseListContentContainerStyle}
style={listStyle}
onLayout={handleListLayout}
@@ -133,6 +133,9 @@ describe("createWebStreamStrategy", () => {
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
@@ -145,4 +148,63 @@ describe("createWebStreamStrategy", () => {
expect(rowRenderCount.mock.calls.length).toBeGreaterThan(0);
expect(rowRenderCount.mock.calls.length).toBeLessThanOrEqual(historyVirtualized.length);
});
it("fires near-history-start when the user scrolls near the top", async () => {
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const onNearHistoryStart = vi.fn();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
<>
{strategy.render({
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
historyToHeadGap: 0,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
isAuthoritativeHistoryReady: true,
onNearBottomChange: vi.fn(),
onNearHistoryStart,
isLoadingOlderHistory: false,
hasOlderHistory: true,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
})}
</>,
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
expect(scrollContainer).toBeInstanceOf(HTMLElement);
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 400 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1200 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 64 });
act(() => {
scrollContainer?.dispatchEvent(new Event("scroll"));
});
expect(onNearHistoryStart).toHaveBeenCalledTimes(1);
});
});
@@ -8,6 +8,7 @@ import React, {
useRef,
useState,
} from "react";
import { ActivityIndicator } from "react-native";
import { measureElement as measureVirtualElement, useVirtualizer } from "@tanstack/react-virtual";
import { estimateStreamItemHeight } from "./agent-stream-web-virtualization";
import type { StreamRenderInput, StreamStrategy, StreamViewportHandle } from "./stream-strategy";
@@ -23,8 +24,18 @@ const WEB_BOTTOM_SETTLE_TIMEOUT_MS = 200;
const USER_SCROLL_DELTA_EPSILON = 1;
const AUTO_SCROLL_BOTTOM_THRESHOLD_PX = 64;
const AUTO_SCROLL_RESUME_THRESHOLD_PX = 1;
const HISTORY_START_THRESHOLD_PX = 96;
import { useWebElementScrollbar } from "./use-web-scrollbar";
const historyStartSlotStyle: CSSProperties = {
display: "flex",
alignItems: "center",
justifyContent: "center",
minHeight: 32,
paddingTop: 4,
paddingBottom: 8,
};
function isScrollContainerNearBottom(
scrollContainer: Pick<HTMLElement, "scrollTop" | "clientHeight" | "scrollHeight">,
thresholdPx = AUTO_SCROLL_BOTTOM_THRESHOLD_PX,
@@ -91,6 +102,9 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
routeBottomAnchorRequest,
isAuthoritativeHistoryReady,
onNearBottomChange,
onNearHistoryStart,
isLoadingOlderHistory,
hasOlderHistory,
scrollEnabled,
isMobileBreakpoint,
} = props;
@@ -115,6 +129,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const pendingAutoScrollFrameRef = useRef<number | null>(null);
const pendingAutoScrollTimeoutRef = useRef<number | null>(null);
const pendingVirtualRowMeasureFramesRef = useRef(new Map<Element, number>());
const historyStartReadyRef = useRef(false);
const showDesktopWebScrollbar = !isMobileBreakpoint;
const scrollbarOverlay = useWebElementScrollbar(scrollContainerRef, {
enabled: showDesktopWebScrollbar,
@@ -280,7 +295,24 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
lastKnownScrollTopRef.current = currentScrollTop;
updateScrollMetrics();
}, [cancelPendingStickToBottom, updateScrollMetrics]);
if (
historyStartReadyRef.current &&
hasOlderHistory &&
currentScrollTop <= HISTORY_START_THRESHOLD_PX
) {
onNearHistoryStart();
}
}, [cancelPendingStickToBottom, hasOlderHistory, onNearHistoryStart, updateScrollMetrics]);
useEffect(() => {
const frame = window.requestAnimationFrame(() => {
historyStartReadyRef.current = true;
});
return () => {
window.cancelAnimationFrame(frame);
historyStartReadyRef.current = false;
};
}, [props.agentId]);
useLayoutEffect(() => {
if (!isActivationReady) {
@@ -501,6 +533,16 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const liveAuxiliary = useMemo(() => {
return renderLiveAuxiliary();
}, [renderLiveAuxiliary]);
const historyStartSlot = useMemo(() => {
if (!isLoadingOlderHistory) {
return null;
}
return (
<div style={historyStartSlotStyle} data-testid="load-older-history-spinner">
<ActivityIndicator size="small" />
</div>
);
}, [isLoadingOlderHistory]);
const shouldRenderEmpty =
!boundary.hasMountedHistory &&
!boundary.hasVirtualizedHistory &&
@@ -516,6 +558,7 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
style={scrollContainerStyle}
>
<div ref={handleContentRef} style={contentContainerStyle}>
{historyStartSlot}
{shouldUseVirtualizer ? (
<div style={virtualRowsContainerStyle}>
{virtualRows.map((virtualRow) => {
@@ -60,6 +60,9 @@ export interface StreamRenderInput {
routeBottomAnchorRequest: BottomAnchorRouteRequest | null;
isAuthoritativeHistoryReady: boolean;
onNearBottomChange: (value: boolean) => void;
onNearHistoryStart: () => void;
isLoadingOlderHistory: boolean;
hasOlderHistory: boolean;
scrollEnabled: boolean;
listStyle: StyleProp<ViewStyle>;
baseListContentContainerStyle: StyleProp<ViewStyle>;
+18 -8
View File
@@ -12,7 +12,8 @@ import {
processTimelineResponse,
type ProcessTimelineResponseOutput,
type TimelineReducerSideEffect,
} from "@/contexts/session-stream-reducers";
} from "@/timeline/session-stream-reducers";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type {
AgentAttachment,
AgentStreamEventPayload,
@@ -451,6 +452,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor);
const setAgentTimelineHasOlder = useSessionStore((state) => state.setAgentTimelineHasOlder);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration);
const markAgentHistorySynchronized = useSessionStore(
@@ -748,17 +750,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
if (isNative) {
const session = useSessionStore.getState().sessions[serverId];
const agentId = session?.focusedAgentId;
const cursor = agentId ? session?.agentTimelineCursor.get(agentId) : undefined;
if (agentId && cursor) {
if (agentId) {
void client
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
direction: "tail",
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
console.warn("[Session] failed to fetch catch-up timeline on resume", agentId, error);
console.warn("[Session] failed to fetch tail timeline on resume", agentId, error);
});
}
}
@@ -1024,7 +1024,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
.fetchAgentTimeline(agentId, {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.endSeq },
limit: 0,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
@@ -1055,6 +1055,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const currentTail = session?.agentStreamTail.get(agentId) ?? [];
const currentHead = session?.agentStreamHead.get(agentId) ?? [];
setAgentTimelineHasOlder(serverId, (prev) => {
if (prev.get(agentId) === payload.hasOlder) {
return prev;
}
const next = new Map(prev);
next.set(agentId, payload.hasOlder);
return next;
});
if (payload.agent) {
const normalized = normalizeAgentSnapshot(payload.agent, serverId);
applyAuthoritativeAgentSnapshot({
@@ -1127,6 +1136,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentStreamHead,
setAgentStreamTail,
setAgentTimelineCursor,
setAgentTimelineHasOlder,
setInitializingAgents,
],
);
@@ -1,47 +0,0 @@
import { describe, expect, it } from "vitest";
import { deriveInitialTimelineRequest } from "./session-timeline-bootstrap-policy";
describe("deriveInitialTimelineRequest", () => {
it("requests tail history when authoritative history has not been applied", () => {
expect(
deriveInitialTimelineRequest({
cursor: { epoch: "epoch-1", seq: 42 },
hasAuthoritativeHistory: false,
initialTimelineLimit: 100,
}),
).toEqual({
direction: "tail",
limit: 100,
projection: "canonical",
});
});
it("requests tail history when no cursor is available", () => {
expect(
deriveInitialTimelineRequest({
cursor: null,
hasAuthoritativeHistory: true,
initialTimelineLimit: 100,
}),
).toEqual({
direction: "tail",
limit: 100,
projection: "canonical",
});
});
it("requests only entries after the known cursor when authoritative history exists", () => {
expect(
deriveInitialTimelineRequest({
cursor: { epoch: "epoch-1", seq: 42 },
hasAuthoritativeHistory: true,
initialTimelineLimit: 100,
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: 0,
projection: "canonical",
});
});
});
@@ -1,34 +0,0 @@
type InitialTimelineCursor = {
epoch: string;
seq: number;
} | null;
export function deriveInitialTimelineRequest({
cursor,
hasAuthoritativeHistory,
initialTimelineLimit,
}: {
cursor: InitialTimelineCursor;
hasAuthoritativeHistory: boolean;
initialTimelineLimit: number;
}): {
direction: "tail" | "after";
cursor?: { epoch: string; seq: number };
limit: number;
projection: "canonical";
} {
if (!hasAuthoritativeHistory || !cursor) {
return {
direction: "tail",
limit: initialTimelineLimit,
projection: "canonical",
};
}
return {
direction: "after",
cursor: { epoch: cursor.epoch, seq: cursor.seq },
limit: 0,
projection: "canonical",
};
}
@@ -1,71 +1,71 @@
import { describe, expect, it } from "vitest";
import { __private__ } from "./use-agent-initialization";
// @vitest-environment jsdom
describe("useAgentInitialization timeline request policy", () => {
it("uses canonical tail bootstrap when history has not synced yet", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: false,
initialTimelineLimit: 200,
}),
).toEqual({
import { act, renderHook } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
import { useAgentInitialization } from "./use-agent-initialization";
const serverId = "server-1";
const agentId = "agent-1";
function makeClient() {
return {
fetchAgentTimeline: vi.fn().mockResolvedValue(undefined),
refreshAgent: vi.fn().mockResolvedValue(undefined),
};
}
afterEach(() => {
resolveInitDeferred(getInitKey(serverId, agentId));
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
vi.restoreAllMocks();
});
describe("useAgentInitialization", () => {
it("always requests a canonical tail bootstrap even with an authoritative cursor", () => {
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
useSessionStore
.getState()
.setAgentTimelineCursor(
serverId,
new Map([[agentId, { epoch: "epoch-1", startSeq: 1, endSeq: 42 }]]),
);
useSessionStore.getState().setAgentAuthoritativeHistoryApplied(serverId, agentId, true);
const { result } = renderHook(() =>
useAgentInitialization({ serverId, client: client as never }),
);
act(() => {
void result.current.ensureAgentIsInitialized(agentId);
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: 200,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("uses canonical tail bootstrap when cursor is missing", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
}),
).toEqual({
it("refresh fetches a bounded canonical tail after refreshing the agent", async () => {
const client = makeClient();
const { result } = renderHook(() =>
useAgentInitialization({ serverId, client: client as never }),
);
await act(async () => {
await result.current.refreshAgent(agentId);
});
expect(client.refreshAgent).toHaveBeenCalledWith(agentId);
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "tail",
limit: 200,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
});
it("uses canonical catch-up after the current cursor once history is synced", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: {
epoch: "epoch-1",
seq: 42,
},
hasAuthoritativeHistory: true,
initialTimelineLimit: 200,
}),
).toEqual({
direction: "after",
cursor: { epoch: "epoch-1", seq: 42 },
limit: 0,
projection: "canonical",
});
});
it("supports unbounded tail bootstrap policy", () => {
expect(
__private__.deriveInitialTimelineRequest({
cursor: null,
hasAuthoritativeHistory: false,
initialTimelineLimit: 0,
}),
).toEqual({
direction: "tail",
limit: 0,
projection: "canonical",
});
});
it("does not expose an RPC-success init fallback", () => {
expect("shouldResolveInitFromRpcSuccess" in __private__).toBe(false);
});
});
@@ -8,21 +8,9 @@ import {
getInitKey,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { deriveInitialTimelineRequest } from "@/contexts/session-timeline-bootstrap-policy";
import { isWeb } from "@/constants/platform";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
const INIT_TIMEOUT_MS = 5 * 60_000;
const NATIVE_INITIAL_TIMELINE_LIMIT = 200;
const UNBOUNDED_TIMELINE_LIMIT = 0;
function resolveInitialTimelineLimit(): number {
return isWeb ? UNBOUNDED_TIMELINE_LIMIT : NATIVE_INITIAL_TIMELINE_LIMIT;
}
export const __private__ = {
deriveInitialTimelineRequest,
resolveInitialTimelineLimit,
};
export function useAgentInitialization({
serverId,
@@ -54,19 +42,13 @@ export function useAgentInitialization({
return existing.promise;
}
const session = useSessionStore.getState().sessions[serverId];
const cursor = session?.agentTimelineCursor.get(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
const hasAuthoritativeHistory =
session?.agentAuthoritativeHistoryApplied.get(agentId) === true;
const timelineRequest = deriveInitialTimelineRequest({
cursor: cursor ? { epoch: cursor.epoch, seq: cursor.endSeq } : null,
hasAuthoritativeHistory,
initialTimelineLimit,
});
const initRequestDirection = timelineRequest.direction === "after" ? "after" : "tail";
const timelineRequest = {
direction: "tail" as const,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical" as const,
};
const deferred = createInitDeferred(key, initRequestDirection);
const deferred = createInitDeferred(key, "tail");
const timeoutId = setTimeout(() => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
@@ -103,10 +85,9 @@ export function useAgentInitialization({
try {
await client.refreshAgent(agentId);
const initialTimelineLimit = resolveInitialTimelineLimit();
await client.fetchAgentTimeline(agentId, {
direction: "tail",
limit: initialTimelineLimit,
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
} catch (error) {
@@ -0,0 +1,143 @@
// @vitest-environment jsdom
import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import { useLoadOlderAgentHistory } from "./use-load-older-agent-history";
const serverId = "server-1";
const agentId = "agent-1";
function makeClient(fetchAgentTimeline = vi.fn().mockResolvedValue(undefined)) {
return { fetchAgentTimeline };
}
function initialize(input?: {
cursor?: { epoch: string; startSeq: number; endSeq: number };
hasOlder?: boolean;
inFlight?: boolean;
fetchAgentTimeline?: ReturnType<typeof vi.fn>;
}) {
const client = makeClient(input?.fetchAgentTimeline);
useSessionStore.getState().initializeSession(serverId, client as never);
if (input?.cursor) {
useSessionStore.getState().setAgentTimelineCursor(serverId, new Map([[agentId, input.cursor]]));
}
if (input?.hasOlder !== undefined) {
useSessionStore
.getState()
.setAgentTimelineHasOlder(serverId, new Map([[agentId, input.hasOlder]]));
}
if (input?.inFlight !== undefined) {
useSessionStore
.getState()
.setAgentTimelineOlderFetchInFlight(serverId, new Map([[agentId, input.inFlight]]));
}
return client;
}
afterEach(() => {
useSessionStore.setState({ sessions: {}, agentLastActivity: new Map() });
vi.restoreAllMocks();
});
describe("useLoadOlderAgentHistory", () => {
it("no-ops without a cursor", () => {
const client = initialize({ hasOlder: true });
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
act(() => {
result.current.loadOlder();
});
expect(client.fetchAgentTimeline).not.toHaveBeenCalled();
});
it("no-ops when the daemon says no older history exists", () => {
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: false,
});
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
act(() => {
result.current.loadOlder();
});
expect(client.fetchAgentTimeline).not.toHaveBeenCalled();
});
it("dedupes concurrent older-page requests", () => {
const fetchAgentTimeline = vi.fn(
() =>
new Promise<void>((resolve) => {
setTimeout(resolve, 20);
}),
);
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: true,
fetchAgentTimeline,
});
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
act(() => {
result.current.loadOlder();
result.current.loadOlder();
});
expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1);
});
it("requests the page before the current start cursor and clears in-flight on success", async () => {
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: true,
});
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId }));
act(() => {
result.current.loadOlder();
});
expect(client.fetchAgentTimeline).toHaveBeenCalledWith(agentId, {
direction: "before",
cursor: { epoch: "epoch-1", seq: 10 },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
});
await waitFor(() => {
expect(
useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
).toBe(false);
});
});
it("shows a panel toast and clears in-flight on failure", async () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const error = new Error("network");
const client = initialize({
cursor: { epoch: "epoch-1", startSeq: 10, endSeq: 20 },
hasOlder: true,
fetchAgentTimeline: vi.fn().mockRejectedValue(error),
});
const toast = { show: vi.fn(), copied: vi.fn(), error: vi.fn() };
const { result } = renderHook(() => useLoadOlderAgentHistory({ serverId, agentId, toast }));
act(() => {
result.current.loadOlder();
});
expect(client.fetchAgentTimeline).toHaveBeenCalledTimes(1);
await waitFor(() => {
expect(toast.show).toHaveBeenCalledWith("Couldn't load older history", {
durationMs: 2200,
testID: "agent-load-older-history-toast",
});
});
expect(
useSessionStore.getState().sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
).toBe(false);
});
});
@@ -0,0 +1,76 @@
import { useCallback } from "react";
import type { ToastApi } from "@/components/toast-host";
import { useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
export function useLoadOlderAgentHistory({
serverId,
agentId,
toast,
}: {
serverId: string;
agentId: string;
toast?: ToastApi | null;
}) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const cursor = useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineCursor.get(agentId),
);
const hasOlder =
useSessionStore((state) => state.sessions[serverId]?.agentTimelineHasOlder.get(agentId)) ===
true;
const isLoadingOlder =
useSessionStore((state) =>
state.sessions[serverId]?.agentTimelineOlderFetchInFlight.get(agentId),
) === true;
const setOlderFetchInFlight = useSessionStore(
(state) => state.setAgentTimelineOlderFetchInFlight,
);
const setInFlight = useCallback(
(value: boolean) => {
setOlderFetchInFlight(serverId, (prev) => {
if (prev.get(agentId) === value) {
return prev;
}
const next = new Map(prev);
next.set(agentId, value);
return next;
});
},
[agentId, serverId, setOlderFetchInFlight],
);
const loadOlder = useCallback(() => {
const latestSession = useSessionStore.getState().sessions[serverId];
const latestIsLoading = latestSession?.agentTimelineOlderFetchInFlight.get(agentId) === true;
if (!client || !cursor || !hasOlder || isLoadingOlder || latestIsLoading) {
return;
}
setInFlight(true);
void client
.fetchAgentTimeline(agentId, {
direction: "before",
cursor: { epoch: cursor.epoch, seq: cursor.startSeq },
limit: TIMELINE_FETCH_PAGE_SIZE,
projection: "canonical",
})
.catch((error) => {
console.warn("[Timeline] failed to load older agent history", agentId, error);
toast?.show("Couldn't load older history", {
durationMs: 2200,
testID: "agent-load-older-history-toast",
});
})
.finally(() => {
setInFlight(false);
});
}, [agentId, client, cursor, hasOlder, isLoadingOlder, serverId, setInFlight, toast]);
return {
isLoadingOlder,
hasOlder,
loadOlder,
};
}
+4
View File
@@ -961,6 +961,7 @@ function ChatAgentContent({
canFinalizePendingCreate={canFinalizePendingCreate}
routeBottomAnchorRequest={routeBottomAnchorRequest}
hasAppliedAuthoritativeHistory={hasAppliedAuthoritativeHistory}
toast={panelToast.api}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
</ReanimatedAnimated.View>
@@ -1018,6 +1019,7 @@ function AgentStreamSection({
canFinalizePendingCreate,
routeBottomAnchorRequest,
hasAppliedAuthoritativeHistory,
toast,
onOpenWorkspaceFile,
}: {
streamViewRef: React.RefObject<AgentStreamViewHandle | null>;
@@ -1029,6 +1031,7 @@ function AgentStreamSection({
canFinalizePendingCreate: boolean;
routeBottomAnchorRequest: RouteBottomAnchorRequest;
hasAppliedAuthoritativeHistory: boolean;
toast: ReturnType<typeof useToastHost>["api"];
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
}) {
const streamItemsRaw = useSessionStore((state) =>
@@ -1163,6 +1166,7 @@ function AgentStreamSection({
pendingPermissions={pendingPermissions}
routeBottomAnchorRequest={routeBottomAnchorRequest}
isAuthoritativeHistoryReady={hasAppliedAuthoritativeHistory}
toast={toast}
onOpenWorkspaceFile={onOpenWorkspaceFile}
/>
);
+54
View File
@@ -268,6 +268,8 @@ export interface SessionState {
agentStreamTail: Map<string, StreamItem[]>;
agentStreamHead: Map<string, StreamItem[]>;
agentTimelineCursor: Map<string, AgentTimelineCursorState>;
agentTimelineHasOlder: Map<string, boolean>;
agentTimelineOlderFetchInFlight: Map<string, boolean>;
historySyncGeneration: number;
agentHistorySyncGeneration: Map<string, number>;
agentAuthoritativeHistoryApplied: Map<string, boolean>;
@@ -351,6 +353,14 @@ interface SessionStoreActions {
| Map<string, AgentTimelineCursorState>
| ((prev: Map<string, AgentTimelineCursorState>) => Map<string, AgentTimelineCursorState>),
) => void;
setAgentTimelineHasOlder: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
setAgentTimelineOlderFetchInFlight: (
serverId: string,
state: Map<string, boolean> | ((prev: Map<string, boolean>) => Map<string, boolean>),
) => void;
bumpHistorySyncGeneration: (serverId: string) => void;
markAgentHistorySynchronized: (serverId: string, agentId: string) => void;
setAgentAuthoritativeHistoryApplied: (
@@ -443,6 +453,8 @@ function createInitialSessionState(serverId: string, client: DaemonClient): Sess
agentStreamTail: new Map(),
agentStreamHead: new Map(),
agentTimelineCursor: new Map(),
agentTimelineHasOlder: new Map(),
agentTimelineOlderFetchInFlight: new Map(),
historySyncGeneration: 0,
agentHistorySyncGeneration: new Map(),
agentAuthoritativeHistoryApplied: new Map(),
@@ -861,6 +873,48 @@ export const useSessionStore = create<SessionStore>()(
});
},
setAgentTimelineHasOlder: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineHasOlder) : state;
if (session.agentTimelineHasOlder === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineHasOlder: nextState },
},
};
});
},
setAgentTimelineOlderFetchInFlight: (serverId, state) => {
set((prev) => {
const session = prev.sessions[serverId];
if (!session) {
return prev;
}
const nextState =
typeof state === "function" ? state(session.agentTimelineOlderFetchInFlight) : state;
if (session.agentTimelineOlderFetchInFlight === nextState) {
return prev;
}
return {
...prev,
sessions: {
...prev.sessions,
[serverId]: { ...session, agentTimelineOlderFetchInFlight: nextState },
},
};
});
},
bumpHistorySyncGeneration: (serverId) => {
set((prev) => {
const session = prev.sessions[serverId];
@@ -16,10 +16,15 @@ import {
// Test helpers
// ---------------------------------------------------------------------------
function makeTimelineEntry(seq: number, text: string, type: string = "assistant_message") {
function makeTimelineEntry(
seq: number,
text: string,
type: string = "assistant_message",
seqEnd = seq,
) {
return {
seqStart: seq,
seqEnd: seq,
seqEnd,
provider: "claude",
item: { type, text },
timestamp: new Date(1000 + seq).toISOString(),
@@ -84,6 +89,14 @@ function getAssistantTexts(items: StreamItem[]): string[] {
.map((item) => item.text);
}
function getUserTexts(items: StreamItem[]): string[] {
return items
.filter((item): item is Extract<StreamItem, { kind: "user_message" }> => {
return item.kind === "user_message";
})
.map((item) => item.text);
}
const baseTimelineInput: ProcessTimelineResponseInput = {
payload: {
agentId: "agent-1",
@@ -94,6 +107,8 @@ const baseTimelineInput: ProcessTimelineResponseInput = {
endCursor: null,
entries: [],
error: null,
hasNewer: false,
hasOlder: false,
},
currentTail: [],
currentHead: [],
@@ -356,6 +371,117 @@ describe("processTimelineResponse", () => {
expect(result.cursorChanged).toBe(false);
});
it("prepends older before-cursor entries and only expands the start cursor", () => {
const currentTail: StreamItem[] = [
{
kind: "user_message",
id: "current-3",
text: "current-3",
timestamp: new Date(3000),
},
];
const currentHead = [makeAssistantItem("live head")];
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentHead,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [
makeTimelineEntry(1, "older-1", "user_message"),
makeTimelineEntry(2, "older-2", "user_message"),
],
},
});
expect(getUserTexts(result.tail)).toEqual(["older-1", "older-2", "current-3"]);
expect(result.head).toBe(currentHead);
expect(result.cursorChanged).toBe(true);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
});
it("leaves the cursor alone when a before page makes no progress", () => {
const currentTail: StreamItem[] = [
{
kind: "user_message",
id: "current-3",
text: "current-3",
timestamp: new Date(3000),
},
];
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 3 },
endCursor: { seq: 4 },
entries: [
makeTimelineEntry(3, "overlap-3", "user_message"),
makeTimelineEntry(4, "overlap-4", "user_message"),
],
},
});
expect(result.tail).toBe(currentTail);
expect(result.cursorChanged).toBe(false);
expect(result.cursor).toBe(existingCursor);
});
it("merges assistant chunks across the older-page prepend boundary", () => {
const currentTail = [makeAssistantItem("newer chunk", "assistant-newer")];
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
startSeq: 3,
endSeq: 5,
};
const result = processTimelineResponse({
...baseTimelineInput,
currentTail,
currentCursor: existingCursor,
payload: {
...baseTimelineInput.payload,
direction: "before",
epoch: "epoch-1",
startCursor: { seq: 1 },
endCursor: { seq: 2 },
entries: [makeTimelineEntry(1, "older chunk ")],
},
});
expect(getAssistantTexts(result.tail)).toEqual(["older chunk newer chunk"]);
expect(result.cursor).toEqual({
epoch: "epoch-1",
startSeq: 1,
endSeq: 5,
});
});
it("requests canonical catch-up when a projected entry overlaps unseen seqs", () => {
const existingCursor: TimelineCursor = {
epoch: "epoch-1",
@@ -946,6 +1072,8 @@ describe("processAgentStreamEvents", () => {
endCursor: { seq: 186 },
entries: [makeTimelineEntry(186, seq186Text)],
error: null,
hasNewer: false,
hasOlder: false,
},
});
@@ -65,6 +65,8 @@ export interface ProcessTimelineResponseInput {
endCursor: { seq: number } | null;
entries: TimelineResponseEntry[];
error: string | null;
hasNewer: boolean;
hasOlder: boolean;
};
currentTail: StreamItem[];
currentHead: StreamItem[];
@@ -310,6 +312,55 @@ function acceptIncrementalTimelineUnits(args: {
return { acceptedUnits, cursor, gapCursor };
}
function acceptOlderTimelineUnits(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
currentCursor: TimelineCursor | undefined;
}): IncrementalAcceptResult {
const { timelineUnits, payload, currentCursor } = args;
if (!currentCursor || currentCursor.epoch !== payload.epoch) {
return { acceptedUnits: [], cursor: currentCursor, gapCursor: null };
}
const acceptedUnits = timelineUnits.filter((unit) => unit.seqEnd < currentCursor.startSeq);
if (acceptedUnits.length === 0) {
return { acceptedUnits, cursor: currentCursor, gapCursor: null };
}
const firstAccepted = acceptedUnits[0];
const startSeq = payload.startCursor?.seq ?? firstAccepted?.seq ?? currentCursor.startSeq;
return {
acceptedUnits,
cursor: { ...currentCursor, startSeq },
gapCursor: null,
};
}
function mergePrependedCanonicalTail(olderTail: StreamItem[], currentTail: StreamItem[]) {
if (olderTail.length === 0) {
return currentTail;
}
if (currentTail.length === 0) {
return olderTail;
}
const olderLast = olderTail.at(-1);
const currentFirst = currentTail[0];
if (olderLast?.kind !== "assistant_message" || currentFirst?.kind !== "assistant_message") {
return [...olderTail, ...currentTail];
}
return [
...olderTail.slice(0, -1),
{
...olderLast,
text: `${olderLast.text}${currentFirst.text}`,
timestamp: currentFirst.timestamp,
},
...currentTail.slice(1),
];
}
function applyTimelineIncrementalPath(args: {
timelineUnits: TimelineUnit[];
payload: ProcessTimelineResponseInput["payload"];
@@ -328,14 +379,27 @@ function applyTimelineIncrementalPath(args: {
return { tail: nextTail, head: nextHead, cursor: nextCursor, cursorChanged, sideEffects };
}
const { acceptedUnits, cursor, gapCursor } = acceptIncrementalTimelineUnits({
timelineUnits,
payload,
currentCursor,
});
const { acceptedUnits, cursor, gapCursor } =
payload.direction === "before"
? acceptOlderTimelineUnits({
timelineUnits,
payload,
currentCursor,
})
: acceptIncrementalTimelineUnits({
timelineUnits,
payload,
currentCursor,
});
if (acceptedUnits.length > 0) {
if (currentHead.length > 0) {
if (payload.direction === "before") {
const olderTail = hydrateStreamState(
acceptedUnits.map(({ event, timestamp }) => ({ event, timestamp })),
{ source: "canonical" },
);
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
} else if (currentHead.length > 0) {
for (const { event, timestamp } of acceptedUnits) {
const applied = applyStreamEvent({
tail: nextTail,
@@ -0,0 +1,2 @@
// Count is projected timeline items, not delta chunks. The daemon's `selectTimelineWindowByProjectedLimit` interprets this against canonical entries: `assistant_merge`, `reasoning_merge`, and `tool_lifecycle`. Do not confuse this with raw stream deltas.
export const TIMELINE_FETCH_PAGE_SIZE = 100;