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:
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user