Default client RPC waits to 60 seconds (#1789)

* fix(client): wait longer for session responses

* fix(client): default RPC waits to sixty seconds

* fix(app): preserve detached stream scroll on delayed history

Code drift: longer client RPC waits let delayed timeline responses arrive after a user scroll-away, so web stream anchoring must not reattach on transient scroll-top resets.

Restore the 15s connect deadline and leave app initialization slack above the default 60s session RPC wait.

* fix(client): keep helper waits within caller deadlines

Review fix: the 60s default session RPC wait leaked into wait previews and waitForAgentUpsert helper fetches. Bound those helper RPCs to the caller deadline or a short best-effort preview timeout, and allow small scroll ranges to reattach at bottom.

* fix(client): respect caller timeout budgets

* fix(cli): keep diagnostic probes responsive

* Refactor daemon client request options

* Preserve daemon client legacy overloads
This commit is contained in:
Mohamed Boudra
2026-06-29 17:23:18 +02:00
committed by GitHub
parent 57800a0f17
commit b613bea9f6
58 changed files with 1234 additions and 281 deletions
@@ -404,4 +404,211 @@ describe("createWebStreamStrategy", () => {
expect(scrollTo).not.toHaveBeenCalled();
});
it("does not force bottom after upward wheel when cached scroll top is stale", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const historyMounted = Array.from({ length: 20 }, (_, index) => userMessage(index));
const routeBottomAnchorRequest = {
agentId: "agent",
reason: "initial-entry" as const,
requestKey: "server:agent:initial-entry",
};
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted,
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: false,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 1491 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 991 });
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -900 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 91 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 0 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 2531 });
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
expect(scrollTo).not.toHaveBeenCalled();
});
it("reattaches follow-output when a small scroll range returns to bottom", async () => {
const scrollTo = vi.fn(function (
this: HTMLElement,
options?: ScrollToOptions | number,
y?: number,
) {
const top = typeof options === "object" ? (options.top ?? 0) : (y ?? 0);
Object.defineProperty(this, "scrollTop", {
configurable: true,
value: top,
});
});
HTMLElement.prototype.scrollTo = scrollTo;
const strategy = createWebStreamStrategy({ isMobileBreakpoint: true });
const viewportRef = React.createRef<StreamViewportHandle>();
const renderInput = {
agentId: "agent",
segments: {
historyVirtualized: [],
historyMounted: [userMessage(1), userMessage(2)],
liveHead: [],
},
boundary: {
hasVirtualizedHistory: false,
hasMountedHistory: true,
hasLiveHead: false,
},
renderers: createRenderers(vi.fn()),
listEmptyComponent: null,
viewportRef,
routeBottomAnchorRequest: null,
onNearBottomChange: vi.fn(),
onNearHistoryStart: vi.fn(),
isLoadingOlderHistory: false,
hasOlderHistory: false,
scrollEnabled: true,
listStyle: null,
baseListContentContainerStyle: null,
forwardListContentContainerStyle: null,
};
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
act(() => {
root?.render(
strategy.render({
...renderInput,
isAuthoritativeHistoryReady: true,
}),
);
});
const scrollContainer = container.querySelector('[data-testid="agent-chat-scroll"]');
if (!(scrollContainer instanceof HTMLElement)) {
throw new Error("Expected agent chat scroll container");
}
Object.defineProperty(scrollContainer, "clientHeight", { configurable: true, value: 500 });
Object.defineProperty(scrollContainer, "scrollHeight", { configurable: true, value: 550 });
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
scrollContainer.dispatchEvent(new WheelEvent("wheel", { deltaY: -30 }));
});
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 20 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
expect(scrollTo).not.toHaveBeenCalled();
Object.defineProperty(scrollContainer, "scrollTop", { configurable: true, value: 50 });
act(() => {
scrollContainer.dispatchEvent(new Event("scroll"));
});
scrollTo.mockClear();
act(() => {
root?.render(
strategy.render({
...renderInput,
segments: {
...renderInput.segments,
liveHead: [userMessage(3)],
},
isAuthoritativeHistoryReady: true,
}),
);
});
await act(async () => {
await new Promise((resolve) => requestAnimationFrame(resolve));
});
expect(scrollTo).toHaveBeenCalled();
});
});
@@ -117,11 +117,12 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
contentRef.current = node;
}, []);
const [followOutput, setFollowOutputr] = useState(true);
const followOutputRef = useRef(followOutput);
const setFollowOutput = (value: boolean) => {
followOutputRef.current = value;
setFollowOutputr(value);
return value;
};
const followOutputRef = useRef(followOutput);
const lastKnownScrollTopRef = useRef(0);
const pendingUserScrollUpIntentRef = useRef(false);
const isPointerScrollActiveRef = useRef(false);
@@ -277,12 +278,14 @@ function WebStreamViewport(props: StreamRenderInput & { isMobileBreakpoint: bool
const currentScrollTop = scrollContainer.scrollTop;
const isAtBottom = isScrollContainerAtBottom(scrollContainer);
const scrolledUp = currentScrollTop < lastKnownScrollTopRef.current - USER_SCROLL_DELTA_EPSILON;
const scrolledDown =
currentScrollTop > lastKnownScrollTopRef.current + USER_SCROLL_DELTA_EPSILON;
if (!followOutputRef.current && isAtBottom) {
if (!followOutputRef.current && isAtBottom && scrolledDown) {
setFollowOutput(true);
pendingUserScrollUpIntentRef.current = false;
} else if (followOutputRef.current && pendingUserScrollUpIntentRef.current) {
if (scrolledUp) {
if (scrolledUp || !isAtBottom) {
cancelPendingStickToBottom();
setFollowOutput(false);
}
@@ -84,7 +84,7 @@ function HostAgentReadyRouteContent() {
let cancelled = false;
void client
.fetchAgent(agentId)
.fetchAgent({ agentId })
.then((result) => {
if (cancelled || redirectedRef.current) {
return;
@@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
import { usePushTokenRegistration } from "@/hooks/use-push-token-registration";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { refreshAgentInitializationTimeout } from "@/hooks/use-agent-initialization";
import { prefetchProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { generateMessageId, type StreamItem } from "@/types/stream";
import {
@@ -1210,6 +1211,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
error: payload.error,
});
if (followUp?.direction === "after") {
refreshAgentInitializationTimeout({
key: initKey,
agentId,
setAgentInitializing: (id, initializing) => {
if (initializing) {
return;
}
clearAgentInitializingFlag(setInitializingAgents, serverId, id);
},
});
requestCanonicalCatchUp(agentId, {
epoch: followUp.cursor.epoch,
endSeq: followUp.cursor.seq,
@@ -1,12 +1,11 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import {
type AgentCommandsClient,
type DraftCommandConfig,
fetchAgentCommands,
} from "./use-agent-commands-query";
type ListCommands = DaemonClient["listCommands"];
type ListCommands = AgentCommandsClient["listCommands"];
type ListCommandsResult = Awaited<ReturnType<ListCommands>>;
interface ListCommandsCall {
@@ -22,15 +21,8 @@ function createClient(response: ListCommandsResult): FakeAgentCommandsClient {
const calls: ListCommandsCall[] = [];
return {
calls,
listCommands: (async (
agentId: string,
requestIdOrOptions?: string | { draftConfig?: DraftCommandConfig },
) => {
const options =
typeof requestIdOrOptions === "object" && requestIdOrOptions !== null
? requestIdOrOptions
: undefined;
calls.push({ agentId, draftConfig: options?.draftConfig });
listCommands: (async (options: Parameters<ListCommands>[0]) => {
calls.push({ agentId: options.agentId, draftConfig: options.draftConfig });
return response;
}) as ListCommands,
};
@@ -16,14 +16,22 @@ export interface AgentSlashCommand {
export type DraftCommandConfig = AgentCommandsDraftConfig;
export type AgentCommandsClient = Pick<DaemonClient, "listCommands">;
interface ListAgentCommandsOptions {
agentId: string;
draftConfig?: DraftCommandConfig;
}
export interface AgentCommandsClient {
listCommands(options: ListAgentCommandsOptions): ReturnType<DaemonClient["listCommands"]>;
}
export async function fetchAgentCommands(input: {
client: AgentCommandsClient;
agentId: string;
draftConfig?: DraftCommandConfig;
}): Promise<AgentSlashCommand[]> {
const response = await input.client.listCommands(input.agentId, {
const response = await input.client.listCommands({
agentId: input.agentId,
draftConfig: input.draftConfig,
});
return response.commands as AgentSlashCommand[];
@@ -5,6 +5,7 @@ import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-
import {
createSetAgentInitializing,
ensureAgentIsInitialized,
refreshAgentInitializationTimeout,
refreshAgent,
} from "./use-agent-initialization";
@@ -80,7 +81,7 @@ describe("ensureAgentIsInitialized", () => {
expect(getInitDeferred(getInitKey(serverId, agentId))?.requestDirection).toBe("tail");
});
it("times out initialization after 30 seconds", async () => {
it("times out initialization after 65 seconds", async () => {
vi.useFakeTimers();
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
@@ -92,18 +93,50 @@ describe("ensureAgentIsInitialized", () => {
setAgentInitializing: bindSetAgentInitializing(),
});
vi.advanceTimersByTime(29_999);
vi.advanceTimersByTime(64_999);
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeDefined();
vi.advanceTimersByTime(1);
await expect(promise).rejects.toThrow("History sync timed out after 30s");
await expect(promise).rejects.toThrow("History sync timed out after 65s");
expect(getInitDeferred(getInitKey(serverId, agentId))).toBeUndefined();
expect(useSessionStore.getState().sessions[serverId]?.initializingAgents.get(agentId)).toBe(
false,
);
vi.useRealTimers();
});
it("refreshes the initialization timeout after paged catch-up progress", async () => {
vi.useFakeTimers();
const client = makeClient();
useSessionStore.getState().initializeSession(serverId, client as never);
const setAgentInitializing = bindSetAgentInitializing();
const key = getInitKey(serverId, agentId);
const promise = ensureAgentIsInitialized({
serverId,
agentId,
client: client as never,
setAgentInitializing,
});
vi.advanceTimersByTime(64_999);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
vi.advanceTimersByTime(1);
expect(getInitDeferred(key)).toBeDefined();
const rejection = expect(promise).rejects.toThrow("History sync timed out after 65s");
vi.advanceTimersByTime(64_998);
expect(getInitDeferred(key)).toBeDefined();
vi.advanceTimersByTime(1);
await rejection;
expect(getInitDeferred(key)).toBeUndefined();
vi.useRealTimers();
});
});
describe("refreshAgent", () => {
@@ -3,19 +3,36 @@ import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import {
attachInitTimeout,
createInitDeferred,
getInitDeferred,
getInitKey,
INIT_TIMEOUT_MS,
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { planInitialAgentTimelineSync, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
export const INIT_TIMEOUT_MS = 30_000;
export type SetAgentInitializing = (agentId: string, initializing: boolean) => void;
export function createHistorySyncTimeoutError(): Error {
return new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`);
}
export function refreshAgentInitializationTimeout(input: {
key: string;
agentId: string;
setAgentInitializing: SetAgentInitializing;
}): void {
refreshInitTimeout({
key: input.key,
onTimeout: () => {
input.setAgentInitializing(input.agentId, false);
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
},
});
}
export interface EnsureAgentIsInitializedInput {
serverId: string;
agentId: string;
@@ -38,14 +55,7 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
const timelineRequest = planInitialAgentTimelineSync({ cursor, hasAuthoritativeHistory });
const deferred = createInitDeferred(key, timelineRequest.direction);
const timeoutId = setTimeout(() => {
setAgentInitializing(agentId, false);
rejectInitDeferred(
key,
new Error(`History sync timed out after ${Math.round(INIT_TIMEOUT_MS / 1000)}s`),
);
}, INIT_TIMEOUT_MS);
attachInitTimeout(key, timeoutId);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
setAgentInitializing(agentId, true);
+2 -2
View File
@@ -588,7 +588,7 @@ function AgentPanelBody({
const attemptToken = ++lookupAttemptTokenRef.current;
client
.fetchAgent(agentId)
.fetchAgent({ agentId })
.then((result) => {
if (attemptToken !== lookupAttemptTokenRef.current) {
return;
@@ -985,7 +985,7 @@ function ChatAgentContent({
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
if (!currentAgent) {
const result = await client.fetchAgent(agentId);
const result = await client.fetchAgent({ agentId });
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
+11 -2
View File
@@ -7,6 +7,7 @@ export interface DeferredInit {
}
const initPromises = new Map<string, DeferredInit>();
export const INIT_TIMEOUT_MS = 65_000;
export function getInitKey(serverId: string, agentId: string): string {
return `${serverId}:${agentId}`;
@@ -36,12 +37,20 @@ export function createInitDeferred(key: string, requestDirection: "tail" | "afte
return deferred;
}
export function attachInitTimeout(key: string, timeoutId: ReturnType<typeof setTimeout>): void {
const deferred = initPromises.get(key);
export function refreshInitTimeout(input: {
key: string;
onTimeout: () => void;
timeoutMs?: number;
}): void {
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
const deferred = initPromises.get(input.key);
if (!deferred) {
clearTimeout(timeoutId);
return;
}
if (deferred.timeoutId) {
clearTimeout(deferred.timeoutId);
}
deferred.timeoutId = timeoutId;
}