feat(httpapi): bridge remaining session routes (#24510)

This commit is contained in:
Kit Langton
2026-04-26 12:24:19 -04:00
committed by GitHub
parent 301ecb185e
commit c5b67927af
4 changed files with 443 additions and 14 deletions

View File

@@ -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.

View File

@@ -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),

View File

@@ -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))

View File

@@ -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,9 +119,25 @@ 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 }), {
@@ -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)
})
})