From daaa2e591129bc00aac019afec6e8a93e2ab8f30 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Sun, 26 Apr 2026 11:50:26 -0400 Subject: [PATCH] feat(httpapi): bridge session lifecycle routes (#24486) --- packages/opencode/specs/effect/http-api.md | 12 +- .../server/routes/instance/httpapi/session.ts | 160 +++++++++++++++++- .../src/server/routes/instance/index.ts | 5 + .../test/server/httpapi-session.test.ts | 44 +++++ 4 files changed, 214 insertions(+), 7 deletions(-) diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index f6a0c06a5d..e11e88b7b4 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -291,12 +291,12 @@ This checklist tracks bridge parity only. Checked routes are available through t - [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. +- [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. -- [ ] `POST /session/:sessionID/fork` - fork session. -- [ ] `POST /session/:sessionID/abort` - abort session. +- [x] `POST /session/:sessionID/fork` - fork session. +- [x] `POST /session/:sessionID/abort` - abort session. - [ ] `POST /session/:sessionID/share` - share session. - [x] `GET /session/:sessionID/diff` - session diff. - [ ] `DELETE /session/:sessionID/share` - unshare session. @@ -355,7 +355,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 6. [x] Bridge workspace create/remove/session-restore routes. 7. [x] Bridge sync start/replay/history routes. 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. [x] 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. 12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP. diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index e06c8d98ac..06f7d57910 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -1,7 +1,11 @@ import * as InstanceState from "@/effect/instance-state" +import { AppRuntime } from "@/effect/app-runtime" +import { Permission } from "@/permission" import { Instance } from "@/project/instance" +import { SessionShare } from "@/share" import { Session } from "@/session" import { MessageV2 } from "@/session/message-v2" +import { SessionPrompt } from "@/session/prompt" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" @@ -26,6 +30,18 @@ const MessagesQuery = Schema.Struct({ before: Schema.optional(Schema.String), }) const StatusMap = Schema.Record(Schema.String, SessionStatus.Info) +const UpdatePayload = Schema.Struct({ + title: Schema.optional(Schema.String), + permission: Schema.optional(Permission.Ruleset), + time: Schema.optional( + Schema.Struct({ + archived: Schema.optional(Schema.Number), + }), + ), +}).annotate({ identifier: "SessionUpdateInput" }) +const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ + identifier: "SessionForkInput", +}) export const SessionPaths = { list: root, @@ -36,6 +52,11 @@ export const SessionPaths = { diff: `${root}/:sessionID/diff`, messages: `${root}/:sessionID/message`, message: `${root}/:sessionID/message/:messageID`, + create: root, + remove: `${root}/:sessionID`, + update: `${root}/:sessionID`, + fork: `${root}/:sessionID/fork`, + abort: `${root}/:sessionID/abort`, } as const export const SessionApi = HttpApi.make("session") @@ -123,6 +144,58 @@ export const SessionApi = HttpApi.make("session") description: "Retrieve a specific message from a session by its message ID.", }), ), + HttpApiEndpoint.post("create", SessionPaths.create, { + payload: Session.CreateInput, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.create", + summary: "Create session", + description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.", + }), + ), + HttpApiEndpoint.delete("remove", SessionPaths.remove, { + params: { sessionID: SessionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.delete", + summary: "Delete session", + description: "Delete a session and permanently remove all associated data, including messages and history.", + }), + ), + HttpApiEndpoint.patch("update", SessionPaths.update, { + params: { sessionID: SessionID }, + payload: UpdatePayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.update", + summary: "Update session", + description: "Update properties of an existing session, such as title or other metadata.", + }), + ), + HttpApiEndpoint.post("fork", SessionPaths.fork, { + params: { sessionID: SessionID }, + payload: ForkPayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.fork", + summary: "Fork session", + description: "Create a new session by forking an existing session at a specific message point.", + }), + ), + HttpApiEndpoint.post("abort", SessionPaths.abort, { + params: { sessionID: SessionID }, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.abort", + summary: "Abort session", + description: "Abort an active session and stop any ongoing AI processing or command execution.", + }), + ), ) .annotateMerge( OpenApi.annotations({ @@ -222,6 +295,86 @@ export const sessionHandlers = Layer.unwrap( ) }) + const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload: Session.CreateInput }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer))), + ), + ) + }) + + const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) { + const instance = yield* InstanceState.context + yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer))), + ), + ) + return true + }) + + const update = Effect.fn("SessionHttpApi.update")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof UpdatePayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + Session.Service.use((svc) => + Effect.gen(function* () { + const current = yield* svc.get(ctx.params.sessionID) + if (ctx.payload.title !== undefined) { + yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title }) + } + if (ctx.payload.permission !== undefined) { + yield* svc.setPermission({ + sessionID: ctx.params.sessionID, + permission: Permission.merge(current.permission ?? [], ctx.payload.permission), + }) + } + if (ctx.payload.time?.archived !== undefined) { + yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived }) + } + return yield* svc.get(ctx.params.sessionID) + }), + ).pipe(Effect.provide(Session.defaultLayer)), + ), + ), + ) + }) + + const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof ForkPayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + Session.Service.use((svc) => svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })).pipe( + Effect.provide(Session.defaultLayer), + ), + ), + ), + ) + }) + + const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) { + const instance = yield* InstanceState.context + yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe( + Effect.provide(SessionPrompt.defaultLayer), + ), + ), + ), + ) + return true + }) + return HttpApiBuilder.group(SessionApi, "session", (handlers) => handlers .handle("list", list) @@ -231,7 +384,12 @@ export const sessionHandlers = Layer.unwrap( .handle("todo", todo) .handle("diff", diff) .handle("messages", messages) - .handle("message", message), + .handle("message", message) + .handle("create", create) + .handle("remove", remove) + .handle("update", update) + .handle("fork", fork) + .handle("abort", abort), ) }), ).pipe( diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index 965f26b485..ba029f0a3f 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -102,6 +102,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { 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)) + 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.fork, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.abort, (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 index 4c4663d715..d589a45a02 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -129,4 +129,48 @@ describe("session HttpApi", () => { ), ).toMatchObject({ info: { id: message.id } }) }) + + test("serves lifecycle mutation routes through Hono bridge", async () => { + await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } }) + const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" } + + const created = await json( + await app().request(SessionPaths.create, { + method: "POST", + headers, + body: JSON.stringify({ title: "created" }), + }), + ) + expect(created.title).toBe("created") + + const updated = await json( + await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), { + method: "PATCH", + headers, + body: JSON.stringify({ title: "updated", time: { archived: 1 } }), + }), + ) + expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } }) + + const forked = await json( + await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), { + method: "POST", + headers, + body: JSON.stringify({}), + }), + ) + expect(forked.id).not.toBe(created.id) + + expect( + await json( + await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }), + ), + ).toBe(true) + + expect( + await json( + await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }), + ), + ).toBe(true) + }) })