Преглед изворни кода

feat(httpapi): bridge session read routes (#24485)

Kit Langton пре 3 месеци
родитељ
комит
e0d1ff42c0

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

@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
 
 ## Current Route Status
 
-| Area                      | Status            | Notes                                                                      |
-| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
-| `question`                | `bridged`         | `GET /question`, reply, reject                                             |
-| `permission`              | `bridged`         | list and reply                                                             |
-| `provider`                | `bridged`         | list, auth, OAuth authorize/callback                                       |
-| `config`                  | `bridged`         | read, providers, update                                                    |
-| `project`                 | `bridged`         | list, current, git init, update                                            |
-| `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                 |
-| `mcp`                     | `bridged`         | status, add, OAuth, connect/disconnect                                     |
-| `workspace`               | `bridged`         | adaptor/list/status/create/remove/session-restore                          |
-| top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                  |
-| experimental JSON routes  | `bridged`         | console, tool, worktree list/mutations, global session list, resource list |
-| `session`                 | `later/special`   | large stateful surface plus streaming                                      |
-| `sync`                    | `bridged`         | start/replay/history                                                       |
-| `event`                   | `special`         | SSE                                                                        |
-| `pty`                     | `special`         | websocket                                                                  |
-| `tui`                     | `special`         | UI bridge                                                                  |
+| Area                      | Status            | Notes                                                                                    |
+| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
+| `question`                | `bridged`         | `GET /question`, reply, reject                                                           |
+| `permission`              | `bridged`         | list and reply                                                                           |
+| `provider`                | `bridged`         | list, auth, OAuth authorize/callback                                                     |
+| `config`                  | `bridged`         | read, providers, update                                                                  |
+| `project`                 | `bridged`         | list, current, git init, update                                                          |
+| `file`                    | `bridged` partial | find text/file/symbol, list/content/status                                               |
+| `mcp`                     | `bridged`         | status, add, OAuth, connect/disconnect                                                   |
+| `workspace`               | `bridged`         | adaptor/list/status/create/remove/session-restore                                        |
+| top-level instance routes | `bridged`         | path, vcs, command, agent, skill, lsp, formatter, dispose                                |
+| experimental JSON routes  | `bridged`         | console, tool, worktree list/mutations, global session list, resource list              |
+| `session`                 | `bridged` partial | read routes; lifecycle, message mutations, streaming remain                              |
+| `sync`                    | `bridged`         | start/replay/history                                                                      |
+| `event`                   | `special`         | SSE                                                                                      |
+| `pty`                     | `special`         | websocket                                                                                |
+| `tui`                     | `special`         | UI bridge                                                                                |
 
 ## Full Route Checklist
 
@@ -286,11 +286,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
 
 ### Session Routes
 
-- [ ] `GET /session` - list sessions.
-- [ ] `GET /session/status` - session status map.
-- [ ] `GET /session/:sessionID` - get session.
-- [ ] `GET /session/:sessionID/children` - get child sessions.
-- [ ] `GET /session/:sessionID/todo` - get session todos.
+- [x] `GET /session` - list sessions.
+- [x] `GET /session/status` - session status map.
+- [x] `GET /session/:sessionID` - get session.
+- [x] `GET /session/:sessionID/children` - get child sessions.
+- [x] `GET /session/:sessionID/todo` - get session todos.
 - [ ] `POST /session` - create session.
 - [ ] `DELETE /session/:sessionID` - delete session.
 - [ ] `PATCH /session/:sessionID` - update session metadata.
@@ -298,11 +298,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
 - [ ] `POST /session/:sessionID/fork` - fork session.
 - [ ] `POST /session/:sessionID/abort` - abort session.
 - [ ] `POST /session/:sessionID/share` - share session.
-- [ ] `GET /session/:sessionID/diff` - session diff.
+- [x] `GET /session/:sessionID/diff` - session diff.
 - [ ] `DELETE /session/:sessionID/share` - unshare session.
 - [ ] `POST /session/:sessionID/summarize` - summarize session.
-- [ ] `GET /session/:sessionID/message` - list session messages.
-- [ ] `GET /session/:sessionID/message/:messageID` - get message.
+- [x] `GET /session/:sessionID/message` - list session messages.
+- [x] `GET /session/:sessionID/message/:messageID` - get message.
 - [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
 - [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
 - [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
@@ -354,7 +354,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
 5. [x] Bridge experimental global session list.
 6. [x] Bridge workspace create/remove/session-restore routes.
 7. [x] Bridge sync start/replay/history routes.
-8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
+8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
 9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
 10. [ ] Bridge session share/summary/message/part mutation routes.
 11. [ ] Replace event SSE with non-Hono Effect HTTP.

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

@@ -18,6 +18,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
 import { ProjectApi, projectHandlers } from "./project"
 import { ProviderApi, providerHandlers } from "./provider"
 import { QuestionApi, questionHandlers } from "./question"
+import { SessionApi, sessionHandlers } from "./session"
 import { SyncApi, syncHandlers } from "./sync"
 import { WorkspaceApi, workspaceHandlers } from "./workspace"
 import { disposeMiddleware } from "./lifecycle"
@@ -74,6 +75,7 @@ export const routes = Layer.mergeAll(
   HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
   HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
   HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
+  HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
   HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
   HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
 ).pipe(

+ 242 - 0
packages/opencode/src/server/routes/instance/httpapi/session.ts

@@ -0,0 +1,242 @@
+import * as InstanceState from "@/effect/instance-state"
+import { Instance } from "@/project/instance"
+import { Session } from "@/session"
+import { MessageV2 } from "@/session/message-v2"
+import { SessionStatus } from "@/session/status"
+import { SessionSummary } from "@/session/summary"
+import { Todo } from "@/session/todo"
+import { MessageID, SessionID } from "@/session/schema"
+import { Snapshot } from "@/snapshot"
+import { Effect, Layer, Schema, Struct } from "effect"
+import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
+import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import { Authorization } from "./auth"
+
+const root = "/session"
+const ListQuery = Schema.Struct({
+  directory: Schema.optional(Schema.String),
+  roots: Schema.optional(Schema.Literals(["true", "false"])),
+  start: Schema.optional(Schema.NumberFromString),
+  search: Schema.optional(Schema.String),
+  limit: Schema.optional(Schema.NumberFromString),
+})
+const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
+const MessagesQuery = Schema.Struct({
+  limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
+  before: Schema.optional(Schema.String),
+})
+const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
+
+export const SessionPaths = {
+  list: root,
+  status: `${root}/status`,
+  get: `${root}/:sessionID`,
+  children: `${root}/:sessionID/children`,
+  todo: `${root}/:sessionID/todo`,
+  diff: `${root}/:sessionID/diff`,
+  messages: `${root}/:sessionID/message`,
+  message: `${root}/:sessionID/message/:messageID`,
+} as const
+
+export const SessionApi = HttpApi.make("session")
+  .add(
+    HttpApiGroup.make("session")
+      .add(
+        HttpApiEndpoint.get("list", SessionPaths.list, {
+          query: ListQuery,
+          success: Schema.Array(Session.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.list",
+            summary: "List sessions",
+            description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
+          }),
+        ),
+        HttpApiEndpoint.get("status", SessionPaths.status, {
+          success: StatusMap,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.status",
+            summary: "Get session status",
+            description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
+          }),
+        ),
+        HttpApiEndpoint.get("get", SessionPaths.get, {
+          params: { sessionID: SessionID },
+          success: Session.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.get",
+            summary: "Get session",
+            description: "Retrieve detailed information about a specific OpenCode session.",
+          }),
+        ),
+        HttpApiEndpoint.get("children", SessionPaths.children, {
+          params: { sessionID: SessionID },
+          success: Schema.Array(Session.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.children",
+            summary: "Get session children",
+            description: "Retrieve all child sessions that were forked from the specified parent session.",
+          }),
+        ),
+        HttpApiEndpoint.get("todo", SessionPaths.todo, {
+          params: { sessionID: SessionID },
+          success: Schema.Array(Todo.Info),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.todo",
+            summary: "Get session todos",
+            description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
+          }),
+        ),
+        HttpApiEndpoint.get("diff", SessionPaths.diff, {
+          params: { sessionID: SessionID },
+          query: DiffQuery,
+          success: Schema.Array(Snapshot.FileDiff),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.diff",
+            summary: "Get message diff",
+            description: "Get the file changes (diff) that resulted from a specific user message in the session.",
+          }),
+        ),
+        HttpApiEndpoint.get("messages", SessionPaths.messages, {
+          params: { sessionID: SessionID },
+          query: MessagesQuery,
+          success: Schema.Array(MessageV2.WithParts),
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.messages",
+            summary: "Get session messages",
+            description: "Retrieve all messages in a session, including user prompts and AI responses.",
+          }),
+        ),
+        HttpApiEndpoint.get("message", SessionPaths.message, {
+          params: { sessionID: SessionID, messageID: MessageID },
+          success: MessageV2.WithParts,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.message",
+            summary: "Get message",
+            description: "Retrieve a specific message from a session by its message ID.",
+          }),
+        ),
+      )
+      .annotateMerge(
+        OpenApi.annotations({
+          title: "session",
+          description: "Experimental HttpApi session routes.",
+        }),
+      )
+      .middleware(Authorization),
+  )
+  .annotateMerge(
+    OpenApi.annotations({
+      title: "opencode experimental HttpApi",
+      version: "0.0.1",
+      description: "Experimental HttpApi surface for selected instance routes.",
+    }),
+  )
+
+export const sessionHandlers = Layer.unwrap(
+  Effect.gen(function* () {
+    const session = yield* Session.Service
+    const statusSvc = yield* SessionStatus.Service
+    const todoSvc = yield* Todo.Service
+    const summary = yield* SessionSummary.Service
+
+    const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
+      const instance = yield* InstanceState.context
+      return Instance.restore(instance, () =>
+        Array.from(
+          Session.list({
+            directory: ctx.query.directory,
+            roots: ctx.query.roots === "true" ? true : undefined,
+            start: ctx.query.start,
+            search: ctx.query.search,
+            limit: ctx.query.limit,
+          }),
+        ),
+      )
+    })
+
+    const status = Effect.fn("SessionHttpApi.status")(function* () {
+      return Object.fromEntries(yield* statusSvc.list())
+    })
+
+    const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
+      return yield* session.get(ctx.params.sessionID)
+    })
+
+    const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
+      return yield* session.children(ctx.params.sessionID)
+    })
+
+    const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
+      return yield* todoSvc.get(ctx.params.sessionID)
+    })
+
+    const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
+      params: { sessionID: SessionID }
+      query: typeof DiffQuery.Type
+    }) {
+      return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
+    })
+
+    const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
+      params: { sessionID: SessionID }
+      query: typeof MessagesQuery.Type
+    }) {
+      if (ctx.query.limit === undefined || ctx.query.limit === 0) {
+        yield* session.get(ctx.params.sessionID)
+        return yield* session.messages({ sessionID: ctx.params.sessionID })
+      }
+
+      const page = MessageV2.page({
+        sessionID: ctx.params.sessionID,
+        limit: ctx.query.limit,
+        before: ctx.query.before,
+      })
+      if (!page.cursor) return page.items
+
+      const request = yield* HttpServerRequest.HttpServerRequest
+      const url = new URL(request.url, "http://localhost")
+      url.searchParams.set("limit", ctx.query.limit.toString())
+      url.searchParams.set("before", page.cursor)
+      return HttpServerResponse.jsonUnsafe(page.items, {
+        headers: {
+          "Access-Control-Expose-Headers": "Link, X-Next-Cursor",
+          Link: `<${url.toString()}>; rel="next"`,
+          "X-Next-Cursor": page.cursor,
+        },
+      })
+    })
+
+    const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
+      params: { sessionID: SessionID; messageID: MessageID }
+    }) {
+      return yield* Effect.sync(() =>
+        MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
+      )
+    })
+
+    return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
+      handlers
+        .handle("list", list)
+        .handle("status", status)
+        .handle("get", get)
+        .handle("children", children)
+        .handle("todo", todo)
+        .handle("diff", diff)
+        .handle("messages", messages)
+        .handle("message", message),
+    )
+  }),
+).pipe(
+  Layer.provide(Session.defaultLayer),
+  Layer.provide(SessionStatus.defaultLayer),
+  Layer.provide(Todo.defaultLayer),
+  Layer.provide(SessionSummary.defaultLayer),
+)

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

@@ -20,6 +20,7 @@ import { ExperimentalPaths } from "./httpapi/experimental"
 import { FilePaths } from "./httpapi/file"
 import { InstancePaths } from "./httpapi/instance"
 import { McpPaths } from "./httpapi/mcp"
+import { SessionPaths } from "./httpapi/session"
 import { SyncPaths } from "./httpapi/sync"
 import { ProjectRoutes } from "./project"
 import { SessionRoutes } from "./session"
@@ -93,6 +94,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
     app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
     app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
+    app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
   }
 
   return app

+ 108 - 0
packages/opencode/test/server/httpapi-session.test.ts

@@ -0,0 +1,108 @@
+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 { ModelID, ProviderID } from "../../src/provider/schema"
+import { Instance } from "../../src/project/instance"
+import { InstanceRoutes } from "../../src/server/routes/instance"
+import { SessionPaths } from "../../src/server/routes/instance/httpapi/session"
+import { Session } from "../../src/session"
+import { MessageID, PartID, type SessionID } from "../../src/session/schema"
+import { MessageV2 } from "../../src/session/message-v2"
+import { Log } from "../../src/util"
+import { resetDatabase } from "../fixture/db"
+import { tmpdir } from "../fixture/fixture"
+
+void Log.init({ print: false })
+
+const original = Flag.OPENCODE_EXPERIMENTAL_HTTPAPI
+const websocket = (() => () => new Response(null, { status: 501 })) as unknown as UpgradeWebSocket
+
+function app() {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = true
+  return InstanceRoutes(websocket)
+}
+
+function runSession<A, E>(fx: Effect.Effect<A, E, Session.Service>) {
+  return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer)))
+}
+
+function pathFor(path: string, params: Record<string, string>) {
+  return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), path)
+}
+
+async function createSession(directory: string, input?: Session.CreateInput) {
+  return Instance.provide({
+    directory,
+    fn: async () => runSession(Session.Service.use((svc) => svc.create(input))),
+  })
+}
+
+async function createTextMessage(directory: string, sessionID: SessionID, text: string) {
+  return Instance.provide({
+    directory,
+    fn: async () =>
+      runSession(
+        Effect.gen(function* () {
+          const svc = yield* Session.Service
+          const info = yield* svc.updateMessage({
+            id: MessageID.ascending(),
+            role: "user",
+            sessionID,
+            agent: "build",
+            model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
+            time: { created: Date.now() },
+          })
+          yield* svc.updatePart({
+            id: PartID.ascending(),
+            sessionID,
+            messageID: info.id,
+            type: "text",
+            text,
+          })
+          return info
+        }),
+      ),
+  })
+}
+
+async function json<T>(response: Response) {
+  if (response.status !== 200) throw new Error(await response.text())
+  return (await response.json()) as T
+}
+
+afterEach(async () => {
+  Flag.OPENCODE_EXPERIMENTAL_HTTPAPI = original
+  await Instance.disposeAll()
+  await resetDatabase()
+})
+
+describe("session HttpApi", () => {
+  test("serves read routes through Hono bridge", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path }
+    const parent = await createSession(tmp.path, { title: "parent" })
+    const child = await createSession(tmp.path, { title: "child", parentID: parent.id })
+    const message = await createTextMessage(tmp.path, parent.id, "hello")
+    await createTextMessage(tmp.path, parent.id, "world")
+
+    expect((await json<Session.Info[]>(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map((item) => item.id)).toContain(parent.id)
+
+    expect(await json<Record<string, unknown>>(await app().request(SessionPaths.status, { headers }))).toEqual({})
+
+    expect(await json<Session.Info>(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }))).toMatchObject({ id: parent.id, title: "parent" })
+
+    expect((await json<Session.Info[]>(await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }))).map((item) => item.id)).toEqual([child.id])
+
+    expect(await json<unknown[]>(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }))).toEqual([])
+
+    expect(await json<unknown[]>(await app().request(pathFor(SessionPaths.diff, { sessionID: parent.id }), { headers }))).toEqual([])
+
+    const messages = await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1`, { headers })
+    const messagePage = await json<MessageV2.WithParts[]>(messages)
+    expect(messages.headers.get("x-next-cursor")).toBeTruthy()
+    expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
+
+    expect(await json<MessageV2.WithParts>(await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), { headers }))).toMatchObject({ info: { id: message.id } })
+  })
+})