Files
OpenHands/__tests__/api/event-service.test.ts
T
Xingyao Wangandopenhands 2123a2761c 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>
2026-05-16 19:53:31 +00:00

126 lines
3.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import EventService from "#/api/event-service/event-service.api";
import { callCloudProxy } from "#/api/cloud/proxy";
import type { Backend } from "#/api/backend-registry/types";
vi.mock("#/api/cloud/proxy", () => ({
callCloudProxy: vi.fn(),
}));
const cloudBackend: Backend = {
id: "cloud-1",
name: "Production",
host: "https://app.all-hands.dev",
apiKey: "cloud-key",
kind: "cloud",
};
beforeEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
setRegisteredBackends([cloudBackend]);
setActiveSelection({ backendId: cloudBackend.id, orgId: "org-1" });
vi.mocked(callCloudProxy).mockReset();
vi.mocked(callCloudProxy).mockResolvedValue({
items: [],
next_page_id: null,
});
});
afterEach(() => {
window.localStorage.clear();
__resetActiveStoreForTests();
vi.mocked(callCloudProxy).mockReset();
});
describe("EventService.searchEvents — cloud branch", () => {
it("forwards all pagination params to the cloud proxy and clamps limit to <=100", async () => {
const options = {
limit: 500,
sortOrder: "TIMESTAMP_DESC" as const,
pageId: "p1",
timestampGte: "2026-05-01T00:00:00.000000",
timestampLt: "2026-05-12T07:20:29.087853",
};
await EventService.searchEvents("conv-1", null, null, options);
const proxyCall = vi.mocked(callCloudProxy).mock.calls[0][0];
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();
});
});