mirror of
https://github.com/anomalyco/opencode.git
synced 2026-05-01 22:10:23 +00:00
feat(httpapi): bridge session read routes (#24485)
This commit is contained in:
parent
de413c56ae
commit
e0d1ff42c0
5 changed files with 387 additions and 26 deletions
|
|
@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
|
||||||
|
|
||||||
## Current Route Status
|
## Current Route Status
|
||||||
|
|
||||||
| Area | Status | Notes |
|
| Area | Status | Notes |
|
||||||
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
|
| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
|
||||||
| `question` | `bridged` | `GET /question`, reply, reject |
|
| `question` | `bridged` | `GET /question`, reply, reject |
|
||||||
| `permission` | `bridged` | list and reply |
|
| `permission` | `bridged` | list and reply |
|
||||||
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
|
||||||
| `config` | `bridged` | read, providers, update |
|
| `config` | `bridged` | read, providers, update |
|
||||||
| `project` | `bridged` | list, current, git init, update |
|
| `project` | `bridged` | list, current, git init, update |
|
||||||
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
|
||||||
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
|
||||||
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
|
||||||
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
|
| 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 |
|
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
|
||||||
| `session` | `later/special` | large stateful surface plus streaming |
|
| `session` | `bridged` partial | read routes; lifecycle, message mutations, streaming remain |
|
||||||
| `sync` | `bridged` | start/replay/history |
|
| `sync` | `bridged` | start/replay/history |
|
||||||
| `event` | `special` | SSE |
|
| `event` | `special` | SSE |
|
||||||
| `pty` | `special` | websocket |
|
| `pty` | `special` | websocket |
|
||||||
| `tui` | `special` | UI bridge |
|
| `tui` | `special` | UI bridge |
|
||||||
|
|
||||||
## Full Route Checklist
|
## Full Route Checklist
|
||||||
|
|
||||||
|
|
@ -286,11 +286,11 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
||||||
|
|
||||||
### Session Routes
|
### Session Routes
|
||||||
|
|
||||||
- [ ] `GET /session` - list sessions.
|
- [x] `GET /session` - list sessions.
|
||||||
- [ ] `GET /session/status` - session status map.
|
- [x] `GET /session/status` - session status map.
|
||||||
- [ ] `GET /session/:sessionID` - get session.
|
- [x] `GET /session/:sessionID` - get session.
|
||||||
- [ ] `GET /session/:sessionID/children` - get child sessions.
|
- [x] `GET /session/:sessionID/children` - get child sessions.
|
||||||
- [ ] `GET /session/:sessionID/todo` - get session todos.
|
- [x] `GET /session/:sessionID/todo` - get session todos.
|
||||||
- [ ] `POST /session` - create session.
|
- [ ] `POST /session` - create session.
|
||||||
- [ ] `DELETE /session/:sessionID` - delete session.
|
- [ ] `DELETE /session/:sessionID` - delete session.
|
||||||
- [ ] `PATCH /session/:sessionID` - update session metadata.
|
- [ ] `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/fork` - fork session.
|
||||||
- [ ] `POST /session/:sessionID/abort` - abort session.
|
- [ ] `POST /session/:sessionID/abort` - abort session.
|
||||||
- [ ] `POST /session/:sessionID/share` - share 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.
|
- [ ] `DELETE /session/:sessionID/share` - unshare session.
|
||||||
- [ ] `POST /session/:sessionID/summarize` - summarize session.
|
- [ ] `POST /session/:sessionID/summarize` - summarize session.
|
||||||
- [ ] `GET /session/:sessionID/message` - list session messages.
|
- [x] `GET /session/:sessionID/message` - list session messages.
|
||||||
- [ ] `GET /session/:sessionID/message/:messageID` - get message.
|
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
||||||
- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
||||||
- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
||||||
- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update 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.
|
5. [x] Bridge experimental global session list.
|
||||||
6. [x] Bridge workspace create/remove/session-restore routes.
|
6. [x] Bridge workspace create/remove/session-restore routes.
|
||||||
7. [x] Bridge sync start/replay/history 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.
|
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
|
||||||
10. [ ] Bridge session share/summary/message/part mutation routes.
|
10. [ ] Bridge session share/summary/message/part mutation routes.
|
||||||
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
11. [ ] Replace event SSE with non-Hono Effect HTTP.
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
|
||||||
import { ProjectApi, projectHandlers } from "./project"
|
import { ProjectApi, projectHandlers } from "./project"
|
||||||
import { ProviderApi, providerHandlers } from "./provider"
|
import { ProviderApi, providerHandlers } from "./provider"
|
||||||
import { QuestionApi, questionHandlers } from "./question"
|
import { QuestionApi, questionHandlers } from "./question"
|
||||||
|
import { SessionApi, sessionHandlers } from "./session"
|
||||||
import { SyncApi, syncHandlers } from "./sync"
|
import { SyncApi, syncHandlers } from "./sync"
|
||||||
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
import { WorkspaceApi, workspaceHandlers } from "./workspace"
|
||||||
import { disposeMiddleware } from "./lifecycle"
|
import { disposeMiddleware } from "./lifecycle"
|
||||||
|
|
@ -74,6 +75,7 @@ export const routes = Layer.mergeAll(
|
||||||
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
|
||||||
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
|
||||||
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
|
||||||
|
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
|
||||||
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
|
||||||
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
|
||||||
).pipe(
|
).pipe(
|
||||||
|
|
|
||||||
242
packages/opencode/src/server/routes/instance/httpapi/session.ts
Normal file
242
packages/opencode/src/server/routes/instance/httpapi/session.ts
Normal file
|
|
@ -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),
|
||||||
|
)
|
||||||
|
|
@ -20,6 +20,7 @@ import { ExperimentalPaths } from "./httpapi/experimental"
|
||||||
import { FilePaths } from "./httpapi/file"
|
import { FilePaths } from "./httpapi/file"
|
||||||
import { InstancePaths } from "./httpapi/instance"
|
import { InstancePaths } from "./httpapi/instance"
|
||||||
import { McpPaths } from "./httpapi/mcp"
|
import { McpPaths } from "./httpapi/mcp"
|
||||||
|
import { SessionPaths } from "./httpapi/session"
|
||||||
import { SyncPaths } from "./httpapi/sync"
|
import { SyncPaths } from "./httpapi/sync"
|
||||||
import { ProjectRoutes } from "./project"
|
import { ProjectRoutes } from "./project"
|
||||||
import { SessionRoutes } from "./session"
|
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.start, (c) => handler(c.req.raw, context))
|
||||||
app.post(SyncPaths.replay, (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.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
|
return app
|
||||||
|
|
|
||||||
108
packages/opencode/test/server/httpapi-session.test.ts
Normal file
108
packages/opencode/test/server/httpapi-session.test.ts
Normal file
|
|
@ -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 } })
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Add table
Add a link
Reference in a new issue