Explorar o código

feat(httpapi): bridge experimental session list (#24478)

Kit Langton hai 3 meses
pai
achega
6daa2b9aeb

+ 3 - 3
packages/opencode/specs/effect/http-api.md

@@ -181,7 +181,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 | `mcp`                     | `bridged`         | status, add, OAuth, connect/disconnect                                                   |
 | `workspace`               | `bridged` partial | adaptor/list/status; create/remove/session-restore remain                                |
 | top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                |
-| experimental JSON routes  | `bridged` partial | console, tool, worktree list/mutations, resource list; global session list remains later |
+| experimental JSON routes  | `bridged`         | console, tool, worktree list/mutations, global session list, resource list              |
 | `session`                 | `later/special`   | large stateful surface plus streaming                                                    |
 | `sync`                    | `later`           | process/control side effects                                                             |
 | `event`                   | `special`         | SSE                                                                                      |
@@ -266,7 +266,7 @@ This checklist tracks bridge parity only. Checked routes are available through t
 - [x] `POST /experimental/worktree` - create worktree.
 - [x] `DELETE /experimental/worktree` - remove worktree.
 - [x] `POST /experimental/worktree/reset` - reset worktree.
-- [ ] `GET /experimental/session` - global session list.
+- [x] `GET /experimental/session` - global session list.
 - [x] `GET /experimental/resource` - MCP resources.
 
 ### Workspace Routes
@@ -351,7 +351,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
 2. [x] Bridge MCP add/connect/disconnect routes.
 3. [x] Bridge MCP OAuth routes: start, callback, authenticate, remove.
 4. [x] Bridge experimental console switch and tool list routes.
-5. [ ] Bridge experimental global session list.
+5. [x] Bridge experimental global session list.
 6. [ ] Bridge workspace create/remove/session-restore routes.
 7. [ ] Bridge sync start/replay/history routes.
 8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.

+ 46 - 0
packages/opencode/src/server/routes/instance/httpapi/experimental.ts

@@ -6,10 +6,12 @@ import { InstanceState } from "@/effect"
 import { MCP } from "@/mcp"
 import { Project } from "@/project"
 import { ProviderID, ModelID } from "@/provider/schema"
+import { Session } from "@/session"
 import { ToolRegistry } from "@/tool"
 import * as EffectZod from "@/util/effect-zod"
 import { Worktree } from "@/worktree"
 import { Effect, Layer, Option, Schema } from "effect"
+import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"
 import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiError, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
 import { Authorization } from "./auth"
 
@@ -50,6 +52,15 @@ const ToolListQuery = Schema.Struct({
 })
 
 const WorktreeList = Schema.Array(Schema.String).annotate({ identifier: "WorktreeList" })
+const SessionListQuery = Schema.Struct({
+  directory: Schema.optional(Schema.String),
+  roots: Schema.optional(Schema.Literals(["true", "false"])),
+  start: Schema.optional(Schema.NumberFromString),
+  cursor: Schema.optional(Schema.NumberFromString),
+  search: Schema.optional(Schema.String),
+  limit: Schema.optional(Schema.NumberFromString),
+  archived: Schema.optional(Schema.Literals(["true", "false"])),
+})
 
 export const ExperimentalPaths = {
   console: "/experimental/console",
@@ -59,6 +70,7 @@ export const ExperimentalPaths = {
   toolIDs: "/experimental/tool/ids",
   worktree: "/experimental/worktree",
   worktreeReset: "/experimental/worktree/reset",
+  session: "/experimental/session",
   resource: "/experimental/resource",
 } as const
 
@@ -154,6 +166,17 @@ export const ExperimentalApi = HttpApi.make("experimental")
             description: "Reset a worktree branch to the primary default branch.",
           }),
         ),
+        HttpApiEndpoint.get("session", ExperimentalPaths.session, {
+          query: SessionListQuery,
+          success: Schema.Array(Session.GlobalInfo),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "experimental.session.list",
+            summary: "List sessions",
+            description:
+              "Get a list of all OpenCode sessions across projects, sorted by most recently updated. Archived sessions are excluded by default.",
+          }),
+        ),
         HttpApiEndpoint.get("resource", ExperimentalPaths.resource, {
           success: Schema.Record(Schema.String, MCP.Resource),
         }).annotateMerge(
@@ -279,6 +302,28 @@ export const experimentalHandlers = Layer.unwrap(
       return true
     })
 
