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
12 changes: 6 additions & 6 deletions packages/opencode/specs/effect/http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
160 changes: 159 additions & 1 deletion packages/opencode/src/server/routes/instance/httpapi/session.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -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,
Expand All @@ -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")
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions packages/opencode/src/server/routes/instance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
44 changes: 44 additions & 0 deletions packages/opencode/test/server/httpapi-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,48 @@ describe("session HttpApi", () => {

expect(await json<MessageV2.WithParts>(await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), { headers }))).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<Session.Info>(
await app().request(SessionPaths.create, {
method: "POST",
headers,
body: JSON.stringify({ title: "created" }),
}),
)
expect(created.title).toBe("created")

const updated = await json<Session.Info>(
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<Session.Info>(
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<boolean>(
await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }),
),
).toBe(true)

expect(
await json<boolean>(
await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }),
),
).toBe(true)
})
})
Loading