Skip to content

v2: add parentID filter to session list #34936

Description

@opencode-agent

Summary

Add a V2 session-list query option for filtering by parent session. Callers like the TUI session picker and --continue need to select resumable root sessions without fetching recent subagent sessions first and filtering them client-side. The same parameter can also support listing direct children of a session.

Problem

Some session-list callers want only top-level sessions where parentID is absent/null. Today they have to request a page of sessions and then filter children out locally. That breaks when recent subagent sessions fill the page: limit is applied before client-side filtering, so older root sessions can be hidden even though they should be selectable/resumable.

This also pushes API pagination edge cases into callers. A caller that needs 50 root sessions cannot reliably ask for 50 sessions and filter !parentID; it has to over-fetch, paginate, and guess how many child sessions might appear.

There is also a natural adjacent need to list direct child sessions for a given parent session.

Proposed API shape

Support a parent filter on the V2 session list endpoint:

session.list({})
// all sessions, current behavior

session.list({
  parentID: null,
  limit: 50,
  order: "desc",
  directory,
  workspace,
})
// only root/top-level sessions

session.list({
  parentID: "ses_...",
  limit: 50,
  order: "desc",
})
// direct children of the given parent session

Semantics:

  • Omitted parentID means no parent filtering: return all matching sessions.
  • parentID: null means sessions whose parent_id IS NULL.
  • parentID: SessionID means sessions whose parent_id = SessionID.
  • Filtering happens before limit/pagination.
  • Cursor anchors should use the same sort column as the query.
  • Generated clients/SDKs should expose the query option as SessionID | null.

HTTP query encoding can decode ?parentID=null to null in the protocol layer, while SDK users see the natural JS shape above.

Relevant draft patch excerpts

The original draft patch used roots: true. The core behavior should be preserved, but expressed through parentID: null | SessionID instead.

Protocol query field, adjusted from the draft shape:

const SessionsQueryFields = {
  limit: PositiveInt.pipe(Schema.optional),
  order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional).annotate({
    description: "Session order for the first page. Use desc for newest first or asc for oldest first.",
  }),
  search: Schema.optional(Schema.String),
  parentID: Schema.Union(
    Session.ID,
    Schema.Literal("null").pipe(
      Schema.decodeTo(Schema.Null, {
        decode: SchemaGetter.transform(() => null),
        encode: SchemaGetter.transform(() => "null"),
      }),
    ),
  ).pipe(Schema.optional).annotate({
    description: "Filter by parent session. Use null to return only root sessions.",
  }),
}

Core list input/query handling, adjusted from the draft shape:

const ListInputBase = {
  search: Schema.String.pipe(Schema.optional),
  limit: PositiveInt.pipe(Schema.optional),
  order: Schema.Literals(["asc", "desc"]).pipe(Schema.optional),
  parentID: Schema.Union(Session.ID, Schema.Null).pipe(Schema.optional),
  anchor: ListAnchor.pipe(Schema.optional),
}
import { and, asc, desc, eq, gt, isNull, like, lt, or, type SQL } from "drizzle-orm"

const sortColumn = SessionTable.time_updated
const conditions: SQL[] = []
if ("directory" in input) conditions.push(eq(SessionTable.directory, input.directory))
if (input.workspaceID) conditions.push(eq(SessionTable.workspace_id, input.workspaceID))
if ("project" in input) conditions.push(eq(SessionTable.project_id, input.project))
if (input.search) conditions.push(like(SessionTable.title, `%${input.search}%`))
if ("parentID" in input) {
  conditions.push(input.parentID === null ? isNull(SessionTable.parent_id) : eq(SessionTable.parent_id, input.parentID))
}

Pagination anchors should match the chosen sort column:

anchor: {
  id: first.id,
  time: DateTime.toEpochMillis(first.time.updated),
  direction: "previous",
}
anchor: {
  id: last.id,
  time: DateTime.toEpochMillis(last.time.updated),
  direction: "next",
}

Generated clients need to pass the query through:

export type SessionListInput = {
  readonly limit?: number | undefined
  readonly order?: "asc" | "desc" | undefined
  readonly search?: string | undefined
  readonly parentID?: SessionID | null | undefined
  readonly directory?: string | undefined
  readonly project?: string | undefined
  readonly subpath?: string | undefined
}
query: {
  limit: input?.["limit"],
  order: input?.["order"],
  search: input?.["search"],
  parentID: input?.["parentID"],
  directory: input?.["directory"],
  project: input?.["project"],
  subpath: input?.["subpath"],
}

TUI callers can then avoid local filtering/over-fetching:

sdk.api.session.list({
  limit: 1,
  order: "desc",
  parentID: null,
  directory: location.directory,
  workspace: location.workspaceID,
})
client.session.list({
  search: query,
  limit: 50,
  order: "desc",
  parentID: null,
  directory: location.directory,
  workspace: location.workspaceID,
})

Suggested regression test from the draft patch, adjusted to parentID: null:

it.effect("filters roots by updated recency before applying the page limit", () =>
  Effect.gen(function* () {
    const session = yield* SessionV2.Service
    const { db } = yield* Database.Service
    const staleRoot = yield* session.create({ location, title: "stale root" })
    const root = yield* session.create({ location, title: "root" })
    const children = yield* Effect.forEach(Array.from({ length: 60 }), (_, index) =>
      session.create({ parentID: root.id, title: `child ${index}` }),
    )

    yield* Effect.forEach(children, (item, index) =>
      db
        .update(SessionTable)
        .set({ time_created: index + 100, time_updated: index + 20_000 })
        .where(eq(SessionTable.id, item.id))
        .run(),
    )
    yield* db
      .update(SessionTable)
      .set({ time_created: 2, time_updated: 5_000 })
      .where(eq(SessionTable.id, staleRoot.id))
      .run()
    yield* db
      .update(SessionTable)
      .set({ time_created: 1, time_updated: 10_000 })
      .where(eq(SessionTable.id, root.id))
      .run()

    const page = yield* session.list({ directory: location.directory, parentID: null, limit: 1, order: "desc" })

    expect(page.map((item) => item.id)).toEqual([root.id])
  }),
)

Add coverage for direct children too:

const children = yield* session.list({ parentID: root.id })
expect(children.every((item) => item.parentID === root.id)).toBe(true)

Note: the draft patch also showed generated SDK drift for a plugin API. That should be excluded unless it is expected generator output for the final branch.

Acceptance criteria

  • V2 session list accepts and documents parentID?: SessionID | null.
  • Omitted parentID preserves current all-session behavior.
  • parentID: null filters in the database query with parent_id IS NULL before pagination/limit.
  • parentID: SessionID filters in the database query with parent_id = SessionID before pagination/limit.
  • Pagination anchors are consistent with the list sort column.
  • Generated clients and SDKs expose/pass through the option.
  • TUI root-session callers such as --continue and session pickers use parentID: null instead of fetching children and filtering locally.
  • Regression coverage proves recent child sessions do not hide root sessions when parentID: null and a small limit are used.

Related

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions