diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 3a1a99860fd6..fc17feb5b313 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/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. diff --git a/packages/opencode/src/server/routes/instance/httpapi/server.ts b/packages/opencode/src/server/routes/instance/httpapi/server.ts index fa728b04e6f9..adc70a43b358 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/server.ts +++ b/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( diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts new file mode 100644 index 000000000000..e06c8d98aca5 --- /dev/null +++ b/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), +) diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 57988b26f18f..965f26b48559 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/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 diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts new file mode 100644 index 000000000000..42cbb8495c67 --- /dev/null +++ b/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(fx: Effect.Effect) { + return Effect.runPromise(fx.pipe(Effect.provide(Session.defaultLayer))) +} + +function pathFor(path: string, params: Record) { + 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(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(await app().request(`${SessionPaths.list}?roots=true`, { headers }))).map((item) => item.id)).toContain(parent.id) + + expect(await json>(await app().request(SessionPaths.status, { headers }))).toEqual({}) + + expect(await json(await app().request(pathFor(SessionPaths.get, { sessionID: parent.id }), { headers }))).toMatchObject({ id: parent.id, title: "parent" }) + + expect((await json(await app().request(pathFor(SessionPaths.children, { sessionID: parent.id }), { headers }))).map((item) => item.id)).toEqual([child.id]) + + expect(await json(await app().request(pathFor(SessionPaths.todo, { sessionID: parent.id }), { headers }))).toEqual([]) + + expect(await json(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(messages) + expect(messages.headers.get("x-next-cursor")).toBeTruthy() + expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) + + expect(await json(await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), { headers }))).toMatchObject({ info: { id: message.id } }) + }) +})