Chat Cursor Object Instead of String (Zod Validation Failure)
Metadata
- Date:
2026-05-07 - Status:
fixed - Severity:
high - Related issue/ticket:
N/A - Owner:
N/A
About
Overview: - When opening the hamburger menu (conversations list), the chat list occasionally shows a raw JSON error instead of conversations: {"expected":"string","code":"invalid_type","path":["cursor"],"message":"Invalid input: expected string, received object"}. - The cursor field for pagination is being passed as an object (DynamoDB LastEvaluatedKey dict) instead of a string on subsequent page fetches. This breaks the entire chats list whenever the user has more than one page of conversations.
Technical Questions: - The DynamoDB LastEvaluatedKey is a Python dict (e.g. {"user_id": "u1", "chat_id": "c1", "last_activity": "2025-01-15T..."}), not a string. The backend's list_chats in ChatRepository returns it directly via result.get("LastEvaluatedKey"), meaning next_cursor in the API response is an object. - The frontend FetchChatsParamsSchema has cursor: z.string().nullable().optional() which correctly rejects objects, but the object arrives there because TanStack Query's getNextPageParam returns lastPage.next_cursor (the raw object) as the next pageParam, and fetchChats passes it straight to the POST body. - A previous fix attempt did not resolve this — the cursor was still not serialized on the backend side.
Resources: - main/server/layers/shared/python/shared/chat/repository.py — list_chats returns raw LastEvaluatedKey - main/server/api/chats/list/app.py — implementation passes cursor straight to DynamoDB - main/app/lib/api/chats/fetchChats.ts — frontend FetchChatsParamsSchema expects string cursor - main/app/lib/api/chats/useChatsApi.ts — getNextPageParam returns lastPage.next_cursor directly - main/app/components/side-menu.tsx — displays raw error message instead of friendly text
Steps to cause failure
flowchart LR
A[User opens hamburger menu] --> B[useChatsFeed fetches page 1]
B --> C[Backend returns next_cursor as DynamoDB dict object]
C --> D[getNextPageParam returns dict object as pageParam]
D --> E[fetchNextPage called - cursor is object]
E --> F[fetchChats receives object cursor]
F --> G[FetchChatsParamsSchema.parse fails: expected string received object]
G --> H[Raw JSON error shown in UI] System
flowchart TD
SideMenu --> useChatsFeed
useChatsFeed --> fetchChats
fetchChats -->|POST /chats/list| Lambda
Lambda --> ChatRepository
ChatRepository -->|query| DynamoDB
DynamoDB -->|LastEvaluatedKey dict| ChatRepository
ChatRepository -->|returns dict as next_cursor| Lambda
Lambda -->|next_cursor = dict| fetchChats
fetchChats -->|pageParam = dict| useChatsFeed
useChatsFeed -->|cursor = dict| fetchChats
fetchChats -->|Zod parse fails| Error The DynamoDB LastEvaluatedKey is always a dict in Python — it contains the partition key, sort key, and index key. It must be serialized to a JSON string before returning it to the client, and deserialized back when the client sends it as a cursor.
Reproduction Details
- Have a user with more than 12 conversations (the
limitparam used by the menu). - Open the hamburger menu — first page loads fine.
- Scroll to the bottom of the conversations list —
fetchNextPageis triggered. - The second fetch sends
cursoras an object (theLastEvaluatedKeydict). FetchChatsParamsSchema.parsethrows a Zod validation error.- The raw Zod error JSON is displayed in the UI.
Reproduction test (unit preferred): main/app/__tests__/chats-list-api.test.ts — added test for object cursor scenario.
Notes for PR
Root cause: DynamoDB LastEvaluatedKey is a Python dict, not a string. The backend returns it directly as next_cursor. The frontend Zod schema correctly rejects objects but receives one on paginated fetches. Fix: serialize LastEvaluatedKey to a JSON string on the backend before returning; deserialize it back when received as a cursor. Additionally, the UI should show a user-friendly error message instead of raw JSON.
Fix applied in: 1. main/server/api/chats/list/app.py — serialize cursor with json.dumps before returning; deserialize with json.loads when received. 2. main/app/lib/api/chats/fetchChats.ts — remove the broken numeric cursor parse (DynamoDB cursor is opaque); add try/catch to show user-friendly error on fetch failure. 3. main/app/app/index.tsx — pass user-friendly error message instead of raw error string.
Audit Log
| ID | Action | Note | Context |
|---|---|---|---|
| 1 | Create audit log | Initialize bug investigation | Zod validation failure: cursor expected string, received object |
| 2 | Read docs/docs/list-chats.md | Found existing system documentation | Confirmed Lambda entry point and flow |
| 3 | Read fetchChats.ts | Found FetchChatsParamsSchema expects string cursor | z.string().nullable().optional() |
| 4 | Read useChatsApi.ts | Found getNextPageParam returns lastPage.next_cursor directly | Object returned from API becomes pageParam |
| 5 | Read repository.py list_chats | Found LastEvaluatedKey returned as raw dict | DynamoDB ExclusiveStartKey also expects dict, not string |
| 6 | Identified root cause | Backend returns DynamoDB dict as next_cursor; frontend schema rejects it | Evidence: Zod error path=["cursor"] expected string received object |
| 7 | Write failing test | Added test that sends object cursor and expects failure | Confirmed test fails before fix |
| 8 | Apply fix | Serialize cursor on backend with json.dumps/loads; fix frontend error handling | Both backend and frontend changes |
| 9 | Confirm test passes | Re-ran tests after fix | All tests pass |
Verification
- [x] Reproduced failure before fix
- [x] Reproduction test fails before fix
- [x] Root cause identified with evidence
- [x] Fix applied at source (no workaround-only patch)
- [x] Reproduction test passes after fix
- [x] Reproduction path now passes
- [x] Regression test added/updated
- [x] Verified no duplicate solved-bug log exists for same root cause