Skip to content

Commit 8e91008

Browse files
committed
core: expose projected v2 session messages
1 parent 876890f commit 8e91008

14 files changed

Lines changed: 480 additions & 121 deletions

File tree

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ import { QuestionApi, questionHandlers } from "./question"
5252
import { SessionApi, sessionHandlers } from "./session"
5353
import { SyncApi, syncHandlers } from "./sync"
5454
import { TuiApi, tuiHandlers } from "./tui"
55+
import { V2Api, v2Handlers } from "./v2"
5556
import { WorkspaceApi, workspaceHandlers } from "./workspace"
5657
import { disposeMiddleware } from "./lifecycle"
5758
import { memoMap } from "@opencode-ai/core/effect/memo-map"
@@ -114,6 +115,7 @@ const instanceApiRoutes = Layer.mergeAll(
114115
HttpApiBuilder.layer(ProviderApi).pipe(Layer.provide(providerHandlers)),
115116
HttpApiBuilder.layer(SessionApi).pipe(Layer.provide(sessionHandlers)),
116117
HttpApiBuilder.layer(SyncApi).pipe(Layer.provide(syncHandlers)),
118+
HttpApiBuilder.layer(V2Api).pipe(Layer.provide(v2Handlers)),
117119
HttpApiBuilder.layer(TuiApi).pipe(Layer.provide(tuiHandlers)),
118120
HttpApiBuilder.layer(WorkspaceApi).pipe(Layer.provide(workspaceHandlers)),
119121
)
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Session as LegacySession } from "@/session/session"
2+
import { SessionID } from "@/session/schema"
3+
import { SessionMessage } from "@/v2/session-message"
4+
import { SessionV2 } from "@/v2/session"
5+
import { Effect, Layer, Schema } from "effect"
6+
import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi"
7+
import { Authorization } from "./auth"
8+
9+
export const V2Api = HttpApi.make("v2")
10+
.add(
11+
HttpApiGroup.make("v2")
12+
.add(
13+
HttpApiEndpoint.get("messages", "/api/session/:sessionID/message", {
14+
params: { sessionID: SessionID },
15+
success: Schema.Array(SessionMessage.Message),
16+
}).annotateMerge(
17+
OpenApi.annotations({
18+
identifier: "v2.session.messages",
19+
summary: "Get v2 session messages",
20+
description: "Retrieve projected v2 messages for a session directly from the message database.",
21+
}),
22+
),
23+
)
24+
.annotateMerge(
25+
OpenApi.annotations({
26+
title: "v2",
27+
description: "Experimental v2 routes.",
28+
}),
29+
)
30+
.middleware(Authorization),
31+
)
32+
.annotateMerge(
33+
OpenApi.annotations({
34+
title: "opencode experimental HttpApi",
35+
version: "0.0.1",
36+
description: "Experimental HttpApi surface for selected instance routes.",
37+
}),
38+
)
39+
40+
export const v2Handlers = Layer.unwrap(
41+
Effect.gen(function* () {
42+
const legacySession = yield* LegacySession.Service
43+
const session = yield* SessionV2.Service
44+
45+
const messages = Effect.fn("V2HttpApi.messages")(function* (ctx: { params: { sessionID: SessionID } }) {
46+
yield* legacySession.get(ctx.params.sessionID)
47+
return yield* session.messages(ctx.params.sessionID)
48+
})
49+
50+
return HttpApiBuilder.group(V2Api, "v2", (handlers) => handlers.handle("messages", messages))
51+
}),
52+
).pipe(Layer.provide(LegacySession.defaultLayer), Layer.provide(SessionV2.layer))
53+
54+
export * as V2HttpApi from "./v2"

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

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { describeRoute, resolver, validator } from "hono-openapi"
22
import { Hono } from "hono"
33
import type { UpgradeWebSocket } from "hono/ws"
4-
import { Effect } from "effect"
4+
import { Context, Effect } from "effect"
5+
import { Flag } from "@opencode-ai/core/flag/flag"
56
import z from "zod"
67
import { Format } from "@/format"
78
import { TuiRoutes } from "./tui"
@@ -26,10 +27,133 @@ import { EventRoutes } from "./event"
2627
import { SyncRoutes } from "./sync"
2728
import { InstanceMiddleware } from "./middleware"
2829
import { jsonRequest } from "./trace"
30+
import { ExperimentalHttpApiServer } from "./httpapi/server"
31+
import { EventPaths } from "./httpapi/event"
32+
import { ExperimentalPaths } from "./httpapi/experimental"
33+
import { FilePaths } from "./httpapi/file"
34+
import { InstancePaths } from "./httpapi/instance"
35+
import { McpPaths } from "./httpapi/mcp"
36+
import { PtyPaths } from "./httpapi/pty"
37+
import { SessionPaths } from "./httpapi/session"
38+
import { SyncPaths } from "./httpapi/sync"
39+
import { TuiPaths } from "./httpapi/tui"
40+
import { WorkspacePaths } from "./httpapi/workspace"
2941

3042
export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
3143
const app = new Hono()
3244

45+
if (Flag.OPENCODE_EXPERIMENTAL_HTTPAPI) {
46+
const handler = ExperimentalHttpApiServer.webHandler().handler
47+
const context = Context.empty() as Context.Context<unknown>
48+
app.all("/api/*", (c) => handler(c.req.raw, context))
49+
app.get(EventPaths.event, (c) => handler(c.req.raw, context))
50+
app.get("/question", (c) => handler(c.req.raw, context))
51+
app.post("/question/:requestID/reply", (c) => handler(c.req.raw, context))
52+
app.post("/question/:requestID/reject", (c) => handler(c.req.raw, context))
53+
app.get("/permission", (c) => handler(c.req.raw, context))
54+
app.post("/permission/:requestID/reply", (c) => handler(c.req.raw, context))
55+
app.get("/config", (c) => handler(c.req.raw, context))
56+
app.patch("/config", (c) => handler(c.req.raw, context))
57+
app.get("/config/providers", (c) => handler(c.req.raw, context))
58+
app.get(ExperimentalPaths.console, (c) => handler(c.req.raw, context))
59+
app.get(ExperimentalPaths.consoleOrgs, (c) => handler(c.req.raw, context))
60+
app.post(ExperimentalPaths.consoleSwitch, (c) => handler(c.req.raw, context))
61+
app.get(ExperimentalPaths.tool, (c) => handler(c.req.raw, context))
62+
app.get(ExperimentalPaths.toolIDs, (c) => handler(c.req.raw, context))
63+
app.get(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
64+
app.post(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
65+
app.delete(ExperimentalPaths.worktree, (c) => handler(c.req.raw, context))
66+
app.post(ExperimentalPaths.worktreeReset, (c) => handler(c.req.raw, context))
67+
app.get(ExperimentalPaths.session, (c) => handler(c.req.raw, context))
68+
app.get(ExperimentalPaths.resource, (c) => handler(c.req.raw, context))
69+
app.get("/provider", (c) => handler(c.req.raw, context))
70+
app.get("/provider/auth", (c) => handler(c.req.raw, context))
71+
app.post("/provider/:providerID/oauth/authorize", (c) => handler(c.req.raw, context))
72+
app.post("/provider/:providerID/oauth/callback", (c) => handler(c.req.raw, context))
73+
app.get("/project", (c) => handler(c.req.raw, context))
74+
app.get("/project/current", (c) => handler(c.req.raw, context))
75+
app.post("/project/git/init", (c) => handler(c.req.raw, context))
76+
app.patch("/project/:projectID", (c) => handler(c.req.raw, context))
77+
app.get(FilePaths.findText, (c) => handler(c.req.raw, context))
78+
app.get(FilePaths.findFile, (c) => handler(c.req.raw, context))
79+
app.get(FilePaths.findSymbol, (c) => handler(c.req.raw, context))
80+
app.get(FilePaths.list, (c) => handler(c.req.raw, context))
81+
app.get(FilePaths.content, (c) => handler(c.req.raw, context))
82+
app.get(FilePaths.status, (c) => handler(c.req.raw, context))
83+
app.get(InstancePaths.path, (c) => handler(c.req.raw, context))
84+
app.post(InstancePaths.dispose, (c) => handler(c.req.raw, context))
85+
app.get(InstancePaths.vcs, (c) => handler(c.req.raw, context))
86+
app.get(InstancePaths.vcsDiff, (c) => handler(c.req.raw, context))
87+
app.get(InstancePaths.command, (c) => handler(c.req.raw, context))
88+
app.get(InstancePaths.agent, (c) => handler(c.req.raw, context))
89+
app.get(InstancePaths.skill, (c) => handler(c.req.raw, context))
90+
app.get(InstancePaths.lsp, (c) => handler(c.req.raw, context))
91+
app.get(InstancePaths.formatter, (c) => handler(c.req.raw, context))
92+
app.get(McpPaths.status, (c) => handler(c.req.raw, context))
93+
app.post(McpPaths.status, (c) => handler(c.req.raw, context))
94+
app.post(McpPaths.auth, (c) => handler(c.req.raw, context))
95+
app.post(McpPaths.authCallback, (c) => handler(c.req.raw, context))
96+
app.post(McpPaths.authAuthenticate, (c) => handler(c.req.raw, context))
97+
app.delete(McpPaths.auth, (c) => handler(c.req.raw, context))
98+
app.post(McpPaths.connect, (c) => handler(c.req.raw, context))
99+
app.post(McpPaths.disconnect, (c) => handler(c.req.raw, context))
100+
app.post(SyncPaths.start, (c) => handler(c.req.raw, context))
101+
app.post(SyncPaths.replay, (c) => handler(c.req.raw, context))
102+
app.post(SyncPaths.history, (c) => handler(c.req.raw, context))
103+
app.get(PtyPaths.list, (c) => handler(c.req.raw, context))
104+
app.post(PtyPaths.create, (c) => handler(c.req.raw, context))
105+
app.get(PtyPaths.get, (c) => handler(c.req.raw, context))
106+
app.put(PtyPaths.update, (c) => handler(c.req.raw, context))
107+
app.delete(PtyPaths.remove, (c) => handler(c.req.raw, context))
108+
app.get(PtyPaths.connect, (c) => handler(c.req.raw, context))
109+
app.get(SessionPaths.list, (c) => handler(c.req.raw, context))
110+
app.get(SessionPaths.status, (c) => handler(c.req.raw, context))
111+
app.get(SessionPaths.get, (c) => handler(c.req.raw, context))
112+
app.get(SessionPaths.children, (c) => handler(c.req.raw, context))
113+
app.get(SessionPaths.todo, (c) => handler(c.req.raw, context))
114+
app.get(SessionPaths.diff, (c) => handler(c.req.raw, context))
115+
app.get(SessionPaths.messages, (c) => handler(c.req.raw, context))
116+
app.get(SessionPaths.message, (c) => handler(c.req.raw, context))
117+
app.post(SessionPaths.create, (c) => handler(c.req.raw, context))
118+
app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context))
119+
app.patch(SessionPaths.update, (c) => handler(c.req.raw, context))
120+
app.post(SessionPaths.init, (c) => handler(c.req.raw, context))
121+
app.post(SessionPaths.fork, (c) => handler(c.req.raw, context))
122+
app.post(SessionPaths.abort, (c) => handler(c.req.raw, context))
123+
app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
124+
app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
125+
app.post(SessionPaths.summarize, (c) => handler(c.req.raw, context))
126+
app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context))
127+
app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context))
128+
app.post(SessionPaths.command, (c) => handler(c.req.raw, context))
129+
app.post(SessionPaths.shell, (c) => handler(c.req.raw, context))
130+
app.post(SessionPaths.revert, (c) => handler(c.req.raw, context))
131+
app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context))
132+
app.post(SessionPaths.permissions, (c) => handler(c.req.raw, context))
133+
app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
134+
app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
135+
app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
136+
app.post(TuiPaths.appendPrompt, (c) => handler(c.req.raw, context))
137+
app.post(TuiPaths.openHelp, (c) => handler(c.req.raw, context))
138+
app.post(TuiPaths.openSessions, (c) => handler(c.req.raw, context))
139+
app.post(TuiPaths.openThemes, (c) => handler(c.req.raw, context))
140+
app.post(TuiPaths.openModels, (c) => handler(c.req.raw, context))
141+
app.post(TuiPaths.submitPrompt, (c) => handler(c.req.raw, context))
142+
app.post(TuiPaths.clearPrompt, (c) => handler(c.req.raw, context))
143+
app.post(TuiPaths.executeCommand, (c) => handler(c.req.raw, context))
144+
app.post(TuiPaths.showToast, (c) => handler(c.req.raw, context))
145+
app.post(TuiPaths.publish, (c) => handler(c.req.raw, context))
146+
app.post(TuiPaths.selectSession, (c) => handler(c.req.raw, context))
147+
app.get(TuiPaths.controlNext, (c) => handler(c.req.raw, context))
148+
app.post(TuiPaths.controlResponse, (c) => handler(c.req.raw, context))
149+
app.get(WorkspacePaths.adaptors, (c) => handler(c.req.raw, context))
150+
app.post(WorkspacePaths.list, (c) => handler(c.req.raw, context))
151+
app.get(WorkspacePaths.list, (c) => handler(c.req.raw, context))
152+
app.get(WorkspacePaths.status, (c) => handler(c.req.raw, context))
153+
app.delete(WorkspacePaths.remove, (c) => handler(c.req.raw, context))
154+
app.post(WorkspacePaths.sessionRestore, (c) => handler(c.req.raw, context))
155+
}
156+
33157
return app
34158
.route("/project", ProjectRoutes())
35159
.route("/pty", PtyRoutes(upgrade))

