Skip to content

Commit 0d7f623

Browse files
kitlangtonoleksii-honchar
authored andcommitted
feat(httpapi): bridge session read routes (anomalyco#24485)
1 parent 0a8c712 commit 0d7f623

5 files changed

Lines changed: 387 additions & 26 deletions

File tree

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

Lines changed: 26 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -170,23 +170,23 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho
170170

171171
## Current Route Status
172172

173-
| Area | Status | Notes |
174-
| ------------------------- | ----------------- | -------------------------------------------------------------------------- |
175-
| `question` | `bridged` | `GET /question`, reply, reject |
176-
| `permission` | `bridged` | list and reply |
177-
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
178-
| `config` | `bridged` | read, providers, update |
179-
| `project` | `bridged` | list, current, git init, update |
180-
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
181-
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
182-
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
183-
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184-
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
185-
| `session` | `later/special` | large stateful surface plus streaming |
186-
| `sync` | `bridged` | start/replay/history |
187-
| `event` | `special` | SSE |
188-
| `pty` | `special` | websocket |
189-
| `tui` | `special` | UI bridge |
173+
| Area | Status | Notes |
174+
| ------------------------- | ----------------- | ---------------------------------------------------------------------------------------- |
175+
| `question` | `bridged` | `GET /question`, reply, reject |
176+
| `permission` | `bridged` | list and reply |
177+
| `provider` | `bridged` | list, auth, OAuth authorize/callback |
178+
| `config` | `bridged` | read, providers, update |
179+
| `project` | `bridged` | list, current, git init, update |
180+
| `file` | `bridged` partial | find text/file/symbol, list/content/status |
181+
| `mcp` | `bridged` | status, add, OAuth, connect/disconnect |
182+
| `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore |
183+
| top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose |
184+
| experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list |
185+
| `session` | `bridged` partial | read routes; lifecycle, message mutations, streaming remain |
186+
| `sync` | `bridged` | start/replay/history |
187+
| `event` | `special` | SSE |
188+
| `pty` | `special` | websocket |
189+
| `tui` | `special` | UI bridge |
190190

191191
## Full Route Checklist
192192

@@ -286,23 +286,23 @@ This checklist tracks bridge parity only. Checked routes are available through t
286286

287287
### Session Routes
288288

289-
- [ ] `GET /session` - list sessions.
290-
- [ ] `GET /session/status` - session status map.
291-
- [ ] `GET /session/:sessionID` - get session.
292-
- [ ] `GET /session/:sessionID/children` - get child sessions.
293-
- [ ] `GET /session/:sessionID/todo` - get session todos.
289+
- [x] `GET /session` - list sessions.
290+
- [x] `GET /session/status` - session status map.
291+
- [x] `GET /session/:sessionID` - get session.
292+
- [x] `GET /session/:sessionID/children` - get child sessions.
293+
- [x] `GET /session/:sessionID/todo` - get session todos.
294294
- [ ] `POST /session` - create session.
295295
- [ ] `DELETE /session/:sessionID` - delete session.
296296
- [ ] `PATCH /session/:sessionID` - update session metadata.
297297
- [ ] `POST /session/:sessionID/init` - run project init command.
298298
- [ ] `POST /session/:sessionID/fork` - fork session.
299299
- [ ] `POST /session/:sessionID/abort` - abort session.
300300
- [ ] `POST /session/:sessionID/share` - share session.
301-
- [ ] `GET /session/:sessionID/diff` - session diff.
301+
- [x] `GET /session/:sessionID/diff` - session diff.
302302
- [ ] `DELETE /session/:sessionID/share` - unshare session.
303303
- [ ] `POST /session/:sessionID/summarize` - summarize session.
304-
- [ ] `GET /session/:sessionID/message` - list session messages.
305-
- [ ] `GET /session/:sessionID/message/:messageID` - get message.
304+
- [x] `GET /session/:sessionID/message` - list session messages.
305+
- [x] `GET /session/:sessionID/message/:messageID` - get message.
306306
- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
307307
- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
308308
- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
@@ -354,7 +354,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev
354354
5. [x] Bridge experimental global session list.
355355
6. [x] Bridge workspace create/remove/session-restore routes.
356356
7. [x] Bridge sync start/replay/history routes.
357-
8. [ ] Bridge session read routes: list, status, get, children, todo, diff, messages.
357+
8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages.
358358
9. [ ] 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.

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { PermissionApi, permissionHandlers } from "./permission"
1818
import { ProjectApi, projectHandlers } from "./project"
1919
import { ProviderApi, providerHandlers } from "./provider"
2020
import { QuestionApi, questionHandlers } from "./question"
21+
import { SessionApi, sessionHandlers } from "./session"
2122
import { SyncApi, syncHandlers } from "./sync"
2223
import { WorkspaceApi, workspaceHandlers } from "./workspace"
2324
import { disposeMiddleware } from "./lifecycle"
@@ -74,6 +75,7 @@ export const routes = Layer.mergeAll(
7475
HttpApiBuilder.layer(QuestionApi).pipe(Layer.provide(questionHandlers)),
7576
HttpApiBuilder.layer(PermissionApi).pipe(Layer.provide(permissionHandlers)),
7677
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
78+
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
7779
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
7880
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
7981
).pipe(
Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import * as InstanceState from "@/effect/instance-state"
2+
import { Instance } from "@/project/instance"
3+
import { Session } from "@/session"
4+
import { MessageV2 } from "@/session/message-v2"
5+
import { SessionStatus } from "@/session/status"
6+
import { SessionSummary } from "@/session/summary"
7+
import { Todo } from "@/session/todo"
8+
import { MessageID, SessionID } from "@/session/schema"
9+
import { Snapshot } from "@/snapshot"
10+
import { Effect, Layer, Schema, Struct } from "effect"
11+
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
12+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
13+
import { Authorization } from "./auth"
14+
15+
const root = "/session"
16+
const ListQuery = Schema.Struct({
17+
directory: Schema.optional(Schema.String),
18+
roots: Schema.optional(Schema.Literals(["true", "false"])),
19+
start: Schema.optional(Schema.NumberFromString),
20+
search: Schema.optional(Schema.String),
21+
limit: Schema.optional(Schema.NumberFromString),
22+
})
23+
const DiffQuery = Schema.Struct(Struct.omit(SessionSummary.DiffInput.fields, ["sessionID"]))
24+
const MessagesQuery = Schema.Struct({
25+
limit: Schema.optional(Schema.NumberFromString.check(Schema.isInt(), Schema.isGreaterThanOrEqualTo(0))),
26+
before: Schema.optional(Schema.String),
27+
})
28+
const StatusMap = Schema.Record(Schema.String, SessionStatus.Info)
29+
30+
export const SessionPaths = {
31+
list: root,
32+
status: `${root}/status`,
33+
get: `${root}/:sessionID`,
34+
children: `${root}/:sessionID/children`,
35+
todo: `${root}/:sessionID/todo`,
36+
diff: `${root}/:sessionID/diff`,
37+
messages: `${root}/:sessionID/message`,
38+
message: `${root}/:sessionID/message/:messageID`,
39+
} as const
40+
41+
export const SessionApi = HttpApi.make("session")
42+
.add(
43+
HttpApiGroup.make("session")
44+
.add(
45+
HttpApiEndpoint.get("list", SessionPaths.list, {
46+
query: ListQuery,
47+
success: Schema.Array(Session.Info),
48+
}).annotateMerge(
49+
OpenApi.annotations({
50+
identifier: "session.list",
51+
summary: "List sessions",
52+
description: "Get a list of all OpenCode sessions, sorted by most recently updated.",
53+
}),
54+
),
55+
HttpApiEndpoint.get("status", SessionPaths.status, {
56+
success: StatusMap,
57+
}).annotateMerge(
58+
OpenApi.annotations({
59+
identifier: "session.status",
60+
summary: "Get session status",
61+
description: "Retrieve the current status of all sessions, including active, idle, and completed states.",
62+
}),
63+
),
64+
HttpApiEndpoint.get("get", SessionPaths.get, {
65+
params: { sessionID: SessionID },
66+
success: Session.Info,
67+
}).annotateMerge(
68+
OpenApi.annotations({
69+
identifier: "session.get",
70+
summary: "Get session",
71+
description: "Retrieve detailed information about a specific OpenCode session.",
72+
}),
73+
),
74+
HttpApiEndpoint.get("children", SessionPaths.children, {
75+
params: { sessionID: SessionID },
76+
success: Schema.Array(Session.Info),
77+
}).annotateMerge(
78+
OpenApi.annotations({
79+
identifier: "session.children",
80+
summary: "Get session children",
81+
description: "Retrieve all child sessions that were forked from the specified parent session.",
82+
}),
83+
),
84+
HttpApiEndpoint.get("todo", SessionPaths.todo, {
85+
params: { sessionID: SessionID },
86+
success: Schema.Array(Todo.Info),
87+
}).annotateMerge(
88+
OpenApi.annotations({
89+
identifier: "session.todo",
90+
summary: "Get session todos",
91+
description: "Retrieve the todo list associated with a specific session, showing tasks and action items.",
92+
}),
93+
),
94+
HttpApiEndpoint.get("diff", SessionPaths.diff, {
95+
params: { sessionID: SessionID },
96+
query: DiffQuery,
97+
success: Schema.Array(Snapshot.FileDiff),
98+
}).annotateMerge(
99+
OpenApi.annotations({
100+
identifier: "session.diff",
101+
summary: "Get message diff",
102+
description: "Get the file changes (diff) that resulted from a specific user message in the session.",
103+
}),
104+
),
105+
HttpApiEndpoint.get("messages", SessionPaths.messages, {
106+
params: { sessionID: SessionID },
107+
query: MessagesQuery,
108+
success: Schema.Array(MessageV2.WithParts),
109+
}).annotateMerge(
110+
OpenApi.annotations({
111+
identifier: "session.messages",
112+
summary: "Get session messages",
113+
description: "Retrieve all messages in a session, including user prompts and AI responses.",
114+
}),
115+
),
116+
HttpApiEndpoint.get("message", SessionPaths.message, {
117+
params: { sessionID: SessionID, messageID: MessageID },
118+
success: MessageV2.WithParts,
119+
}).annotateMerge(
120+
OpenApi.annotations({
121+
identifier: "session.message",
122+
summary: "Get message",
123+
description: "Retrieve a specific message from a session by its message ID.",
124+
}),
125+
),
126+
)
127+
.annotateMerge(
128+
OpenApi.annotations({
129+
title: "session",
130+
description: "Experimental HttpApi session routes.",
131+
}),
132+
)
133+
.middleware(Authorization),
134+
)
135+
.annotateMerge(
136+
OpenApi.annotations({
137+
title: "opencode experimental HttpApi",
138+
version: "0.0.1",
139+
description: "Experimental HttpApi surface for selected instance routes.",
140+
}),
141+
)
142+
143+
export const sessionHandlers = Layer.unwrap(
144+
Effect.gen(function* () {
145+
const session = yield* Session.Service
146+
const statusSvc = yield* SessionStatus.Service
147+
const todoSvc = yield* Todo.Service
148+
const summary = yield* SessionSummary.Service
149+
150+
const list = Effect.fn("SessionHttpApi.list")(function* (ctx: { query: typeof ListQuery.Type }) {
151+
const instance = yield* InstanceState.context
152+
return Instance.restore(instance, () =>
153+
Array.from(
154+
Session.list({
155+
directory: ctx.query.directory,
156+
roots: ctx.query.roots === "true" ? true : undefined,
157+
start: ctx.query.start,
158+
search: ctx.query.search,
159+
limit: ctx.query.limit,
160+
}),
161+
),
162+
)
163+
})
164+
165+
const status = Effect.fn("SessionHttpApi.status")(function* () {
166+
return Object.fromEntries(yield* statusSvc.list())
167+
})
168+
169+
const get = Effect.fn("SessionHttpApi.get")(function* (ctx: { params: { sessionID: SessionID } }) {
170+
return yield* session.get(ctx.params.sessionID)
171+
})
172+
173+
const children = Effect.fn("SessionHttpApi.children")(function* (ctx: { params: { sessionID: SessionID } }) {
174+
return yield* session.children(ctx.params.sessionID)
175+
})
176+
177+
const todo = Effect.fn("SessionHttpApi.todo")(function* (ctx: { params: { sessionID: SessionID } }) {
178+
return yield* todoSvc.get(ctx.params.sessionID)
179+
})
180+
181+
const diff = Effect.fn("SessionHttpApi.diff")(function* (ctx: {
182+
params: { sessionID: SessionID }
183+
query: typeof DiffQuery.Type
184+
}) {
185+
return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID })
186+
})
187+
188+
const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: {
189+
params: { sessionID: SessionID }
190+
query: typeof MessagesQuery.Type
191+
}) {
192+
if (ctx.query.limit === undefined || ctx.query.limit === 0) {
193+
yield* session.get(ctx.params.sessionID)
194+
return yield* session.messages({ sessionID: ctx.params.sessionID })
195+
}
196+
197+
const page = MessageV2.page({
198+
sessionID: ctx.params.sessionID,
199+
limit: ctx.query.limit,
200+
before: ctx.query.before,
201+
})
202+
if (!page.cursor) return page.items
203+
204+
const request = yield* HttpServerRequest.HttpServerRequest
205+
const url = new URL(request.url, "http://localhost")
206+
url.searchParams.set("limit", ctx.query.limit.toString())
207+
url.searchParams.set("before", page.cursor)
208+
return HttpServerResponse.jsonUnsafe(page.items, {
209+
headers: {
210+
"Access-Control-Expose-Headers": "Link, X-Next-Cursor",
211+
Link: `<${url.toString()}>; rel="next"`,
212+
"X-Next-Cursor": page.cursor,
213+
},
214+
})
215+
})
216+
217+
const message = Effect.fn("SessionHttpApi.message")(function* (ctx: {
218+
params: { sessionID: SessionID; messageID: MessageID }
219+
}) {
220+
return yield* Effect.sync(() =>
221+
MessageV2.get({ sessionID: ctx.params.sessionID, messageID: ctx.params.messageID }),
222+
)
223+
})
224+
225+
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
226+
handlers
227+
.handle("list", list)
228+
.handle("status", status)
229+
.handle("get", get)
230+
.handle("children", children)
231+
.handle("todo", todo)
232+
.handle("diff", diff)
233+
.handle("messages", messages)
234+
.handle("message", message),
235+
)
236+
}),
237+
).pipe(
238+
Layer.provide(Session.defaultLayer),
239+
Layer.provide(SessionStatus.defaultLayer),
240+
Layer.provide(Todo.defaultLayer),
241+
Layer.provide(SessionSummary.defaultLayer),
242+
)

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

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { ExperimentalPaths } from "./httpapi/experimental"
2020
import { FilePaths } from "./httpapi/file"
2121
import { InstancePaths } from "./httpapi/instance"
2222
import { McpPaths } from "./httpapi/mcp"
23+
import { SessionPaths } from "./httpapi/session"
2324
import { SyncPaths } from "./httpapi/sync"
2425
import { ProjectRoutes } from "./project"
2526
import { SessionRoutes } from "./session"
@@ -93,6 +94,14 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
9394
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
9495
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
9596
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
97+
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
98+
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
99+
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
100+
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
101+
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
102+
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
103+
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
104+
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
96105
}
97106

98107
return app

0 commit comments

Comments
 (0)