summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
authorKit Langton <[email protected]>2026-04-26 12:24:19 -0400
committerGitHub <[email protected]>2026-04-26 12:24:19 -0400
commitc5b67927afd2a86945fbcbce42d3cfc2eba047a8 (patch)
tree110b4723c0309943c7262aab12db7148aa4ddd00 /packages
parent301ecb185e06a230c6d720845b04effa84450976 (diff)
downloadopencode-c5b67927afd2a86945fbcbce42d3cfc2eba047a8.tar.gz
opencode-c5b67927afd2a86945fbcbce42d3cfc2eba047a8.zip
feat(httpapi): bridge remaining session routes (#24510)
Diffstat (limited to 'packages')
-rw-r--r--packages/opencode/specs/effect/http-api.md22
-rw-r--r--packages/opencode/src/server/routes/instance/httpapi/session.ts366
-rw-r--r--packages/opencode/src/server/routes/instance/index.ts9
-rw-r--r--packages/opencode/test/server/httpapi-session.test.ts60
4 files changed, 443 insertions, 14 deletions
diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md
index 3ce4aa83a..5f16ef197 100644
--- a/packages/opencode/specs/effect/http-api.md
+++ b/packages/opencode/specs/effect/http-api.md
@@ -182,7 +182,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
| `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 |
+| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply |
| `sync` | `bridged` | start/replay/history |
| `event` | `special` | SSE |
| `pty` | `special` | websocket |
@@ -294,25 +294,25 @@ This checklist tracks bridge parity only. Checked routes are available through t
- [x] `POST /session` - create session.
- [x] `DELETE /session/:sessionID` - delete session.
- [x] `PATCH /session/:sessionID` - update session metadata.
-- [ ] `POST /session/:sessionID/init` - run project init command.
+- [x] `POST /session/:sessionID/init` - run project init command.
- [x] `POST /session/:sessionID/fork` - fork session.
- [x] `POST /session/:sessionID/abort` - abort session.
- [x] `POST /session/:sessionID/share` - share session.
- [x] `GET /session/:sessionID/diff` - session diff.
- [x] `DELETE /session/:sessionID/share` - unshare session.
-- [ ] `POST /session/:sessionID/summarize` - summarize session.
+- [x] `POST /session/:sessionID/summarize` - summarize session.
- [x] `GET /session/:sessionID/message` - list session messages.
- [x] `GET /session/:sessionID/message/:messageID` - get message.
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
-- [ ] `POST /session/:sessionID/message` - prompt with streaming response.
-- [ ] `POST /session/:sessionID/prompt_async` - async prompt.
-- [ ] `POST /session/:sessionID/command` - run command.
-- [ ] `POST /session/:sessionID/shell` - run shell command.
-- [ ] `POST /session/:sessionID/revert` - revert message.
-- [ ] `POST /session/:sessionID/unrevert` - restore reverted messages.
-- [ ] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
+- [x] `POST /session/:sessionID/message` - prompt with streaming response.
+- [x] `POST /session/:sessionID/prompt_async` - async prompt.
+- [x] `POST /session/:sessionID/command` - run command.
+- [x] `POST /session/:sessionID/shell` - run shell command.
+- [x] `POST /session/:sessionID/revert` - revert message.
+- [x] `POST /session/:sessionID/unrevert` - restore reverted messages.
+- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route.
### Event Routes
@@ -356,7 +356,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
7. [x] Bridge sync start/replay/history routes.
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
-10. [ ] Bridge session share/summary/message/part mutation routes.
+10. [x] Bridge remaining session mutation and prompt routes.
11. [ ] Replace event SSE with non-Hono Effect HTTP.
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.
13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer.
diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts
index b38740d93..36645fd7e 100644
--- a/packages/opencode/src/server/routes/instance/httpapi/session.ts
+++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts
@@ -1,22 +1,41 @@
import * as InstanceState from "@/effect/instance-state"
import { AppRuntime } from "@/effect/app-runtime"
+import { Agent } from "@/agent/agent"
+import { Bus } from "@/bus"
+import { Command } from "@/command"
import { Permission } from "@/permission"
+import { PermissionID } from "@/permission/schema"
import { Instance } from "@/project/instance"
+import { ModelID, ProviderID } from "@/provider/schema"
import { SessionShare } from "@/share"
import { Session } from "@/session"
+import { SessionCompaction } from "@/session/compaction"
import { MessageV2 } from "@/session/message-v2"
import { SessionPrompt } from "@/session/prompt"
+import { SessionRevert } from "@/session/revert"
import { SessionRunState } from "@/session/run-state"
import { SessionStatus } from "@/session/status"
import { SessionSummary } from "@/session/summary"
import { Todo } from "@/session/todo"
import { MessageID, PartID, SessionID } from "@/session/schema"
import { Snapshot } from "@/snapshot"
+import { Log } from "@/util"
+import { NamedError } from "@opencode-ai/core/util/error"
import { Effect, Layer, Schema, Struct } from "effect"
+import * as Stream from "effect/Stream"
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
-import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
+import {
+ HttpApi,
+ HttpApiBuilder,
+ HttpApiEndpoint,
+ HttpApiError,
+ HttpApiGroup,
+ HttpApiSchema,
+ OpenApi,
+} from "effect/unstable/httpapi"
import { Authorization } from "./auth"
+const log = Log.create({ service: "server" })
const root = "/session"
const ListQuery = Schema.Struct({
directory: Schema.optional(Schema.String),
@@ -43,6 +62,31 @@ const UpdatePayload = Schema.Struct({
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
identifier: "SessionForkInput",
})
+const InitPayload = Schema.Struct({
+ modelID: ModelID,
+ providerID: ProviderID,
+ messageID: MessageID,
+}).annotate({ identifier: "SessionInitInput" })
+const SummarizePayload = Schema.Struct({
+ providerID: ProviderID,
+ modelID: ModelID,
+ auto: Schema.optional(Schema.Boolean),
+}).annotate({ identifier: "SessionSummarizeInput" })
+const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({
+ identifier: "SessionPromptInput",
+})
+const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({
+ identifier: "SessionCommandInput",
+})
+const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({
+ identifier: "SessionShellInput",
+})
+const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({
+ identifier: "SessionRevertInput",
+})
+const PermissionResponsePayload = Schema.Struct({
+ response: Permission.Reply,
+}).annotate({ identifier: "SessionPermissionResponseInput" })
export const SessionPaths = {
list: root,
@@ -59,6 +103,15 @@ export const SessionPaths = {
fork: `${root}/:sessionID/fork`,
abort: `${root}/:sessionID/abort`,
share: `${root}/:sessionID/share`,
+ init: `${root}/:sessionID/init`,
+ summarize: `${root}/:sessionID/summarize`,
+ prompt: `${root}/:sessionID/message`,
+ promptAsync: `${root}/:sessionID/prompt_async`,
+ command: `${root}/:sessionID/command`,
+ shell: `${root}/:sessionID/shell`,
+ revert: `${root}/:sessionID/revert`,
+ unrevert: `${root}/:sessionID/unrevert`,
+ permissions: `${root}/:sessionID/permissions/:permissionID`,
deleteMessage: `${root}/:sessionID/message/:messageID`,
deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
@@ -201,6 +254,18 @@ export const SessionApi = HttpApi.make("session")
description: "Abort an active session and stop any ongoing AI processing or command execution.",
}),
),
+ HttpApiEndpoint.post("init", SessionPaths.init, {
+ params: { sessionID: SessionID },
+ payload: InitPayload,
+ success: Schema.Boolean,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.init",
+ summary: "Initialize session",
+ description:
+ "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.",
+ }),
+ ),
HttpApiEndpoint.post("share", SessionPaths.share, {
params: { sessionID: SessionID },
success: Session.Info,
@@ -221,6 +286,95 @@ export const SessionApi = HttpApi.make("session")
description: "Remove the shareable link for a session, making it private again.",
}),
),
+ HttpApiEndpoint.post("summarize", SessionPaths.summarize, {
+ params: { sessionID: SessionID },
+ payload: SummarizePayload,
+ success: Schema.Boolean,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.summarize",
+ summary: "Summarize session",
+ description: "Generate a concise summary of the session using AI compaction to preserve key information.",
+ }),
+ ),
+ HttpApiEndpoint.post("prompt", SessionPaths.prompt, {
+ params: { sessionID: SessionID },
+ payload: PromptPayload,
+ success: MessageV2.WithParts,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.prompt",
+ summary: "Send message",
+ description: "Create and send a new message to a session, streaming the AI response.",
+ }),
+ ),
+ HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, {
+ params: { sessionID: SessionID },
+ payload: PromptPayload,
+ success: HttpApiSchema.NoContent,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.prompt_async",
+ summary: "Send async message",
+ description:
+ "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.",
+ }),
+ ),
+ HttpApiEndpoint.post("command", SessionPaths.command, {
+ params: { sessionID: SessionID },
+ payload: CommandPayload,
+ success: MessageV2.WithParts,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.command",
+ summary: "Send command",
+ description: "Send a new command to a session for execution by the AI assistant.",
+ }),
+ ),
+ HttpApiEndpoint.post("shell", SessionPaths.shell, {
+ params: { sessionID: SessionID },
+ payload: ShellPayload,
+ success: MessageV2.WithParts,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.shell",
+ summary: "Run shell command",
+ description: "Execute a shell command within the session context and return the AI's response.",
+ }),
+ ),
+ HttpApiEndpoint.post("revert", SessionPaths.revert, {
+ params: { sessionID: SessionID },
+ payload: RevertPayload,
+ success: Session.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.revert",
+ summary: "Revert message",
+ description: "Revert a specific message in a session, undoing its effects and restoring the previous state.",
+ }),
+ ),
+ HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, {
+ params: { sessionID: SessionID },
+ success: Session.Info,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "session.unrevert",
+ summary: "Restore reverted messages",
+ description: "Restore all previously reverted messages in a session.",
+ }),
+ ),
+ HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, {
+ params: { sessionID: SessionID, permissionID: PermissionID },
+ payload: PermissionResponsePayload,
+ success: Schema.Boolean,
+ }).annotateMerge(
+ OpenApi.annotations({
+ identifier: "permission.respond",
+ summary: "Respond to permission",
+ description: "Approve or deny a permission request from the AI assistant.",
+ deprecated: true,
+ }),
+ ),
HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
params: { sessionID: SessionID, messageID: MessageID },
success: Schema.Boolean,
@@ -317,6 +471,14 @@ export const sessionHandlers = Layer.unwrap(
params: { sessionID: SessionID }
query: typeof MessagesQuery.Type
}) {
+ if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({})
+ if (ctx.query.before !== undefined) {
+ const before = ctx.query.before
+ yield* Effect.try({
+ try: () => MessageV2.cursor.decode(before),
+ catch: () => new HttpApiError.BadRequest({}),
+ })
+ }
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
yield* session.get(ctx.params.sessionID)
return yield* session.messages({ sessionID: ctx.params.sessionID })
@@ -434,6 +596,29 @@ export const sessionHandlers = Layer.unwrap(
return true
})
+ const init = Effect.fn("SessionHttpApi.init")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof InitPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionPrompt.Service.use((svc) =>
+ svc.command({
+ sessionID: ctx.params.sessionID,
+ messageID: ctx.payload.messageID,
+ model: `${ctx.payload.providerID}/${ctx.payload.modelID}`,
+ command: Command.Default.INIT,
+ arguments: "",
+ }),
+ ).pipe(Effect.provide(SessionPrompt.defaultLayer)),
+ ),
+ ),
+ )
+ return true
+ })
+
const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
const instance = yield* InstanceState.context
return yield* Effect.promise(() =>
@@ -466,6 +651,174 @@ export const sessionHandlers = Layer.unwrap(
)
})
+ const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof SummarizePayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ Effect.gen(function* () {
+ const session = yield* Session.Service
+ const revert = yield* SessionRevert.Service
+ const compact = yield* SessionCompaction.Service
+ const prompt = yield* SessionPrompt.Service
+ const agent = yield* Agent.Service
+
+ yield* revert.cleanup(yield* session.get(ctx.params.sessionID))
+ const messages = yield* session.messages({ sessionID: ctx.params.sessionID })
+ const defaultAgent = yield* agent.defaultAgent()
+ const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent
+
+ yield* compact.create({
+ sessionID: ctx.params.sessionID,
+ agent: currentAgent,
+ model: {
+ providerID: ctx.payload.providerID,
+ modelID: ctx.payload.modelID,
+ },
+ auto: ctx.payload.auto ?? false,
+ })
+ yield* prompt.loop({ sessionID: ctx.params.sessionID })
+ }).pipe(
+ Effect.provide(SessionRevert.defaultLayer),
+ Effect.provide(SessionCompaction.defaultLayer),
+ Effect.provide(SessionPrompt.defaultLayer),
+ Effect.provide(Agent.defaultLayer),
+ Effect.provide(Session.defaultLayer),
+ ),
+ ),
+ ),
+ )
+ return true
+ })
+
+ const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof PromptPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ return HttpServerResponse.stream(
+ Stream.fromEffect(
+ Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
+ ).pipe(Effect.provide(SessionPrompt.defaultLayer)),
+ ),
+ ),
+ ),
+ ).pipe(Stream.map((message) => JSON.stringify(message)), Stream.encodeText),
+ { contentType: "application/json" },
+ )
+ })
+
+ const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof PromptPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ yield* Effect.sync(() => {
+ Instance.restore(instance, () => {
+ void AppRuntime.runPromise(
+ SessionPrompt.Service.use((svc) =>
+ svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput),
+ ).pipe(Effect.provide(SessionPrompt.defaultLayer)),
+ ).catch((error) => {
+ log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error })
+ void Bus.publish(Session.Event.Error, {
+ sessionID: ctx.params.sessionID,
+ error: new NamedError.Unknown({
+ message: error instanceof Error ? error.message : String(error),
+ }).toObject(),
+ })
+ })
+ })
+ })
+ return HttpApiSchema.NoContent.make()
+ })
+
+ const command = Effect.fn("SessionHttpApi.command")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof CommandPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionPrompt.Service.use((svc) =>
+ svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput),
+ ).pipe(Effect.provide(SessionPrompt.defaultLayer)),
+ ),
+ ),
+ )
+ })
+
+ const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof ShellPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionPrompt.Service.use((svc) =>
+ svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput),
+ ).pipe(Effect.provide(SessionPrompt.defaultLayer)),
+ ),
+ ),
+ )
+ })
+
+ const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: {
+ params: { sessionID: SessionID }
+ payload: typeof RevertPayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ log.info("revert", ctx.payload)
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionRevert.Service.use((svc) =>
+ svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }),
+ ).pipe(Effect.provide(SessionRevert.defaultLayer)),
+ ),
+ ),
+ )
+ })
+
+ const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) {
+ const instance = yield* InstanceState.context
+ return yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe(
+ Effect.provide(SessionRevert.defaultLayer),
+ ),
+ ),
+ ),
+ )
+ })
+
+ const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: {
+ params: { permissionID: PermissionID }
+ payload: typeof PermissionResponsePayload.Type
+ }) {
+ const instance = yield* InstanceState.context
+ yield* Effect.promise(() =>
+ Instance.restore(instance, () =>
+ AppRuntime.runPromise(
+ Permission.Service.use((svc) =>
+ svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }),
+ ).pipe(Effect.provide(Permission.defaultLayer)),
+ ),
+ ),
+ )
+ return true
+ })
+
const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
params: { sessionID: SessionID; messageID: MessageID }
}) {
@@ -503,7 +856,7 @@ export const sessionHandlers = Layer.unwrap(
params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
payload: typeof MessageV2.Part.Type
}) {
- const payload = MessageV2.Part.zod.parse(ctx.payload)
+ const payload = ctx.payload as MessageV2.Part
if (
payload.id !== ctx.params.partID ||
payload.messageID !== ctx.params.messageID ||
@@ -538,8 +891,17 @@ export const sessionHandlers = Layer.unwrap(
.handle("update", update)
.handle("fork", fork)
.handle("abort", abort)
+ .handle("init", init)
.handle("share", share)
.handle("unshare", unshare)
+ .handle("summarize", summarize)
+ .handle("prompt", prompt)
+ .handle("promptAsync", promptAsync)
+ .handle("command", command)
+ .handle("shell", shell)
+ .handle("revert", revert)
+ .handle("unrevert", unrevert)
+ .handle("permissionRespond", permissionRespond)
.handle("deleteMessage", deleteMessage)
.handle("deletePart", deletePart)
.handle("updatePart", updatePart),
diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts
index cbb46df22..4c0503af5 100644
--- a/packages/opencode/src/server/routes/instance/index.ts
+++ b/packages/opencode/src/server/routes/instance/index.ts
@@ -105,10 +105,19 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
+ app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts
index 7a1e8fd02..e6c091982 100644
--- a/packages/opencode/test/server/httpapi-session.test.ts
+++ b/packages/opencode/test/server/httpapi-session.test.ts
@@ -2,6 +2,7 @@ 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 { PermissionID } from "../../src/permission/schema"
import { ModelID, ProviderID } from "../../src/provider/schema"
import { Instance } from "../../src/project/instance"
import { InstanceRoutes } from "../../src/server/routes/instance"
@@ -118,10 +119,26 @@ describe("session HttpApi", () => {
headers,
})
const messagePage = await json<MessageV2.WithParts[]>(messages)
- expect(messages.headers.get("x-next-cursor")).toBeTruthy()
+ const nextCursor = messages.headers.get("x-next-cursor")
+ expect(nextCursor).toBeTruthy()
expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" })
expect(
+ (
+ await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, {
+ headers,
+ })
+ ).status,
+ ).toBe(400)
+ expect(
+ (
+ await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, {
+ headers,
+ })
+ ).status,
+ ).toBe(400)
+
+ expect(
await json<MessageV2.WithParts>(
await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), {
headers,
@@ -219,4 +236,45 @@ describe("session HttpApi", () => {
),
).toBe(true)
})
+
+ test("serves remaining non-LLM session mutation routes through Hono bridge", async () => {
+ await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+ const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+ const session = await createSession(tmp.path, { title: "remaining" })
+
+ expect(
+ await json<Session.Info>(
+ await app().request(pathFor(SessionPaths.revert, { sessionID: session.id }), {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ messageID: MessageID.ascending() }),
+ }),
+ ),
+ ).toMatchObject({ id: session.id })
+
+ expect(
+ await json<Session.Info>(
+ await app().request(pathFor(SessionPaths.unrevert, { sessionID: session.id }), {
+ method: "POST",
+ headers,
+ }),
+ ),
+ ).toMatchObject({ id: session.id })
+
+ expect(
+ await json<boolean>(
+ await app().request(
+ pathFor(SessionPaths.permissions, {
+ sessionID: session.id,
+ permissionID: String(PermissionID.ascending()),
+ }),
+ {
+ method: "POST",
+ headers,
+ body: JSON.stringify({ response: "once" }),
+ },
+ ),
+ ),
+ ).toBe(true)
+ })
})