Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions packages/opencode/specs/effect/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand Down Expand Up @@ -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",
Expand All @@ -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

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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()
})
Expand All @@ -294,6 +339,7 @@ export const experimentalHandlers = Layer.unwrap(
.handle("worktreeCreate", worktreeCreate)
.handle("worktreeRemove", worktreeRemove)
.handle("worktreeReset", worktreeReset)
.handle("session", session)
.handle("resource", resource),
)
}),
Expand Down
1 change: 1 addition & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
47 changes: 47 additions & 0 deletions packages/opencode/test/server/httpapi-experimental.test.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 } })

Expand Down
Loading