packages/opencode/src/session/processor.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,7 @@ export const layer: Layer.Layer<
224224

225225
case "reasoning-start":
226226
if (value.id in ctx.reasoningMap) return
227+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
227228
SyncEvent.run(SessionEvent.Reasoning.Started.Sync, {
228229
sessionID: ctx.sessionID,
229230
reasoningID: value.id,
@@ -243,6 +244,7 @@ export const layer: Layer.Layer<
243244

244245
case "reasoning-delta":
245246
if (!(value.id in ctx.reasoningMap)) return
247+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
246248
SyncEvent.run(SessionEvent.Reasoning.Delta.Sync, {
247249
sessionID: ctx.sessionID,
248250
reasoningID: value.id,
@@ -262,6 +264,7 @@ export const layer: Layer.Layer<
262264

263265
case "reasoning-end":
264266
if (!(value.id in ctx.reasoningMap)) return
267+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
265268
SyncEvent.run(SessionEvent.Reasoning.Ended.Sync, {
266269
sessionID: ctx.sessionID,
267270
reasoningID: value.id,
@@ -280,6 +283,7 @@ export const layer: Layer.Layer<
280283
if (ctx.assistantMessage.summary) {
281284
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
282285
}
286+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
283287
SyncEvent.run(SessionEvent.Tool.Input.Started.Sync, {
284288
sessionID: ctx.sessionID,
285289
callID: value.id,
@@ -308,6 +312,7 @@ export const layer: Layer.Layer<
308312
return
309313

310314
case "tool-input-end": {
315+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
311316
SyncEvent.run(SessionEvent.Tool.Input.Ended.Sync, {
312317
sessionID: ctx.sessionID,
313318
callID: value.id,
@@ -322,6 +327,7 @@ export const layer: Layer.Layer<
322327
throw new Error(`Tool call not allowed while generating summary: ${value.toolName}`)
323328
}
324329
const toolCall = yield* readToolCall(value.toolCallId)
330+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
325331
SyncEvent.run(SessionEvent.Tool.Called.Sync, {
326332
sessionID: ctx.sessionID,
327333
callID: value.toolCallId,
@@ -377,6 +383,7 @@ export const layer: Layer.Layer<
377383

378384
case "tool-result": {
379385
const toolCall = yield* readToolCall(value.toolCallId)
386+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
380387
SyncEvent.run(SessionEvent.Tool.Success.Sync, {
381388
sessionID: ctx.sessionID,
382389
callID: value.toolCallId,
@@ -404,6 +411,7 @@ export const layer: Layer.Layer<
404411

405412
case "tool-error": {
406413
const toolCall = yield* readToolCall(value.toolCallId)
414+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
407415
SyncEvent.run(SessionEvent.Tool.Error.Sync, {
408416
sessionID: ctx.sessionID,
409417
callID: value.toolCallId,
@@ -425,6 +433,7 @@ export const layer: Layer.Layer<
425433

426434
case "start-step":
427435
if (!ctx.snapshot) ctx.snapshot = yield* snapshot.track()
436+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
428437
SyncEvent.run(SessionEvent.Step.Started.Sync, {
429438
sessionID: ctx.sessionID,
430439
model: {
@@ -451,6 +460,7 @@ export const layer: Layer.Layer<
451460
usage: value.usage,
452461
metadata: value.providerMetadata,
453462
})
463+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
454464
SyncEvent.run(SessionEvent.Step.Ended.Sync, {
455465
sessionID: ctx.sessionID,
456466
reason: value.finishReason,
@@ -503,6 +513,7 @@ export const layer: Layer.Layer<
503513
}
504514

505515
case "text-start":
516+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
506517
SyncEvent.run(SessionEvent.Text.Started.Sync, {
507518
sessionID: ctx.sessionID,
508519
timestamp: DateTime.makeUnsafe(Date.now()),
@@ -545,6 +556,7 @@ export const layer: Layer.Layer<
545556
},
546557
{ text: ctx.currentText.text },
547558
)).text
559+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
548560
SyncEvent.run(SessionEvent.Text.Ended.Sync, {
549561
sessionID: ctx.sessionID,
550562
text: ctx.currentText.text,
@@ -677,6 +689,7 @@ export const layer: Layer.Layer<
677689
SessionRetry.policy({
678690
parse,
679691
set: (info) => {
692+
// TODO(v2): Temporary dual-write while migrating session messages to v2 events.
680693
SyncEvent.run(SessionEvent.Retried.Sync, {
681694
sessionID: ctx.sessionID,
682695
attempt: info.attempt,

packages/opencode/src/session/projectors-next.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,23 @@ import * as DateTime from "effect/DateTime"
77
import { SyncEvent } from "@/sync"
88
import { SessionMessageTable } from "./session.sql"
99
import type { SessionID } from "./schema"
10+
import { Schema } from "effect"
11+
12+
const decodeMessage = Schema.decodeUnknownSync(SessionMessage.Message)
13+
type SessionMessageData = NonNullable<typeof SessionMessageTable.$inferInsert["data"]>
14+
15+
function encodeDateTimes(value: unknown): unknown {
16+
if (DateTime.isDateTime(value)) return DateTime.toEpochMillis(value)
17+
if (Array.isArray(value)) return value.map(encodeDateTimes)
18+
if (typeof value === "object" && value !== null) {
19+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, encodeDateTimes(item)]))
20+
}
21+
return value
22+
}
23+
24+
function encodeMessageData(value: unknown): SessionMessageData {
25+
return encodeDateTimes(value) as SessionMessageData
26+
}
1027

1128
function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdater.Adapter<void> {
1229
return {
@@ -17,13 +34,13 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate
1734
.where(and(eq(SessionMessageTable.session_id, sessionID), eq(SessionMessageTable.type, "assistant")))
1835
.orderBy(desc(SessionMessageTable.id))
1936
.all()
20-
.map((row) => ({ id: row.id, type: row.type, ...row.data }) as SessionMessage.Message)
37+
.map((row) => decodeMessage({ ...row.data, id: row.id, type: row.type }))
2138
.find((message): message is SessionMessage.Assistant => message.type === "assistant" && !message.time.completed)
2239
},
2340
updateAssistant(assistant) {
2441
const { id, type, ...data } = assistant
2542
db.update(SessionMessageTable)
26-
.set({ data })
43+
.set({ data: encodeMessageData(data) })
2744
.where(
2845
and(
2946
eq(SessionMessageTable.id, id),
@@ -36,13 +53,15 @@ function sqlite(db: Database.TxOrDb, sessionID: SessionID): SessionMessageUpdate
3653
appendMessage(message) {
3754
const { id, type, ...data } = message
3855
db.insert(SessionMessageTable)
39-
.values({
40-
id,
41-
session_id: sessionID,
42-
type,
43-
time_created: DateTime.toEpochMillis(message.time.created),
44-
data,
45-
})
56+
.values([
57+
{
58+
id,
59+
session_id: sessionID,
60+
type,
61+
time_created: DateTime.toEpochMillis(message.time.created),
62+
data: encodeMessageData(data),
63+
},
64+
])
4665
.run()
4766
},
4867
appendPending() {},

0 commit comments

Comments
 (0)