Skip to content

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.pylist_chats returns raw LastEvaluatedKey - main/server/api/chats/list/app.pyimplementation passes cursor straight to DynamoDB - main/app/lib/api/chats/fetchChats.ts — frontend FetchChatsParamsSchema expects string cursor - main/app/lib/api/chats/useChatsApi.tsgetNextPageParam 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

  1. Have a user with more than 12 conversations (the limit param used by the menu).
  2. Open the hamburger menu — first page loads fine.
  3. Scroll to the bottom of the conversations list — fetchNextPage is triggered.
  4. The second fetch sends cursor as an object (the LastEvaluatedKey dict).
  5. FetchChatsParamsSchema.parse throws a Zod validation error.
  6. 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