+    const session = Effect.fn("ExperimentalHttpApi.session")(function* (ctx: { query: typeof SessionListQuery.Type }) {
+      const limit = ctx.query.limit ?? 100
+      const sessions = Array.from(
+        Session.listGlobal({
+          directory: ctx.query.directory,
+          roots: ctx.query.roots === "true" ? true : undefined,
+          start: ctx.query.start,
+          cursor: ctx.query.cursor,
+          search: ctx.query.search,
+          limit: limit + 1,
+          archived: ctx.query.archived === "true" ? true : undefined,
+        }),
+      )
+      const list = sessions.length > limit ? sessions.slice(0, limit) : sessions
+      return HttpServerResponse.jsonUnsafe(list, {
+        headers:
+          sessions.length > limit && list.length > 0
+            ? { "x-next-cursor": String(list[list.length - 1].time.updated) }
+            : undefined,
+      })
+    })
+
     const resource = Effect.fn("ExperimentalHttpApi.resource")(function* () {
       return yield* mcp.resources()
     })
@@ -294,6 +339,7 @@ export const experimentalHandlers = Layer.unwrap(
         .handle("worktreeCreate", worktreeCreate)
         .handle("worktreeRemove", worktreeRemove)
         .handle("worktreeReset", worktreeReset)
+        .handle("session", session)
         .handle("resource", resource),
     )
   }),

+ 1 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -56,6 +56,7 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
     app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
     app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
+    app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
     app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
     app.get("/provider", (c) => handler(c.req.raw, context))
     app.get("/provider/auth", (c) => handler(c.req.raw, context))

+ 47 - 0
packages/opencode/test/server/httpapi-experimental.test.ts

@@ -1,10 +1,12 @@
 import { afterEach, describe, expect, test } from "bun:test"
 import type { UpgradeWebSocket } from "hono/ws"
+import { Effect } from "effect"
 import { Flag } from "@opencode-ai/core/flag/flag"
 import { GlobalBus } from "@/bus/global"
 import { Instance } from "../../src/project/instance"
 import { InstanceRoutes } from "../../src/server/routes/instance"
 import { ExperimentalPaths } from "../../src/server/routes/instance/httpapi/experimental"
+import { Session } from "../../src/session"
 import { Database } from "../../src/storage"
 import { Log } from "../../src/util"
 import { Worktree } from "../../src/worktree"
@@ -22,6 +24,14 @@ function app() {
   return InstanceRoutes(websocket)
 }
 
+function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
+  return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function createSession(input?: Session.CreateInput) {
+  return runSession(Session.Service.use((svc) => svc.create(input)))
+}
+
 async function waitReady(directory: string) {
   return await new Promise<void>((resolve, reject) => {
     const timer = setTimeout(() => {
@@ -126,6 +136,43 @@ describe("experimental HttpApi", () => {
     expect(await switched.json()).toBe(true)
   })
 
+  test("serves global session list through Hono bridge", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+
+    const first = await Instance.provide({
+      directory: tmp.path,
+      fn: async () => createSession({ title: "page-one" }),
+    })
+    await new Promise((resolve) => setTimeout(resolve, 5))
+    const second = await Instance.provide({
+      directory: tmp.path,
+      fn: async () => createSession({ title: "page-two" }),
+    })
+
+    const headers = { "x-opencode-directory": tmp.path }
+    const page = await app().request(
+      `${ExperimentalPaths.session}?${new URLSearchParams({ directory: tmp.path, limit: "1" })}`,
+      { headers },
+    )
+    expect(page.status).toBe(200)
+    expect(page.headers.get("x-next-cursor")).toBeTruthy()
+
+    const body = (await page.json()) as Session.GlobalInfo[]
+    expect(body.map((session) => session.id)).toEqual([second.id])
+    expect(body[0].project?.id).toBe(second.projectID)
+
+    const next = await app().request(
+      `${ExperimentalPaths.session}?${new URLSearchParams({
+        directory: tmp.path,
+        limit: "10",
+        cursor: body[0].time.updated.toString(),
+      })}`,
+      { headers },
+    )
+    expect(next.status).toBe(200)
+    expect(((await next.json()) as Session.GlobalInfo[]).map((session) => session.id)).toContain(first.id)
+  })
+
   testWorktreeMutations("serves worktree mutations through Hono bridge", async () => {
     await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })