Skip to content

Commit e50aab5

Browse files
committed
feat(httpapi): bridge session lifecycle routes
1 parent e0d1ff4 commit e50aab5

4 files changed

Lines changed: 214 additions & 7 deletions

File tree

packages/opencode/specs/effect/http-api.md

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -291,12 +291,12 @@ This checklist tracks bridge parity only. Checked routes are available through t
291291
- [x] `GET /session/:sessionID` - get session.
292292
- [x] `GET /session/:sessionID/children` - get child sessions.
293293
- [x] `GET /session/:sessionID/todo` - get session todos.
294-
- [ ] `POST /session` - create session.
295-
- [ ] `DELETE /session/:sessionID` - delete session.
296-
- [ ] `PATCH /session/:sessionID` - update session metadata.
294+
- [x] `POST /session` - create session.
295+
- [x] `DELETE /session/:sessionID` - delete session.
296+
- [x] `PATCH /session/:sessionID` - update session metadata.
297297
- [ ] `POST /session/:sessionID/init` - run project init command.
298-
- [ ] `POST /session/:sessionID/fork` - fork session.
299-
- [ ] `POST /session/:sessionID/abort` - abort session.
298+
- [x] `POST /session/:sessionID/fork` - fork session.
299+
- [x] `POST /session/:sessionID/abort` - abort session.
300300
- [ ] `POST /session/:sessionID/share` - share session.
301301
- [x] `GET /session/:sessionID/diff` - session diff.
302302
- [ ] `DELETE /session/:sessionID/share` - unshare session.
@@ -355,7 +355,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
355355
6. [x] Bridge workspace create/remove/session-restore routes.
356356
7. [x] Bridge sync start/replay/history routes.
357357
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
358-
9. [ ] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
358+
9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort.
359359
10. [ ] Bridge session share/summary/message/part mutation routes.
360360
11. [ ] Replace event SSE with non-Hono Effect HTTP.
361361
12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP.

packages/opencode/src/server/routes/instance/httpapi/session.ts

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
import * as InstanceState from "@/effect/instance-state"
2+
import { AppRuntime } from "@/effect/app-runtime"
3+
import { Permission } from "@/permission"
24
import { Instance } from "@/project/instance"
5+
import { SessionShare } from "@/share"
36
import { Session } from "@/session"
47
import { MessageV2 } from "@/session/message-v2"
8+
import { SessionPrompt } from "@/session/prompt"
59
import { SessionStatus } from "@/session/status"
610
import { SessionSummary } from "@/session/summary"
711
import { Todo } from "@/session/todo"
@@ -26,6 +30,18 @@ const MessagesQuery = Schema.Struct({
2630
before: Schema.optional(Schema.String),
2731
})
2832
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
33+
const UpdatePayload = Schema.Struct({
34+
title: Schema.optional(Schema.String),
35+
permission: Schema.optional(Permission.Ruleset),
36+
time: Schema.optional(
37+
Schema.Struct({
38+
archived: Schema.optional(Schema.Number),
39+
}),
40+
),
41+
}).annotate({ identifier: "SessionUpdateInput" })
42+
const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({
43+
identifier: "SessionForkInput",
44+
})
2945

3046
export const SessionPaths = {
3147
list: root,
@@ -36,6 +52,11 @@ export const SessionPaths = {
3652
diff: `${root}/:sessionID/diff`,
3753
messages: `${root}/:sessionID/message`,
3854
message: `${root}/:sessionID/message/:messageID`,
55+
create: root,
56+
remove: `${root}/:sessionID`,
57+
update: `${root}/:sessionID`,
58+
fork: `${root}/:sessionID/fork`,
59+
abort: `${root}/:sessionID/abort`,
3960
} as const
4061

4162
export const SessionApi = HttpApi.make("session")
@@ -123,6 +144,58 @@ export const SessionApi = HttpApi.make("session")
123144
description: "Retrieve a specific message from a session by its message ID.",
124145
}),
125146
),
147+
HttpApiEndpoint.post("create", SessionPaths.create, {
148+
payload: Session.CreateInput,
149+
success: Session.Info,
150+
}).annotateMerge(
151+
OpenApi.annotations({
152+
identifier: "session.create",
153+
summary: "Create session",
154+
description: "Create a new OpenCode session for interacting with AI assistants and managing conversations.",
155+
}),
156+
),
157+
HttpApiEndpoint.delete("remove", SessionPaths.remove, {
158+
params: { sessionID: SessionID },
159+
success: Schema.Boolean,
160+
}).annotateMerge(
161+
OpenApi.annotations({
162+
identifier: "session.delete",
163+
summary: "Delete session",
164+
description: "Delete a session and permanently remove all associated data, including messages and history.",
165+
}),
166+
),
167+
HttpApiEndpoint.patch("update", SessionPaths.update, {
168+
params: { sessionID: SessionID },
169+
payload: UpdatePayload,
170+
success: Session.Info,
171+
}).annotateMerge(
172+
OpenApi.annotations({
173+
identifier: "session.update",
174+
summary: "Update session",
175+
description: "Update properties of an existing session, such as title or other metadata.",
176+
}),
177+
),
178+
HttpApiEndpoint.post("fork", SessionPaths.fork, {
179+
params: { sessionID: SessionID },
180+
payload: ForkPayload,
181+
success: Session.Info,
182+
}).annotateMerge(
183+
OpenApi.annotations({
184+
identifier: "session.fork",
185+
summary: "Fork session",
186+
description: "Create a new session by forking an existing session at a specific message point.",
187+
}),
188+
),
189+
HttpApiEndpoint.post("abort", SessionPaths.abort, {
190+
params: { sessionID: SessionID },
191+
success: Schema.Boolean,
192+
}).annotateMerge(
193+
OpenApi.annotations({
194+
identifier: "session.abort",
195+
summary: "Abort session",
196+
description: "Abort an active session and stop any ongoing AI processing or command execution.",
197+
}),
198+
),
126199
)
127200
.annotateMerge(
128201
OpenApi.annotations({
@@ -222,6 +295,86 @@ export const sessionHandlers = Layer.unwrap(
222295
)
223296
})
224297

298+
const create = Effect.fn("SessionHttpApi.create")(function* (ctx: { payload: Session.CreateInput }) {
299+
const instance = yield* InstanceState.context
300+
return yield* Effect.promise(() =>
301+
Instance.restore(instance, () =>
302+
AppRuntime.runPromise(SessionShare.Service.use((svc) => svc.create(ctx.payload)).pipe(Effect.provide(SessionShare.defaultLayer))),
303+
),
304+
)
305+
})
306+
307+
const remove = Effect.fn("SessionHttpApi.remove")(function* (ctx: { params: { sessionID: SessionID } }) {
308+
const instance = yield* InstanceState.context
309+
yield* Effect.promise(() =>
310+
Instance.restore(instance, () =>
311+
AppRuntime.runPromise(Session.Service.use((svc) => svc.remove(ctx.params.sessionID)).pipe(Effect.provide(Session.defaultLayer))),
312+
),
313+
)
314+
return true
315+
})
316+
317+
const update = Effect.fn("SessionHttpApi.update")(function* (ctx: {
318+
params: { sessionID: SessionID }
319+
payload: typeof UpdatePayload.Type
320+
}) {
321+
const instance = yield* InstanceState.context
322+
return yield* Effect.promise(() =>
323+
Instance.restore(instance, () =>
324+
AppRuntime.runPromise(
325+
Session.Service.use((svc) =>
326+
Effect.gen(function* () {
327+
const current = yield* svc.get(ctx.params.sessionID)
328+
if (ctx.payload.title !== undefined) {
329+
yield* svc.setTitle({ sessionID: ctx.params.sessionID, title: ctx.payload.title })
330+
}
331+
if (ctx.payload.permission !== undefined) {
332+
yield* svc.setPermission({
333+
sessionID: ctx.params.sessionID,
334+
permission: Permission.merge(current.permission ?? [], ctx.payload.permission),
335+
})
336+
}
337+
if (ctx.payload.time?.archived !== undefined) {
338+
yield* svc.setArchived({ sessionID: ctx.params.sessionID, time: ctx.payload.time.archived })
339+
}
340+
return yield* svc.get(ctx.params.sessionID)
341+
}),
342+
).pipe(Effect.provide(Session.defaultLayer)),
343+
),
344+
),
345+
)
346+
})
347+
348+
const fork = Effect.fn("SessionHttpApi.fork")(function* (ctx: {
349+
params: { sessionID: SessionID }
350+
payload: typeof ForkPayload.Type
351+
}) {
352+
const instance = yield* InstanceState.context
353+
return yield* Effect.promise(() =>
354+
Instance.restore(instance, () =>
355+
AppRuntime.runPromise(
356+
Session.Service.use((svc) => svc.fork({ sessionID: ctx.params.sessionID, messageID: ctx.payload.messageID })).pipe(
357+
Effect.provide(Session.defaultLayer),
358+
),
359+
),
360+
),
361+
)
362+
})
363+
364+
const abort = Effect.fn("SessionHttpApi.abort")(function* (ctx: { params: { sessionID: SessionID } }) {
365+
const instance = yield* InstanceState.context
366+
yield* Effect.promise(() =>
367+
Instance.restore(instance, () =>
368+
AppRuntime.runPromise(
369+
SessionPrompt.Service.use((svc) => svc.cancel(ctx.params.sessionID)).pipe(
370+
Effect.provide(SessionPrompt.defaultLayer),
371+
),
372+
),
373+
),
374+
)
375+
return true
376+
})
377+
225378
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
226379
handlers
227380
.handle("list", list)
@@ -231,7 +384,12 @@ export const sessionHandlers = Layer.unwrap(
231384
.handle("todo", todo)
232385
.handle("diff", diff)
233386
.handle("messages", messages)
234-
.handle("message", message),
387+
.handle("message", message)
388+
.handle("create", create)
389+
.handle("remove", remove)
390+
.handle("update", update)
391+
.handle("fork", fork)
392+
.handle("abort", abort),
235393
)
236394
}),
237395
).pipe(

packages/opencode/src/server/routes/instance/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
102102
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
103103
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
104104
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
105+
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
106+
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
107+
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
108+
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
109+
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
105110
}
106111

