mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
* 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>
126 lines
3.9 KiB
TypeScript
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();
|
|
});
|
|
});
|