feat: add list sessions for all sessions (experimental) (#14038)

This commit is contained in:
Ryan Vogel
2026-02-20 08:45:12 -05:00
committed by GitHub
parent 7e681b0bc0
commit 7419ebc872
3 changed files with 239 additions and 1 deletions

View File

@@ -6,6 +6,7 @@ import { Worktree } from "../../worktree"
import { Instance } from "../../project/instance"
import { Project } from "../../project/project"
import { MCP } from "../../mcp"
import { Session } from "../../session"
import { zodToJsonSchema } from "zod-to-json-schema"
import { errors } from "../error"
import { lazy } from "../../util/lazy"
@@ -184,6 +185,65 @@ export const ExperimentalRoutes = lazy(() =>
return c.json(true)
},
)
.get(
"/session",
describeRoute({
summary: "List sessions",
description:
"Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
operationId: "experimental.session.list",
responses: {
200: {
description: "List of sessions",
content: {
"application/json": {
schema: resolver(Session.GlobalInfo.array()),
},
},
},
},
}),
validator(
"query",
z.object({
directory: z.string().optional().meta({ description: "Filter sessions by project directory" }),
roots: z.coerce.boolean().optional().meta({ description: "Only return root sessions (no parentID)" }),
start: z.coerce
.number()
.optional()
.meta({ description: "Filter sessions updated on or after this timestamp (milliseconds since epoch)" }),
cursor: z.coerce
.number()
.optional()
.meta({ description: "Return sessions updated before this timestamp (milliseconds since epoch)" }),
search: z.string().optional().meta({ description: "Filter sessions by title (case-insensitive)" }),
limit: z.coerce.number().optional().meta({ description: "Maximum number of sessions to return" }),
archived: z.coerce.boolean().optional().meta({ description: "Include archived sessions (default false)" }),
}),
),
async (c) => {
const query = c.req.valid("query")
const limit = query.limit ?? 100
const sessions: Session.GlobalInfo[] = []
for await (const session of Session.listGlobal({
directory: query.directory,
roots: query.roots,
start: query.start,
cursor: query.cursor,
search: query.search,
limit: limit + 1,
archived: query.archived,
})) {
sessions.push(session)
}
const hasMore = sessions.length > limit
const list = hasMore ? sessions.slice(0, limit) : sessions
if (hasMore && list.length > 0) {
c.header("x-next-cursor", String(list[list.length - 1].time.updated))
}
return c.json(list)
},
)
.get(
"/resource",
describeRoute({

View File

@@ -10,8 +10,10 @@ import { Flag } from "../flag/flag"
import { Identifier } from "../id/id"
import { Installation } from "../installation"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like } from "../storage/db"
import { Database, NotFoundError, eq, and, or, gte, isNull, desc, like, inArray, lt } from "../storage/db"
import type { SQL } from "../storage/db"
import { SessionTable, MessageTable, PartTable } from "./session.sql"
import { ProjectTable } from "../project/project.sql"
import { Storage } from "@/storage/storage"
import { Log } from "../util/log"
import { MessageV2 } from "./message-v2"
@@ -154,6 +156,24 @@ export namespace Session {
})
export type Info = z.output<typeof Info>
export const ProjectInfo = z
.object({
id: z.string(),
name: z.string().optional(),
worktree: z.string(),
})
.meta({
ref: "ProjectSummary",
})
export type ProjectInfo = z.output<typeof ProjectInfo>
export const GlobalInfo = Info.extend({
project: ProjectInfo.nullable(),
}).meta({
ref: "GlobalSession",
})
export type GlobalInfo = z.output<typeof GlobalInfo>
export const Event = {
Created: BusEvent.define(
"session.created",
@@ -544,6 +564,75 @@ export namespace Session {
}
}
export function* listGlobal(input?: {
directory?: string
roots?: boolean
start?: number
cursor?: number
search?: string
limit?: number
archived?: boolean
}) {
const conditions: SQL[] = []
if (input?.directory) {
conditions.push(eq(SessionTable.directory, input.directory))
}
if (input?.roots) {
conditions.push(isNull(SessionTable.parent_id))
}
if (input?.start) {
conditions.push(gte(SessionTable.time_updated, input.start))
}
if (input?.cursor) {
conditions.push(lt(SessionTable.time_updated, input.cursor))
}
if (input?.search) {
conditions.push(like(SessionTable.title, `%${input.search}%`))
}
if (!input?.archived) {
conditions.push(isNull(SessionTable.time_archived))
}
const limit = input?.limit ?? 100
const rows = Database.use((db) => {
const query =
conditions.length > 0
? db
.select()
.from(SessionTable)
.where(and(...conditions))
: db.select().from(SessionTable)
return query.orderBy(desc(SessionTable.time_updated), desc(SessionTable.id)).limit(limit).all()
})
const ids = [...new Set(rows.map((row) => row.project_id))]
const projects = new Map<string, ProjectInfo>()
if (ids.length > 0) {
const items = Database.use((db) =>
db
.select({ id: ProjectTable.id, name: ProjectTable.name, worktree: ProjectTable.worktree })
.from(ProjectTable)
.where(inArray(ProjectTable.id, ids))
.all(),
)
for (const item of items) {
projects.set(item.id, {
id: item.id,
name: item.name ?? undefined,
worktree: item.worktree,
})
}
}
for (const row of rows) {
const project = projects.get(row.project_id) ?? null
yield { ...fromRow(row), project }
}
}
export const children = fn(Identifier.schema("session"), async (parentID) => {
const project = Instance.project
const rows = Database.use((db) =>

View File

@@ -0,0 +1,89 @@
import { describe, expect, test } from "bun:test"
import { Instance } from "../../src/project/instance"
import { Project } from "../../src/project/project"
import { Session } from "../../src/session"
import { Log } from "../../src/util/log"
import { tmpdir } from "../fixture/fixture"
Log.init({ print: false })
describe("Session.listGlobal", () => {
test("lists sessions across projects with project metadata", async () => {
await using first = await tmpdir({ git: true })
await using second = await tmpdir({ git: true })
const firstSession = await Instance.provide({
directory: first.path,
fn: async () => Session.create({ title: "first-session" }),
})
const secondSession = await Instance.provide({
directory: second.path,
fn: async () => Session.create({ title: "second-session" }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).toContain(firstSession.id)
expect(ids).toContain(secondSession.id)
const firstProject = Project.get(firstSession.projectID)
const secondProject = Project.get(secondSession.projectID)
const firstItem = sessions.find((session) => session.id === firstSession.id)
const secondItem = sessions.find((session) => session.id === secondSession.id)
expect(firstItem?.project?.id).toBe(firstProject?.id)
expect(firstItem?.project?.worktree).toBe(firstProject?.worktree)
expect(secondItem?.project?.id).toBe(secondProject?.id)
expect(secondItem?.project?.worktree).toBe(secondProject?.worktree)
})
test("excludes archived sessions by default", async () => {
await using tmp = await tmpdir({ git: true })
const archived = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "archived-session" }),
})
await Instance.provide({
directory: tmp.path,
fn: async () => Session.setArchived({ sessionID: archived.id, time: Date.now() }),
})
const sessions = [...Session.listGlobal({ limit: 200 })]
const ids = sessions.map((session) => session.id)
expect(ids).not.toContain(archived.id)
const allSessions = [...Session.listGlobal({ limit: 200, archived: true })]
const allIds = allSessions.map((session) => session.id)
expect(allIds).toContain(archived.id)
})
test("supports cursor pagination", async () => {
await using tmp = await tmpdir({ git: true })
const first = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-one" }),
})
await new Promise((resolve) => setTimeout(resolve, 5))
const second = await Instance.provide({
directory: tmp.path,
fn: async () => Session.create({ title: "page-two" }),
})
const page = [...Session.listGlobal({ directory: tmp.path, limit: 1 })]
expect(page.length).toBe(1)
expect(page[0].id).toBe(second.id)
const next = [...Session.listGlobal({ directory: tmp.path, limit: 10, cursor: page[0].time.updated })]
const ids = next.map((session) => session.id)
expect(ids).toContain(first.id)
expect(ids).not.toContain(second.id)
})
})