feat: enable paginated event loading for cloud mode (#407)

* feat: enable paginated event loading for cloud mode

Enable scroll-up pagination for cloud backends by passing all search
params (sort_order, page_id, timestamp__gte, timestamp__lt) through to
the cloud proxy. The cloud useLoadOlderEvents hook no longer gates on
`isCloud`, so long conversations load the latest 50 events first and
lazily backfill older pages as the user scrolls up.

The loading indicator now shows 'Fetching older messages…' alongside
the spinner so users know what's happening during pagination.

Depends on: OpenHands/OpenHands#14399 (server-side timestamp fix)
Closes #402

Co-authored-by: openhands <openhands@all-hands.dev>

* address review: add fallback for unpatched cloud backends + tests

- Cloud event search now tries full params first, falls back to
  limit-only on error (graceful degradation for servers without
  OpenHands/OpenHands#14399).
- Added JSDoc note about server dependency to useLoadOlderEvents.
- Updated event-service tests: verify all params forwarded, fallback
  on 500, rethrow on limit-only failure, pagination stop on short page.
- Removed obsolete cloud-disabled test and useActiveBackend mock from
  use-load-older-events tests.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: return empty page on fallback to prevent infinite retries

When an unpatched cloud backend rejects timestamp filters, return an
empty page instead of retrying with limit-only params. The limit-only
fallback would return the same most-recent events already in the store,
which get deduped but leave hasMore=true — causing infinite requests.

An empty page makes useLoadOlderEvents set hasMore=false, cleanly
stopping pagination on unpatched backends.

Co-authored-by: openhands <openhands@all-hands.dev>

* docs: fix stale comment about fallback behavior

Co-authored-by: openhands <openhands@all-hands.dev>

* test: add event pagination e2e coverage

Add deterministic mock conversation fixtures for local and cloud event pagination, plus Playwright regression coverage that verifies initial tail loading and scroll-up older-event backfill for both backend modes.

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: format event pagination params

---------

Co-authored-by: openhands <openhands@all-hands.dev>
This commit is contained in:
Xingyao Wang
2026-05-16 19:53:31 +00:00
committed by GitHub
co-authored by openhands
parent e72b729ce0
commit 2123a2761c
8 changed files with 594 additions and 96 deletions
+74 -9
View File
@@ -26,7 +26,10 @@ beforeEach(() => {
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
vi.mocked(callCloudProxy).mockReset();
vi.mocked(callCloudProxy).mockResolvedValue({ items: [], next_page_id: null });
vi.mocked(callCloudProxy).mockResolvedValue({
items: [],
next_page_id: null,
});
});
afterEach(() => {
@@ -36,10 +39,7 @@ afterEach(() => {
});
describe("EventService.searchEvents — cloud branch", () => {
it("strips timestamp/sort/page params and clamps limit to <=100", async () => {
// Arrange: the caller (e.g. `useLoadOlderEvents`) may pass pagination
// filters the OpenHands SaaS App API can't handle without 500-ing.
// The cloud branch must mirror the cloud frontend's shape: limit only.
it("forwards all pagination params to the cloud proxy and clamps limit to <=100", async () => {
const options = {
limit: 500,
sortOrder: "TIMESTAMP_DESC" as const,
@@ -48,13 +48,78 @@ describe("EventService.searchEvents — cloud branch", () => {
timestampLt: "2026-05-12T07:20:29.087853",
};
// Act
await EventService.searchEvents("conv-1", null, null, options);
// Assert: forwarded path contains only limit=100 (caller's 500 clamped).
const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
expect(proxyCall.path).toBe(
"/api/v1/conversation/conv-1/events/search?limit=100",
const url = new URL(`https://x${proxyCall.path}`);
expect(url.searchParams.get("limit")).toBe("100");
expect(url.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC");
expect(url.searchParams.get("page_id")).toBe("p1");
expect(url.searchParams.get("timestamp__gte")).toBe(
"2026-05-01T00:00:00.000000",
);
expect(url.searchParams.get("timestamp__lt")).toBe(
"2026-05-12T07:20:29.087853",
);
});
it("sends only limit when no filter params are provided", async () => {
await EventService.searchEvents("conv-1", null, null, { limit: 50 });
const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
expect(proxyCall.path).toBe(
"/api/v1/conversation/conv-1/events/search?limit=50",
);
});
it("returns empty page when full-param request fails (graceful degradation)", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
vi.mocked(callCloudProxy).mockRejectedValueOnce(
new Error("Internal Server Error"),
);
const result = await EventService.searchEvents("conv-1", null, null, {
limit: 50,
sortOrder: "TIMESTAMP_DESC",
timestampLt: "2026-05-12T00:00:00.000000",
});
// Only one call — no retry, just returns empty page to stop pagination.
expect(vi.mocked(callCloudProxy)).toHaveBeenCalledTimes(1);
expect(result.items).toHaveLength(0);
expect(result.next_page_id).toBeNull();
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining("doesn't support pagination filters"),
);
warnSpy.mockRestore();
});
it("rethrows when a limit-only request (no filter params) fails", async () => {
vi.mocked(callCloudProxy).mockRejectedValueOnce(
new Error("Network error"),
);
await expect(
EventService.searchEvents("conv-1", null, null, { limit: 50 }),
).rejects.toThrow("Network error");
expect(vi.mocked(callCloudProxy)).toHaveBeenCalledTimes(1);
});
it("stops pagination when server returns fewer items than limit", async () => {
vi.mocked(callCloudProxy).mockResolvedValueOnce({
items: [{ id: "evt-1" }, { id: "evt-2" }],
next_page_id: null,
});
const result = await EventService.searchEvents("conv-1", null, null, {
limit: 50,
sortOrder: "TIMESTAMP_DESC",
});
expect(result.items).toHaveLength(2);
expect(result.next_page_id).toBeNull();
});
});
@@ -15,11 +15,6 @@ import type { EventSearchPage } from "#/api/event-service/event-service.types";
vi.mock("#/api/event-service/event-service.api");
vi.mock("#/hooks/query/use-user-conversation");
const mockUseActiveBackend = vi.fn();
vi.mock("#/contexts/active-backend-context", () => ({
useActiveBackend: () => mockUseActiveBackend(),
}));
function makeConversation(): Conversation {
// Cast: `useUserConversation` actually returns an `AppConversation` whose
// event-host URL lives on `conversation_url` (not the `Conversation.url`
@@ -77,13 +72,6 @@ describe("useLoadOlderEvents", () => {
useEventStore.getState().clearEvents();
});
// Default to a local backend so existing tests exercise the
// pagination path. Individual tests can override to cloud.
mockUseActiveBackend.mockReturnValue({
backend: { kind: "local" },
orgId: null,
});
vi.mocked(useUserConversation).mockReturnValue({
data: makeConversation(),
isLoading: false,
@@ -296,38 +284,6 @@ describe("useLoadOlderEvents", () => {
expect(result.current.hasMore).toBe(true);
});
it("disables REST older-event pagination for cloud backends", async () => {
// Arrange: cloud backend active, plus an anchor event in the store
// that would otherwise let `loadOlder` issue a `timestamp__lt` request.
mockUseActiveBackend.mockReturnValue({
backend: { kind: "cloud" },
orgId: "org-1",
});
act(() => {
useEventStore
.getState()
.addEvent(makeEvent("evt-recent", "2024-06-01T00:00:00Z"));
});
const spy = vi.spyOn(EventService, "searchEvents");
const { result } = renderHook(() => useLoadOlderEvents("conv-1"), {
wrapper,
});
// Act
await act(async () => {
await result.current.loadOlder();
});
// Assert: cloud short-circuits — no request fires and hasMore is false
// from first render so the chat scroll handler never triggers.
expect(spy).not.toHaveBeenCalled();
expect(result.current.hasMore).toBe(false);
});
it("stops paginating and throws when the oldest loaded event is missing a timestamp", async () => {
act(() => {
useEventStore
+44 -20
View File
@@ -113,30 +113,54 @@ class EventService {
// Event *history* lives on the SaaS App API, not the runtime
// sandbox. Path is singular `conversation` and v1-prefixed.
//
// Mirror the OpenHands SaaS frontend's request shape and send ONLY
// `limit`. The SaaS app-server's `search_events` has a server-side
// TypeError when filtering by `timestamp__lt` / `timestamp__gte`
// (it compares the stored `event.timestamp` str against the parsed
// datetime, which raises in Python 3 and surfaces as HTTP 500). The
// cloud frontend has never sent timestamp/sort/page filters here,
// so the broken path is untested; the safe contract is "limit
// only" until the server is fixed. `sort_order` and `page_id` are
// also dropped — neither is part of the proven-working shape, and
// older-event pagination is gated off in `useLoadOlderEvents` for
// cloud, so they have no caller to satisfy.
// Full pagination params (sort_order, page_id, timestamp filters)
// require the server-side fix from OpenHands/OpenHands#14399. If
// the cloud backend hasn't been updated yet, the timestamp filters
// trigger a 500 (str-vs-datetime comparison). We attempt the full
// request first and fall back to a limit-only request on failure.
const cloudLimit = Math.min(limit, 100);
const hasFilterParams = !!(
options.sortOrder ||
options.pageId ||
options.timestampGte ||
options.timestampLt
);
const params = new URLSearchParams();
params.set("limit", String(cloudLimit));
if (options.sortOrder) params.set("sort_order", options.sortOrder);
if (options.pageId) params.set("page_id", options.pageId);
if (options.timestampGte)
params.set("timestamp__gte", options.timestampGte);
if (options.timestampLt) params.set("timestamp__lt", options.timestampLt);
const data = await callCloudProxy<EventSearchPage<OpenHandsEvent>>({
backend: active,
method: "GET",
path: `/api/v1/conversation/${conversationId}/events/search?${params.toString()}`,
});
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
const doCloudSearch = (searchParams: URLSearchParams) =>
callCloudProxy<EventSearchPage<OpenHandsEvent>>({
backend: active,
method: "GET",
path: `/api/v1/conversation/${conversationId}/events/search?${searchParams.toString()}`,
});
try {
const data = await doCloudSearch(params);
return {
items: data?.items ?? [],
next_page_id: data?.next_page_id ?? null,
};
} catch (err) {
if (!hasFilterParams) throw err;
// Server doesn't support timestamp filters yet — stop pagination
// by returning an empty page so the UI doesn't retry indefinitely.
// A limit-only fallback would return the same most-recent events
// already in the store, which get deduped but keep hasMore=true.
console.warn(
"[EventService] Cloud backend doesn't support pagination filters. " +
"Falling back to initial load only. " +
"Server needs OpenHands/OpenHands#14399.",
);
return { items: [], next_page_id: null };
}
}
const page = await new RemoteEventsList(
@@ -433,10 +433,11 @@ export function ChatInterface() {
{isLoadingOlderEvents && (
<div
className="flex justify-center py-2"
className="flex items-center justify-center gap-2 py-3 text-sm text-neutral-400"
data-testid="loading-older-events"
>
<LoadingSpinner size="small" />
<span>{t(I18nKey.CHAT_INTERFACE$FETCHING_OLDER_MESSAGES)}</span>
</div>
)}
+11 -20
View File
@@ -3,7 +3,6 @@ import EventService from "#/api/event-service/event-service.api";
import { useUserConversation } from "#/hooks/query/use-user-conversation";
import { useEventStore } from "#/stores/use-event-store";
import { INITIAL_HISTORY_PAGE_SIZE } from "#/hooks/query/use-conversation-history";
import { useActiveBackend } from "#/contexts/active-backend-context";
import type { OpenHandsEvent } from "#/types/agent-server/core";
const getEventTimestamp = (event: OpenHandsEvent): string | undefined =>
@@ -27,39 +26,32 @@ interface UseLoadOlderEventsResult {
* (`timestamp < oldest known`) into the event store on demand. Used by the
* chat scroll handler to lazily backfill history when the user scrolls up.
*
* Cloud-mode caveat: REST-side older-event pagination is **disabled** for
* cloud backends. The SaaS app-server's `search_events` 500s when called
* with `timestamp__lt` / `timestamp__gte` (compares stored event.timestamp
* `str` against the parsed `datetime` and raises `TypeError`). Until the
* server-side bug at `openhands/app_server/event/event_service_base.py`
* (lines ~101–103) is fixed, the hook reports `hasMore: false` from first
* render and `loadOlder` is a no-op for cloud — matching the OpenHands
* cloud frontend, which never paginates older events either.
* Server dependency: cloud pagination requires the timestamp comparison
* fix from OpenHands/OpenHands#14399. The `EventService.searchEvents`
* cloud path includes a fallback that returns an empty page to stop
* pagination if the full request fails, so older-event pages will
* gracefully degrade to a no-op on unpatched backends rather than
* surfacing errors.
*/
export const useLoadOlderEvents = (
conversationId?: string | null,
): UseLoadOlderEventsResult => {
const { data: conversation } = useUserConversation(conversationId ?? null);
const addEvents = useEventStore((state) => state.addEvents);
const isCloud = useActiveBackend().backend.kind === "cloud";
const [isLoading, setIsLoading] = React.useState(false);
const [hasMore, setHasMore] = React.useState(!isCloud);
const [hasMore, setHasMore] = React.useState(true);
const isLoadingRef = React.useRef(false);
const hasMoreRef = React.useRef(!isCloud);
const hasMoreRef = React.useRef(true);
// Reset the pagination cursor whenever we switch conversations or
// backends. Cloud backends never have more older events to fetch via
// REST (see top-of-file comment), so `hasMore` settles to `false`.
React.useEffect(() => {
hasMoreRef.current = !isCloud;
hasMoreRef.current = true;
isLoadingRef.current = false;
setHasMore(!isCloud);
setHasMore(true);
setIsLoading(false);
}, [conversationId, isCloud]);
}, [conversationId]);
const loadOlder = React.useCallback(async () => {
if (isCloud) return;
if (!conversationId || isLoadingRef.current || !hasMoreRef.current) {
return;
}
@@ -122,7 +114,6 @@ export const useLoadOlderEvents = (
conversation?.conversation_url,
conversation?.session_api_key,
addEvents,
isCloud,
]);
return { isLoading, hasMore, loadOlder };
+17
View File
@@ -25635,6 +25635,23 @@
"uk": "Видалити",
"ca": "Suprimeix"
},
"CHAT_INTERFACE$FETCHING_OLDER_MESSAGES": {
"en": "Fetching older messages…",
"ja": "古いメッセージを取得中…",
"zh-CN": "正在获取较早的消息…",
"zh-TW": "正在擷取較舊的訊息…",
"ko-KR": "이전 메시지를 가져오는 중…",
"no": "Henter eldre meldinger…",
"ar": "جارٍ جلب الرسائل الأقدم…",
"de": "Ältere Nachrichten werden abgerufen…",
"fr": "Récupération des anciens messages…",
"it": "Recupero dei messaggi meno recenti…",
"pt": "Buscando mensagens mais antigas…",
"es": "Obteniendo mensajes anteriores…",
"ca": "S'estan recuperant missatges més antics…",
"tr": "Eski mesajlar getiriliyor…",
"uk": "Отримання старіших повідомлень…"
},
"SETTINGS$CREATE_PROFILE": {
"en": "Create Profile",
"ja": "プロファイルを作成",
+212 -2
View File
@@ -1,8 +1,18 @@
import { http, delay, HttpResponse } from "msw";
import type { DirectConversationInfo } from "#/api/agent-server-adapter";
import type { AppConversation } from "#/api/conversation-service/agent-server-conversation-service.types";
import {
ExecutionStatus,
type OpenHandsEvent,
} from "#/types/agent-server/core";
import { GetMicroagentsResponse } from "#/api/open-hands.types";
const now = Date.now();
const PAGINATION_LOCAL_CONVERSATION_ID = "pagination-local";
const PAGINATION_CLOUD_CONVERSATION_ID = "pagination-cloud";
const PAGINATION_EVENT_COUNT = 100;
const PAGINATION_PAGE_DELAY_MS = 500;
const PAGINATION_BASE_TIME = Date.UTC(2026, 4, 13, 0, 0, 0);
type MockConversation = DirectConversationInfo & {
selected_repository?: string | null;
@@ -10,6 +20,11 @@ type MockConversation = DirectConversationInfo & {
git_provider?: string | null;
};
type CloudProxyEnvelope = {
method?: string;
path?: string;
};
const conversations: MockConversation[] = [
{
id: "1",
@@ -36,12 +51,121 @@ const conversations: MockConversation[] = [
selected_repository: "octocat/earth",
selected_branch: "main",
},
{
id: PAGINATION_LOCAL_CONVERSATION_ID,
title: "Local pagination fixture",
created_at: new Date(PAGINATION_BASE_TIME).toISOString(),
updated_at: new Date(
PAGINATION_BASE_TIME + PAGINATION_EVENT_COUNT * 60_000,
).toISOString(),
execution_status: "idle",
workspace: { working_dir: "/workspace/project" },
},
];
const CONVERSATIONS = new Map<string, MockConversation>(
conversations.map((conversation) => [conversation.id, conversation]),
);
const paginationEventsByConversation = new Map<string, OpenHandsEvent[]>([
[
PAGINATION_LOCAL_CONVERSATION_ID,
createPaginationEvents("Local pagination message"),
],
[
PAGINATION_CLOUD_CONVERSATION_ID,
createPaginationEvents("Cloud pagination message"),
],
]);
function createPaginationEvent(
index: number,
messagePrefix: string,
): OpenHandsEvent {
return {
id: `${messagePrefix.toLowerCase().replaceAll(" ", "-")}-${index}`,
timestamp: new Date(PAGINATION_BASE_TIME + index * 60_000).toISOString(),
source: "agent",
llm_message: {
role: "assistant",
content: [{ type: "text", text: `${messagePrefix} ${index}` }],
},
activated_microagents: [],
extended_content: [],
};
}
function createPaginationEvents(messagePrefix: string): OpenHandsEvent[] {
return Array.from({ length: PAGINATION_EVENT_COUNT }, (_, index) =>
createPaginationEvent(index + 1, messagePrefix),
);
}
function searchPaginationEvents(
events: OpenHandsEvent[],
searchParams: URLSearchParams,
) {
const limit = Number(searchParams.get("limit") ?? "100");
const timestampLt = searchParams.get("timestamp__lt");
const sortOrder = searchParams.get("sort_order");
const filtered = timestampLt
? events.filter((event) => event.timestamp < timestampLt)
: events;
const sorted = [...filtered].sort((a, b) =>
sortOrder === "TIMESTAMP_DESC"
? b.timestamp.localeCompare(a.timestamp)
: a.timestamp.localeCompare(b.timestamp),
);
return {
items: sorted.slice(0, limit),
next_page_id: sorted.length > limit ? "next-page" : null,
};
}
async function maybeReturnPaginationEvents(
conversationId: string,
searchParams: URLSearchParams,
) {
const events = paginationEventsByConversation.get(conversationId);
if (!events) return null;
if (searchParams.has("timestamp__lt")) {
await delay(PAGINATION_PAGE_DELAY_MS);
}
return searchPaginationEvents(events, searchParams);
}
function createCloudPaginationConversation(): AppConversation {
const createdAt = new Date(PAGINATION_BASE_TIME).toISOString();
const updatedAt = new Date(
PAGINATION_BASE_TIME + PAGINATION_EVENT_COUNT * 60_000,
).toISOString();
return {
id: PAGINATION_CLOUD_CONVERSATION_ID,
created_by_user_id: null,
selected_repository: null,
selected_branch: null,
git_provider: null,
title: "Cloud pagination fixture",
trigger: null,
pr_number: [],
llm_model: "openhands/claude-haiku-4-5-20251001",
metrics: null,
created_at: createdAt,
updated_at: updatedAt,
execution_status: ExecutionStatus.IDLE,
conversation_url: null,
session_api_key: null,
sandbox_id: null,
workspace: { working_dir: "/workspace/project" },
public: false,
sub_conversation_ids: [],
};
}
const CLOUD_PAGINATION_CONVERSATION = createCloudPaginationConversation();
function createConversationResponse(
conversation: MockConversation,
): DirectConversationInfo {
@@ -144,8 +268,16 @@ export const CONVERSATION_HANDLERS = [
HttpResponse.json(0),
),
http.get("*/api/conversations/:conversationId/events/search", async () =>
HttpResponse.json({ items: [] }),
http.get(
"*/api/conversations/:conversationId/events/search",
async ({ params, request }) => {
const paginationPage = await maybeReturnPaginationEvents(
params.conversationId as string,
new URL(request.url).searchParams,
);
if (paginationPage) return HttpResponse.json(paginationPage);
return HttpResponse.json({ items: [], next_page_id: null });
},
),
http.post("*/api/conversations/:conversationId/events", async () =>
@@ -160,6 +292,84 @@ export const CONVERSATION_HANDLERS = [
HttpResponse.json({ success: true }),
),
http.post("*/api/cloud-proxy", async ({ request }) => {
const envelope = (await request.json()) as CloudProxyEnvelope;
const upstreamPath = envelope.path ?? "/";
const upstreamUrl = new URL(upstreamPath, "https://mock-cloud.test");
if (upstreamUrl.pathname === "/api/v1/app-conversations") {
const ids = upstreamUrl.searchParams.getAll("ids");
if (ids.length > 0) {
return HttpResponse.json(
ids.map((id) =>
id === PAGINATION_CLOUD_CONVERSATION_ID
? CLOUD_PAGINATION_CONVERSATION
: null,
),
);
}
}
if (upstreamUrl.pathname === "/api/v1/app-conversations/search") {
return HttpResponse.json({
items: [CLOUD_PAGINATION_CONVERSATION],
next_page_id: null,
});
}
if (
upstreamUrl.pathname ===
`/api/v1/conversation/${PAGINATION_CLOUD_CONVERSATION_ID}/events/search`
) {
const paginationPage = await maybeReturnPaginationEvents(
PAGINATION_CLOUD_CONVERSATION_ID,
upstreamUrl.searchParams,
);
return HttpResponse.json(paginationPage);
}
if (upstreamUrl.pathname === "/api/v1/settings") {
return HttpResponse.json({
llm_model: "openhands/claude-haiku-4-5-20251001",
llm_base_url: "",
llm_api_key: null,
llm_api_key_set: false,
search_api_key_set: false,
agent: "CodeActAgent",
language: "en",
user_consents_to_analytics: false,
provider_tokens_set: { github: "" },
});
}
if (upstreamUrl.pathname === "/api/keys/current") {
return HttpResponse.json({
id: "mock-key",
name: "Mock key",
org_id: "org-1",
user_id: "user-1",
auth_type: "api_key",
});
}
if (upstreamUrl.pathname === "/api/organizations") {
return HttpResponse.json({
items: [{ id: "org-1", name: "Mock Org", is_personal: true }],
current_org_id: "org-1",
});
}
if (upstreamUrl.pathname === "/api/organizations/org-1/me") {
return HttpResponse.json({ org_id: "org-1", user_id: "org-1" });
}
if (upstreamUrl.pathname === "/api/authenticate") {
return HttpResponse.json({ ok: true });
}
return HttpResponse.json({});
}),
http.post("*/api/conversations/:conversationId/ask_agent", async () =>
HttpResponse.json({ response: "Mock agent response" }),
),
@@ -0,0 +1,234 @@
import { expect, test, type Page, type Request } from "@playwright/test";
const LOCAL_CONVERSATION_ID = "pagination-local";
const CLOUD_CONVERSATION_ID = "pagination-cloud";
const PAGE_SIZE = 50;
const PAGINATION_BASE_TIME = Date.UTC(2026, 4, 13, 0, 0, 0);
function timestampForEvent(index: number): string {
return new Date(PAGINATION_BASE_TIME + index * 60_000).toISOString();
}
async function seedBackendSelection(
page: Page,
mode: "local" | "cloud",
conversationId: string,
) {
await page.addInitScript(
({ selectedMode, selectedConversationId }) => {
window.localStorage.setItem("analytics-consent", "true");
window.localStorage.setItem("openhands-telemetry-consent", "denied");
window.localStorage.setItem("openhands-telemetry-first-use", "true");
window.localStorage.setItem("openhands-onboarded", "true");
window.localStorage.setItem("conversation-right-panel-shown", "false");
window.localStorage.setItem(
`conversation-right-panel-shown-${selectedConversationId}`,
"false",
);
const localBackend = {
id: "pagination-local-backend",
name: "Local pagination backend",
host: window.location.origin,
apiKey: "",
kind: "local",
};
const cloudBackend = {
id: "pagination-cloud-backend",
name: "Cloud pagination backend",
host: "https://app.all-hands.dev",
apiKey: "mock-cloud-api-key",
kind: "cloud",
};
window.localStorage.setItem(
"openhands-backends",
JSON.stringify(
selectedMode === "cloud"
? [localBackend, cloudBackend]
: [localBackend],
),
);
window.localStorage.setItem(
"openhands-active-backend",
JSON.stringify({
backendId:
selectedMode === "cloud" ? cloudBackend.id : localBackend.id,
orgId: selectedMode === "cloud" ? "org-1" : null,
}),
);
},
{ selectedMode: mode, selectedConversationId: conversationId },
);
}
function eventSearchRequestFor(conversationId: string) {
return (request: Request) => {
if (request.method() !== "GET") return false;
if (
!request
.url()
.includes(`/api/conversations/${conversationId}/events/search`)
) {
return false;
}
return new URL(request.url()).searchParams.has("timestamp__lt");
};
}
function parseCloudProxyPath(request: Request): string {
const body = JSON.parse(request.postData() ?? "{}") as { path?: string };
return body.path ?? "";
}
function cloudEventSearchRequestFor(conversationId: string) {
return (request: Request) => {
if (request.method() !== "POST") return false;
if (!request.url().includes("/api/cloud-proxy")) return false;
const path = parseCloudProxyPath(request);
return (
path.includes(`/api/v1/conversation/${conversationId}/events/search`) &&
path.includes("timestamp__lt")
);
};
}
async function getChatScroller(page: Page) {
const chatInterface = page.getByTestId("chat-interface");
await expect(chatInterface).toBeVisible({ timeout: 15_000 });
const scroller = chatInterface.locator(".custom-scrollbar-always").first();
await expect(scroller).toBeVisible();
return scroller;
}
async function waitForScrollableConversation(page: Page) {
const scroller = await getChatScroller(page);
await expect
.poll(
() =>
scroller.evaluate(
(element) => element.scrollHeight > element.clientHeight,
),
{ timeout: 15_000 },
)
.toBe(true);
return scroller;
}
async function triggerOlderEventLoad(page: Page) {
const scroller = await waitForScrollableConversation(page);
await scroller.evaluate((element) => {
element.scrollTop = 0;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
}
test.describe("conversation event pagination", () => {
test.use({ viewport: { width: 1280, height: 720 } });
test("loads older events when scrolling up on a local backend", async ({
page,
}) => {
await seedBackendSelection(page, "local", LOCAL_CONVERSATION_ID);
const initialRequestPromise = page.waitForRequest((request) => {
if (request.method() !== "GET") return false;
if (
!request
.url()
.includes(`/api/conversations/${LOCAL_CONVERSATION_ID}/events/search`)
) {
return false;
}
return !new URL(request.url()).searchParams.has("timestamp__lt");
});
await page.goto(`/conversations/${LOCAL_CONVERSATION_ID}`);
const initialRequest = await initialRequestPromise;
const initialUrl = new URL(initialRequest.url());
expect(initialUrl.searchParams.get("limit")).toBe(String(PAGE_SIZE));
expect(initialUrl.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC");
await expect(
page.getByText("Local pagination message 100", { exact: true }),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("Local pagination message 50", { exact: true }),
).toHaveCount(0);
const olderRequestPromise = page.waitForRequest(
eventSearchRequestFor(LOCAL_CONVERSATION_ID),
);
await triggerOlderEventLoad(page);
await expect(page.getByTestId("loading-older-events")).toContainText(
"Fetching older messages",
);
const olderRequest = await olderRequestPromise;
const olderUrl = new URL(olderRequest.url());
expect(olderUrl.searchParams.get("limit")).toBe(String(PAGE_SIZE));
expect(olderUrl.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC");
expect(olderUrl.searchParams.get("timestamp__lt")).toBe(
timestampForEvent(51),
);
await expect(
page.getByText("Local pagination message 50", { exact: true }),
).toBeAttached({ timeout: 15_000 });
await expect(page.getByTestId("loading-older-events")).toHaveCount(0);
});
test("loads older events when scrolling up on a cloud backend", async ({
page,
}) => {
await seedBackendSelection(page, "cloud", CLOUD_CONVERSATION_ID);
const initialRequestPromise = page.waitForRequest((request) => {
if (request.method() !== "POST") return false;
if (!request.url().includes("/api/cloud-proxy")) return false;
const path = parseCloudProxyPath(request);
return (
path.includes(
`/api/v1/conversation/${CLOUD_CONVERSATION_ID}/events/search`,
) && !path.includes("timestamp__lt")
);
});
await page.goto(`/conversations/${CLOUD_CONVERSATION_ID}`);
const initialPath = parseCloudProxyPath(await initialRequestPromise);
const initialUrl = new URL(initialPath, "https://mock-cloud.test");
expect(initialUrl.searchParams.get("limit")).toBe(String(PAGE_SIZE));
expect(initialUrl.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC");
await expect(
page.getByText("Cloud pagination message 100", { exact: true }),
).toBeVisible({ timeout: 15_000 });
await expect(
page.getByText("Cloud pagination message 50", { exact: true }),
).toHaveCount(0);
const olderRequestPromise = page.waitForRequest(
cloudEventSearchRequestFor(CLOUD_CONVERSATION_ID),
);
await triggerOlderEventLoad(page);
await expect(page.getByTestId("loading-older-events")).toContainText(
"Fetching older messages",
);
const olderPath = parseCloudProxyPath(await olderRequestPromise);
const olderUrl = new URL(olderPath, "https://mock-cloud.test");
expect(olderUrl.pathname).toBe(
`/api/v1/conversation/${CLOUD_CONVERSATION_ID}/events/search`,
);
expect(olderUrl.searchParams.get("limit")).toBe(String(PAGE_SIZE));
expect(olderUrl.searchParams.get("sort_order")).toBe("TIMESTAMP_DESC");
expect(olderUrl.searchParams.get("timestamp__lt")).toBe(
timestampForEvent(51),
);
await expect(
page.getByText("Cloud pagination message 50", { exact: true }),
).toBeAttached({ timeout: 15_000 });
await expect(page.getByTestId("loading-older-events")).toHaveCount(0);
});
});