107112
return app

packages/opencode/test/server/httpapi-session.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,48 @@ describe("session HttpApi", () => {
105105

106106
expect(await json<MessageV2.WithParts>(await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), { headers }))).toMatchObject({ info: { id: message.id } })
107107
})
108+
109+
test("serves lifecycle mutation routes through Hono bridge", async () => {
110+
await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false, share: "disabled" } })
111+
const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
112+
113+
const created = await json<Session.Info>(
114+
await app().request(SessionPaths.create, {
115+
method: "POST",
116+
headers,
117+
body: JSON.stringify({ title: "created" }),
118+
}),
119+
)
120+
expect(created.title).toBe("created")
121+
122+
const updated = await json<Session.Info>(
123+
await app().request(pathFor(SessionPaths.update, { sessionID: created.id }), {
124+
method: "PATCH",
125+
headers,
126+
body: JSON.stringify({ title: "updated", time: { archived: 1 } }),
127+
}),
128+
)
129+
expect(updated).toMatchObject({ id: created.id, title: "updated", time: { archived: 1 } })
130+
131+
const forked = await json<Session.Info>(
132+
await app().request(pathFor(SessionPaths.fork, { sessionID: created.id }), {
133+
method: "POST",
134+
headers,
135+
body: JSON.stringify({}),
136+
}),
137+
)
138+
expect(forked.id).not.toBe(created.id)
139+
140+
expect(
141+
await json<boolean>(
142+
await app().request(pathFor(SessionPaths.abort, { sessionID: created.id }), { method: "POST", headers }),
143+
),
144+
).toBe(true)
145+
146+
expect(
147+
await json<boolean>(
148+
await app().request(pathFor(SessionPaths.remove, { sessionID: created.id }), { method: "DELETE", headers }),
149+
),
150+
).toBe(true)
151+
})
108152
})

0 commit comments

Comments
 (0)