Fix agent history catch-up + avoid mobile background stream deltas (#18)

* Update files

* Fix agent history sync + mobile stream gating
This commit is contained in:
Mohamed Boudra
2026-02-06 13:18:15 +07:00
committed by GitHub
parent 4633a7e8fe
commit d724988542
8 changed files with 169 additions and 117 deletions
@@ -84,6 +84,11 @@ export interface AgentStreamViewProps {
agent: Agent; agent: Agent;
streamItems: StreamItem[]; streamItems: StreamItem[];
pendingPermissions: Map<string, PendingPermission>; pendingPermissions: Map<string, PendingPermission>;
/**
* True when we expect a history snapshot to arrive shortly (e.g. after opening an agent
* or resuming after connectivity changes) and should avoid showing the "empty" state.
*/
isSyncingHistory?: boolean;
} }
export function AgentStreamView({ export function AgentStreamView({
@@ -92,6 +97,7 @@ export function AgentStreamView({
agent, agent,
streamItems, streamItems,
pendingPermissions, pendingPermissions,
isSyncingHistory = false,
}: AgentStreamViewProps) { }: AgentStreamViewProps) {
const flatListRef = useRef<FlatList<StreamItem>>(null); const flatListRef = useRef<FlatList<StreamItem>>(null);
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -590,6 +596,43 @@ export function AgentStreamView({
return { marginBottom: tightGap }; return { marginBottom: tightGap };
}, [listHeaderComponent, tightGap]); }, [listHeaderComponent, tightGap]);
const listEmptyComponent = useMemo(() => {
const hasPermissions = pendingPermissionItems.length > 0;
const hasHeadItems = (streamHead?.length ?? 0) > 0;
if (hasPermissions || hasHeadItems) {
return null;
}
const shouldShowSyncing =
isSyncingHistory || agent.status === "running";
if (shouldShowSyncing) {
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
<Text style={stylesheet.emptyStateText}>Catching up…</Text>
</View>
);
}
return (
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
);
}, [
agent.status,
isSyncingHistory,
pendingPermissionItems.length,
streamHead,
theme.colors.foregroundMuted,
]);
return ( return (
<ToolCallSheetProvider> <ToolCallSheetProvider>
<View style={stylesheet.container}> <View style={stylesheet.container}>
@@ -607,13 +650,7 @@ export function AgentStreamView({
style={stylesheet.list} style={stylesheet.list}
onScroll={handleScroll} onScroll={handleScroll}
scrollEventThrottle={16} scrollEventThrottle={16}
ListEmptyComponent={ ListEmptyComponent={listEmptyComponent}
<View style={[stylesheet.emptyState, stylesheet.contentWrapper]}>
<Text style={stylesheet.emptyStateText}>
Start chatting with this agent...
</Text>
</View>
}
ListHeaderComponent={listHeaderComponent} ListHeaderComponent={listHeaderComponent}
extraData={flatListExtraData} extraData={flatListExtraData}
maintainVisibleContentPosition={ maintainVisibleContentPosition={
+4 -43
View File
@@ -915,16 +915,6 @@ export function SessionProvider({
}); });
} }
setInitializingAgents(serverId, (prev) => {
const currentState = prev.get(agentId);
if (currentState === false) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
// NOTE: We don't update lastActivityAt on every stream event to prevent // NOTE: We don't update lastActivityAt on every stream event to prevent
// cascading rerenders. The agent_update handler updates agent.lastActivityAt // cascading rerenders. The agent_update handler updates agent.lastActivityAt
// on status changes, which is sufficient for sorting and display purposes. // on status changes, which is sufficient for sorting and display purposes.
@@ -956,7 +946,7 @@ export function SessionProvider({
clearAgentStreamHead(serverId, agentId); clearAgentStreamHead(serverId, agentId);
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
if (!prev.has(agentId)) { if (prev.get(agentId) !== true) {
return prev; return prev;
} }
const next = new Map(prev); const next = new Map(prev);
@@ -990,16 +980,6 @@ export function SessionProvider({
requestId: (message.payload as any).requestId, requestId: (message.payload as any).requestId,
}); });
} }
if (status === "agent_initialized" && "agentId" in message.payload) {
const agentId = (message.payload as any).agentId as string | undefined;
if (agentId) {
setInitializingAgents(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, false);
return next;
});
}
}
}); });
const unsubPermissionRequest = client.on( const unsubPermissionRequest = client.on(
@@ -1443,12 +1423,6 @@ export function SessionProvider({
return next; return next;
}); });
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) { if (!client) {
console.warn("[Session] initializeAgent skipped: daemon unavailable"); console.warn("[Session] initializeAgent skipped: daemon unavailable");
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
@@ -1470,24 +1444,17 @@ export function SessionProvider({
}); });
}); });
}, },
[serverId, client, setAgentStreamTail, setInitializingAgents, clearAgentStreamHead] [serverId, client, setInitializingAgents]
); );
const refreshAgent = useCallback( const refreshAgent = useCallback(
({ agentId, requestId }: { agentId: string; requestId?: string }) => { ({ agentId }: { agentId: string }) => {
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(agentId, true); next.set(agentId, true);
return next; return next;
}); });
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
refreshAgentMutation refreshAgentMutation
.mutateAsync({ agentId }) .mutateAsync({ agentId })
.catch((error) => { .catch((error) => {
@@ -1499,13 +1466,7 @@ export function SessionProvider({
}); });
}); });
}, },
[ [serverId, refreshAgentMutation, setInitializingAgents]
serverId,
refreshAgentMutation,
setAgentStreamTail,
setInitializingAgents,
clearAgentStreamHead,
]
); );
const sendAgentMessage = useCallback( const sendAgentMessage = useCallback(
@@ -1,19 +1,18 @@
import { useCallback } from "react"; import { useCallback } from "react";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { import {
attachInitTimeout,
createInitDeferred, createInitDeferred,
getInitDeferred, getInitDeferred,
getInitKey, getInitKey,
rejectInitDeferred, rejectInitDeferred,
} from "@/utils/agent-initialization"; } from "@/utils/agent-initialization";
const INIT_TIMEOUT_MS = 10000; const INIT_TIMEOUT_MS = 5 * 60_000;
export function useAgentInitialization(serverId: string) { export function useAgentInitialization(serverId: string) {
const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null); const client = useSessionStore((state) => state.sessions[serverId]?.client ?? null);
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail);
const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead);
const ensureAgentIsInitialized = useCallback( const ensureAgentIsInitialized = useCallback(
(agentId: string): Promise<void> => { (agentId: string): Promise<void> => {
@@ -24,10 +23,23 @@ export function useAgentInitialization(serverId: string) {
} }
const deferred = createInitDeferred(key); const deferred = createInitDeferred(key);
const timeoutId = setTimeout(() => { const timeoutId = setTimeout(() => {
rejectInitDeferred(key, new Error(`Agent initialization timed out after ${INIT_TIMEOUT_MS}ms`)); setInitializingAgents(serverId, (prev) => {
if (prev.get(agentId) !== true) {
return prev;
}
const next = new Map(prev);
next.set(agentId, false);
return next;
});
rejectInitDeferred(
key,
new Error(
`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`
)
);
}, INIT_TIMEOUT_MS); }, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
@@ -35,15 +47,7 @@ export function useAgentInitialization(serverId: string) {
return next; return next;
}); });
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
if (!client) { if (!client) {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(agentId, false); next.set(agentId, false);
@@ -56,10 +60,10 @@ export function useAgentInitialization(serverId: string) {
client client
.initializeAgent(agentId) .initializeAgent(agentId)
.then(() => { .then(() => {
clearTimeout(timeoutId); // No-op: the actual "timeline hydrated" signal is the `agent_stream_snapshot`
// message, handled in SessionContext.
}) })
.catch((error) => { .catch((error) => {
clearTimeout(timeoutId);
setInitializingAgents(serverId, (prev) => { setInitializingAgents(serverId, (prev) => {
const next = new Map(prev); const next = new Map(prev);
next.set(agentId, false); next.set(agentId, false);
@@ -73,7 +77,7 @@ export function useAgentInitialization(serverId: string) {
return deferred.promise; return deferred.promise;
}, },
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents] [client, serverId, setInitializingAgents]
); );
const refreshAgent = useCallback( const refreshAgent = useCallback(
@@ -87,13 +91,6 @@ export function useAgentInitialization(serverId: string) {
return next; return next;
}); });
setAgentStreamTail(serverId, (prev) => {
const next = new Map(prev);
next.set(agentId, []);
return next;
});
clearAgentStreamHead(serverId, agentId);
try { try {
await client.refreshAgent(agentId); await client.refreshAgent(agentId);
} catch (error) { } catch (error) {
@@ -105,9 +102,8 @@ export function useAgentInitialization(serverId: string) {
throw error; throw error;
} }
}, },
[clearAgentStreamHead, client, serverId, setAgentStreamTail, setInitializingAgents] [client, serverId, setInitializingAgents]
); );
return { ensureAgentIsInitialized, refreshAgent }; return { ensureAgentIsInitialized, refreshAgent };
} }
@@ -55,12 +55,13 @@ export function useClientActivity({ client, focusedAgentId }: ClientActivityOpti
appVisibleRef.current = nextState === "active"; appVisibleRef.current = nextState === "active";
if (nextState === "active") { if (nextState === "active") {
recordUserActivity(); recordUserActivity();
maybeSendImmediateHeartbeat();
} }
// Send immediately on visibility changes so the server can adapt streaming behavior.
sendHeartbeat();
}); });
return () => subscription.remove(); return () => subscription.remove();
}, [maybeSendImmediateHeartbeat, recordUserActivity]); }, [recordUserActivity, sendHeartbeat]);
// Track user activity on web for accurate staleness. // Track user activity on web for accurate staleness.
useEffect(() => { useEffect(() => {
@@ -7,6 +7,7 @@ import {
ScrollView, ScrollView,
Platform, Platform,
BackHandler, BackHandler,
AppState,
} from "react-native"; } from "react-native";
import { useRouter } from "expo-router"; import { useRouter } from "expo-router";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
@@ -503,6 +504,46 @@ function AgentScreenContent({
}); });
}, [resolvedAgentId, ensureAgentIsInitialized, isConnected]); }, [resolvedAgentId, ensureAgentIsInitialized, isConnected]);
// When the app comes back to the foreground, re-sync history for the focused agent.
// This covers cases where the OS/backgrounding caused us to miss stream events.
const lastAppStateRef = useRef(AppState.currentState);
const lastResumeSyncAtRef = useRef(0);
useEffect(() => {
if (!resolvedAgentId) {
return;
}
const subscription = AppState.addEventListener("change", (nextState) => {
const prev = lastAppStateRef.current;
lastAppStateRef.current = nextState;
if (nextState !== "active" || prev === "active") {
return;
}
if (!isConnected) {
return;
}
const now = Date.now();
// Avoid accidental double-syncs on rapid transitions.
if (now - lastResumeSyncAtRef.current < 2000) {
return;
}
lastResumeSyncAtRef.current = now;
ensureAgentIsInitialized(resolvedAgentId).catch((error) => {
console.warn("[AgentScreen] Agent initialization failed on resume", {
agentId: resolvedAgentId,
error,
});
});
});
return () => {
subscription.remove();
};
}, [ensureAgentIsInitialized, isConnected, resolvedAgentId]);
useEffect(() => { useEffect(() => {
if (Platform.OS !== "web") { if (Platform.OS !== "web") {
return; return;
@@ -723,28 +764,21 @@ function AgentScreenContent({
<ReanimatedAnimated.View <ReanimatedAnimated.View
style={[styles.content, animatedKeyboardStyle]} style={[styles.content, animatedKeyboardStyle]}
> >
{isInitializing && !shouldUseOptimisticStream ? ( <AgentStreamView
<View style={styles.loadingContainer}> agentId={effectiveAgent.id}
<ActivityIndicator serverId={serverId}
size="large" agent={effectiveAgent}
color={theme.colors.primary} streamItems={
/> shouldUseOptimisticStream ? mergedStreamItems : streamItems
<Text style={styles.loadingText}>Loading agent...</Text> }
</View> pendingPermissions={pendingPermissions}
) : ( isSyncingHistory={isInitializing && !shouldUseOptimisticStream}
<AgentStreamView />
agentId={effectiveAgent.id}
serverId={serverId}
agent={effectiveAgent}
streamItems={shouldUseOptimisticStream ? mergedStreamItems : streamItems}
pendingPermissions={pendingPermissions}
/>
)}
</ReanimatedAnimated.View> </ReanimatedAnimated.View>
</View> </View>
{/* Agent Input Area */} {/* Agent Input Area */}
{!isInitializing && agent && resolvedAgentId && ( {agent && resolvedAgentId && (
<AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} /> <AgentInputArea agentId={resolvedAgentId} serverId={serverId} autoFocus onAddImages={handleAddImagesCallback} />
)} )}
+5 -19
View File
@@ -46,25 +46,11 @@ function createUniqueTimelineId(
timestamp: Date timestamp: Date
): string { ): string {
const base = createTimelineId(prefix, text, timestamp); const base = createTimelineId(prefix, text, timestamp);
let suffixSeed = state.length; // We only ever append new timeline items, and we incorporate the current
let candidate = `${base}_${suffixSeed.toString(36)}`; // length as a monotonic suffix, so uniqueness is guaranteed without an O(n)
// collision scan (important for large hydration snapshots).
// Fast path when the generated id hasn't been used yet const suffixSeed = state.length;
const hasCollision = state.some((entry) => entry.id === candidate); return `${base}_${suffixSeed.toString(36)}`;
if (!hasCollision) {
return candidate;
}
// If the id already exists (e.g. prior items were pruned/replaced),
// spin until we find an unused suffix. Building the lookup Set lazily keeps
// the common case cheap while still guaranteeing uniqueness.
const usedIds = new Set(state.map((entry) => entry.id));
while (usedIds.has(candidate)) {
suffixSeed += 1;
candidate = `${base}_${suffixSeed.toString(36)}`;
}
return candidate;
} }
export type StreamItem = export type StreamItem =
+22 -3
View File
@@ -2,6 +2,7 @@ export interface DeferredInit {
promise: Promise<void>; promise: Promise<void>;
resolve: () => void; resolve: () => void;
reject: (error: Error) => void; reject: (error: Error) => void;
timeoutId: ReturnType<typeof setTimeout> | null;
} }
const initPromises = new Map<string, DeferredInit>(); const initPromises = new Map<string, DeferredInit>();
@@ -23,14 +24,30 @@ export function createInitDeferred(key: string): DeferredInit {
reject = rej; reject = rej;
}); });
const deferred: DeferredInit = { promise, resolve, reject }; const deferred: DeferredInit = { promise, resolve, reject, timeoutId: null };
initPromises.set(key, deferred); initPromises.set(key, deferred);
return deferred; return deferred;
} }
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
const deferred = initPromises.get(key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
deferred.timeoutId = timeoutId;
}
export function resolveInitDeferred(key: string): void { export function resolveInitDeferred(key: string): void {
const deferred = initPromises.get(key); const deferred = initPromises.get(key);
deferred?.resolve(); if (!deferred) {
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key);
deferred.resolve();
} }
export function rejectInitDeferred(key: string, error: Error): void { export function rejectInitDeferred(key: string, error: Error): void {
@@ -38,7 +55,9 @@ export function rejectInitDeferred(key: string, error: Error): void {
if (!deferred) { if (!deferred) {
return; return;
} }
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
initPromises.delete(key); initPromises.delete(key);
deferred.reject(error); deferred.reject(error);
} }
+18
View File
@@ -578,6 +578,24 @@ export class Session {
return; return;
} }
// Reduce bandwidth/CPU on mobile: only forward high-frequency agent stream events
// while the app is visible and the user is focused on that agent.
//
// History catch-up is handled via explicit `initialize_agent_request` which emits a
// batched `agent_stream_snapshot`.
const activity = this.clientActivity;
if (activity?.deviceType === "mobile") {
if (!activity.appVisible) {
return;
}
if (!activity.focusedAgentId) {
return;
}
if (activity.focusedAgentId !== event.agentId) {
return;
}
}
const payload = { const payload = {
agentId: event.agentId, agentId: event.agentId,
event: serializeAgentStreamEvent(event.event), event: serializeAgentStreamEvent(event.event),