feat(agent_manager): add pagination and lazy loading for chat history (#116) - #124
Conversation
…response envelope
|
Thanks for the thorough review @AmitAvital1! All 20 suggestions have been implemented and verified across the backend, database, frontend widget, and test suite. Key Changes Implemented:
Verification Results:
|
|
@rishu685 Thanks for the write-up, but none of it is on the PR yet, so I want to flag that before you spend more time. The head is still Could you push? One thing worth ruling out on the test run. Under import agent_manager.domain as d
print(d.__file__, hasattr(d, "PaginatedSessions"))If that prints a path outside your tree, force it with Two things from your description that I'd fix before pushing: The index won't serve the sort. Sorting on The cursor has to encode the coalesced value too. If it still encodes Also, on I've consolidated the inline comments so each finding sits in one place. Push whenever you're ready and I'll review the real diff. |
There was a problem hiding this comment.
Requesting changes at d25be318.
The approach is right and the core keyset logic holds up. Paging both backends across ties, NULLs, and every page size from 1 to 4 produced identical, complete, duplicate-free sequences, ConversationService stays a thin pass-through, and the suite is green at 870 passed with lint and mypy clean. Nothing below requires rethinking the design.
Six things to resolve before merge:
- A malformed cursor returns HTTP 500. This is the only route in
conversations.pywithout an error-mapping wrapper, and an empty?cursor=reaches the decoder too. - Cursors aren't timezone-normalized. A naive cursor raises
TypeErrorin the memory backend, and an offset-carrying cursor silently re-emits the boundary row on SQLite. - The new query has no supporting index and no migration.
EXPLAIN QUERY PLANshows a temp B-tree sort on every page, so the change doesn't yet deliver the performance it exists for. - Empty conversations now come back in arbitrary order, since dropping the
created_atfallback leavesuuid4().hexas the effective sort key. Theset()-based assertion intest_pagination.pyhides it. memory_repository.pyimports the cursor codec fromsql_repository.py. Sibling adapters behind one port shouldn't depend on each other, and it's why the codec has no owner and the two ended up disagreeing on its format.- No ADR, for two simultaneous contract changes, against three separate written policies in this repo.
Beyond the fixes, the two design points worth taking are a generic Page[T] and a PageRequest that owns the page-size default and cap. That's what makes this pagination the repo has rather than pagination this endpoint has, which is the goal in #116.
Most of the test gaps close by moving the pagination cases into test_repository_contract.py, where they run against both adapters and would have caught items 2 and 4.
…a-org#124 (extra-org#116) - Align database expression index on COALESCE(last_message_at, created_at) - Fix cursor codec and keyset predicate to encode coalesced active timestamps - Enforce PageRequest limits at domain/port level - Handle empty string cursors as absent, map InvalidCursorError to 400 Bad Request - Add request generation counter in React widget to eliminate stale async race conditions - Remove temporary Page.sessions property shim - Expand page-walk test suite for exact sequence ordering over mixed NULL/non-NULL data
AmitAvital1
left a comment
There was a problem hiding this comment.
This is a big step and most of it landed properly. I re-ran everything rather than going by the description:
malformed cursor 500 plain text -> 400 {"error_type": "invalid_cursor", ...}
cursor payload '2026-08-20T14:00:00' -> '2025-12-22T00:00:00+00:00'
ordering [s0, s1, s2, new] -> [new, s0, s1, s2]
query plan USE TEMP B-TREE -> USING INDEX ... (user_id=? AND <expr><?)
The index is an expression index matching the COALESCE sort, with migration 0005, and page N is a real range seek rather than a sort. Both backends now derive ordering and the keyset predicate from one definition. 875 passed, ruff and mypy clean.
Also resolved: Page[T] and PageRequest with single constants, a contract test over both adapters, an e2e spec that actually walks a second page, the API docs, and the widget work — generation counter plus a synchronous ref for the races, any removed, hasMoreThreads derived, and the unrelated churn reverted. Replacing the .sessions shim with a plain type alias was the better call.
Two things left before I can approve:
domain/__init__.pyandapplication/errors.pynow import frominfrastructure. The sibling-adapter problem is fixed, but the replacement points the dependency outward from the innermost layer. One file move.- Still no ADR, and the response-shape break still has no
BREAKING CHANGE:footer.
The rest are small and inline.
…iew findings - Move pagination codec and InvalidCursorError to domain/pagination.py (eliminates domain -> infra dependency) - Export public ensure_utc helper function across repositories - Explicitly normalize tz-naive SQL row timestamps before cursor encoding - Align table index definition in tables.py with Alembic migration 0005 using text(...) - Add explicit message timestamps in contract tests for deterministic pagination order - Update loadMoreThreads comment in AgentChatApp.tsx
BREAKING CHANGE: GET /conversations response shape changed from flat list [ConversationSummary] to paginated envelope { items: [ConversationSummary], next_cursor: str | null }
…t tests - Add error handling and retry UI in thread drawer - Add Load More button for non-overflowing list viewports - Annotate Page[T].items as Sequence[T] - Expand contract test to 5 sessions with mixed last_message_at and created_at timestamps
AmitAvital1
left a comment
There was a problem hiding this comment.
Approving. Everything I raised is addressed, and I re-verified the behaviour rather than reading the diff alone:
malformed cursor -> 400 {"error_type": "invalid_cursor", "message": "invalid pagination cursor"}
cursor payload -> {"t": "2025-12-22T00:00:00+00:00", "id": "s0"}
ordering -> ['new', 's0', 's1', 's2'] full walk, no gaps, no duplicates
query plan -> SEARCH ... USING INDEX idx_conversation_sessions_user_active_session
(user_id=? AND <expr><?)
875 passed, ruff, mypy and generate-check all clean.
The layering fix is the right one: moving the whole codec into domain/pagination.py puts the cursor contract where both adapters can depend on it inward, rather than shuffling the violation somewhere else. ADR 0003 is more thorough than I asked for — recording why offset was rejected and why the sort key is COALESCE(last_message_at, created_at) is exactly what a future reader will need, and the BREAKING CHANGE: footer means the release will say so too. The contract test now runs five sessions with mixed dated and never-messaged rows and a shared timestamp for the tiebreak, across both adapters, which is what makes the two keyset implementations trustworthy rather than coincidentally equal.
The drawer error notice with a Retry button is a better answer than what I suggested, and the "Load more" button resolves the no-overflow case at the same time.
Two small things inline, neither blocking. Happy for this to go in with or without them.
Nice work — this moved a long way in a short time.
| ) | ||
|
|
||
| # Backward-compatibility private alias | ||
| _utc = ensure_utc |
There was a problem hiding this comment.
This alias has no callers — everything uses ensure_utc directly.
More broadly, this whole module is now a re-export of domain.pagination, and the only thing importing it is tests/agent_manager/test_pagination.py:16. Both adapters already import from domain.pagination.
Pointing that test at agent_manager.domain.pagination lets the file be deleted. Same reasoning as dropping the .sessions shim earlier: nothing outside the repo depends on this path, so there's no compatibility to preserve.
| ## Consequences | ||
|
|
||
| - Clients fetch subsequent pages using `next_cursor` until `next_cursor` is `null`. | ||
| - Keyset range seeks eliminate `OFFSET` database performance degradation and prevent skipped/duplicated sessions when thread activity changes mid-page. |
There was a problem hiding this comment.
This claims more than keyset can deliver. Because the sort key is mutable, a thread that receives a message while someone is paging jumps ahead of their cursor and is skipped on the page they were about to load. That's inherent to keyset over a mutable column, and it's the reason the next line says frontends deduplicate — if skips and duplicates were prevented, no dedup would be needed.
Worth stating accurately, since the ADR is what the next reader will trust:
Keyset range seeks eliminate
OFFSETperformance degradation and keep page boundaries stable against inserts. Becauselast_message_atis mutable, a thread that becomes active mid-scroll can move across page boundaries; the listing is a snapshot from when paging began, and clients deduplicate byconversation_id.
Matches the comment already in loadMoreThreads.
AmitAvital1
left a comment
There was a problem hiding this comment.
Re-approving against df28800, since my previous approval landed on b5e75783 a moment after you'd already pushed past it.
Worth recording why that matters: b5e75783 did not pass this project's typecheck gate. I ran mypy src when the Makefile runs mypy $(SRC) $(TESTS), so I missed three errors in test_repository_contract.py — the Sequence concatenation and the _sessions access on the base Repository type. You'd caught and fixed them in a8f8352 before I even submitted. My verification was narrower than the gate; that's on me, and the approval you had was on a commit CI would have rejected.
Re-ran the full gate on df28800:
ruff format --check 322 files already formatted
ruff check All checks passed!
mypy src tests Success: no issues found in 275 source files
pytest 875 passed
generate-check clean
And re-confirmed the behaviour: malformed cursor returns 400 {"error_type": "invalid_cursor"}, the cursor payload carries +00:00, and a full page walk returns ['new', 's0', 's1', 's2'] with no gaps or duplicates.
The two notes from my last review are still open and still non-blocking: the infrastructure/persistence/pagination.py re-export shim, and the "prevent skipped/duplicated sessions" line in the ADR's consequences. Fine either way.
Summary of Changes
Closes #116
PaginatedSessionsdataclass and updatedRepository.list_sessions(user_id, limit=50, cursor=None).sql_repository.py: Implemented opaque URL-safe base64 cursors,ORDER BY last_message_at DESC NULLS LAST, session_id DESC, andlimit + 1row fetching fornext_cursordetermination.memory_repository.py: Implemented matching cursor filtering and sorting logic.ConversationService.list_conversationsto returnPaginatedSessions.GET /conversationsendpoint with query parameterslimit(default 20, 1-100) andcursor, returningPaginatedConversationsResponse.AgentChatClient.ts&useConversation.ts: Addedlimitandcursorpass-throughs.AgentChatApp.tsx: Implemented infinite scroll inThreadDrawerwith scroll proximity triggers, deduplication, and loading indicator.tests/agent_manager/test_pagination.pycovering cursor encoding, multi-page database navigation, ordering, and API endpoints. Updated existing test suite for paginated response envelopes. All 679 tests passing.