Skip to content

Session list "Load More" pagination broken when filtering by directory #30109

Closed as not planned

Description

@nanshanteahouse

Description

Two related bugs found:

Bug 1: GET /session ignores offset when directory is specified

The "Load More" button in the session list does nothing. The frontend loadMore logic increments the limit parameter and re-fetches sessions, but the server ignores offset when directory is present, and limit is capped at the total number of sessions for that directory — so pagination never advances beyond the first page.

Verified via curl:

# Without directory (limit & offset work correctly)
GET /session?limit=5  → 5 sessions
GET /session?limit=50 → 50 sessions

# With directory: limit capped at total, offset completely ignored
GET /session?directory=...&limit=5   → 5  (OK, ≤ 18 total)
GET /session?directory=...&limit=50  → 18 (capped, expected 50)
GET /session?directory=...&limit=200 → 18 (capped, expected 200)

# offset: always returns the first session regardless of value
GET /session?directory=...&offset=0&limit=1  → ses_181a21538...
GET /session?directory=...&offset=10&limit=1 → ses_181a21538... (SAME!)

The frontend code calls client.session.list({ directory, roots: true, limit: N }) with an increasing limit on each "Load More" click. Since offset is ignored and limit is capped, the same data is returned every time — the UI never shows new sessions.

Relevant frontend code path (found in the minified JS bundle served by the OpenCode web server):

// KZ() wraps the session list call with limit
function KZ(t) {
  try {
    return {
      data: (await t.list({directory: t.directory, roots: true, limit: t.limit})).data,
      limit: t.limit,
      limited: true
    }
  } catch {
    return {
      data: (await t.list({directory: t.directory, roots: true})).data,
      limit: t.limit,
      limited: false
    }
  }
}

The loadMore handler increments limit by 5 and calls loadSessions:

I = async () => {
  l("limit", M => (M ?? 0) + 5),
  await r.project.loadSessions(t.directory)
}

Since offset is ignored and limit is capped at the directory's total sessions, the same data is returned every time and the session count never increases.

Bug 2: /api/session?roots=true crashes on time_archived type mismatch

The cursor-based pagination endpoint GET /api/session works fine for non-archived sessions, but breaks when roots=true causes archived sessions to appear in results:

{
  "_tag": "InvalidRequestError",
  "message": "Expected DateTime.Utc, got 1780160907969\n  at [\"items\"][6][\"time\"][\"archived\"]",
  "kind": "Body"
}

Why roots=true is the trigger: all 140 archived sessions in the DB are root sessions (no parent_id). Without roots=true, archived sessions are excluded from results and the endpoint works fine. With roots=true, the first archive-eligible root session appears at index 6 and the serializer crashes.

# Works — no archived sessions in results
GET /api/session?limit=10             → 200 OK, items + cursor pagination

# Works (small limit avoids archived sessions)
GET /api/session?roots=true&limit=5   → 200 OK (no archived sessions in first 5)

# Breaks — archived root at index 6
GET /api/session?roots=true&limit=20  → 500 InvalidRequestError

Root cause: the session.time_archived column stores Unix millisecond timestamps as integers (e.g., 1780160907969), but the API serializer expects DateTime.Utc format (ISO 8601 string).

Database schema:

`time_archived` integer,   -- stored as integer (ms timestamp)

Plugins

oh-my-openagent@latest (4.5.12)

OpenCode version

1.15.13

Steps to reproduce

Bug 1 — Pagination params ignored with directory filter:

  1. Open any project's session list (web UI or TUI)
  2. Count the initially loaded sessions
  3. Click "Load More" / "加载更多"
  4. Nothing happens — the button appears to be unresponsive, no new sessions load

Bug 2 — /api/session?roots=true datetime parse error:

  1. Archive a session (sets time_archived to integer ms timestamp in DB)
  2. Request GET /api/session?roots=true&limit=20
  3. Receive InvalidRequestError — Expected DateTime.Utc, got <integer>
  4. Note: without roots=true the endpoint works fine

Screenshot and/or share link

N/A (API-level bugs, reproducible via curl)

Operating System

Ubuntu 26.04 (WSL)

Terminal

N/A (Tested via web UI + curl)

Activity

  1. lexlian commented on Jun 1, 2026

    @lexlian

    I fixed the issue with PR #30217 — turned out to be two unrelated bugs in the session list, both worth landing in one PR.

    Bug 1 — "Load More" is stuck. With directory= set, GET /session?offset=N&limit=L was returning the same first page no matter what offset you sent. The frontend's "Load More" was effectively a no-op whenever you were scoped to a directory.

    • Root cause: offset was never declared on ListQuery, never on ListInput, never read off ctx.query in the handler, and never applied to the database query in listByProject — every layer silently dropped it, so the server was always just running the same "give me the first limit rows" query regardless of what the client sent.
    • Fix: plumbed offset through all four layers — added the field to ListQuery and ListInput, forwarded it in the handler, and added the matching OFFSET to the SQL so the server actually skips past the first offset rows.

    Bug 2 — ?roots=true returns 500. The v2 Info.time.archived field was wrapped in optionalOmitUndefined(V2Schema.DateTimeUtcFromMillis), and any query that hit an archived root session blew up with Expected DateTime.Utc, got 1780160907969. The v1 path was fine, which is why this only surfaced on the v2 endpoint.

    • Root cause: the v2 field wraps DateTimeUtcFromMillis, which is itself a schema that converts between a number (epoch millis) and a DateTime.Utc value in both directions. The bug is in how optionalOmitUndefined composes with that — when it builds the optional wrapper, it accidentally double-applies the number-to-DateTime conversion, so by the time the outer encoder runs it's already looking at a number but expecting a DateTime.Utc and throws. The v1 Info.time.archived doesn't have this problem because it wraps plain Schema.Finite (a number is a number, no conversion involved), so the wrapper just works as expected. Only the v2 wrapper around DateTimeUtcFromMillis hits the edge case.
    • Fix: swapped the wrapper for plain Schema.optional(...) to match what packages/core/src/session/message.ts already does for time.completed / time.ran / time.pruned. The wire format is now identical to the bare DateTimeUtcFromMillis field, no observable change for working inputs.
  2. github-actions commented on Aug 1, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions