Browse Source

feat(httpapi): bridge session message mutations (#24487)

Kit Langton 4 months ago
parent
commit
151df05eeb

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

@@ -297,15 +297,15 @@ This checklist tracks bridge parity only. Checked routes are available through t
 - [ ] `POST /session/:sessionID/init` - run project init command.
 - [x] `POST /session/:sessionID/fork` - fork session.
 - [x] `POST /session/:sessionID/abort` - abort session.
-- [ ] `POST /session/:sessionID/share` - share session.
+- [x] `POST /session/:sessionID/share` - share session.
 - [x] `GET /session/:sessionID/diff` - session diff.
-- [ ] `DELETE /session/:sessionID/share` - unshare session.
+- [x] `DELETE /session/:sessionID/share` - unshare session.
 - [ ] `POST /session/:sessionID/summarize` - summarize session.
 - [x] `GET /session/:sessionID/message` - list session messages.
 - [x] `GET /session/:sessionID/message/:messageID` - get message.
-- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
-- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
-- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
+- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
+- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
+- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
 - [ ] `POST /session/:sessionID/message` - prompt with streaming response.
 - [ ] `POST /session/:sessionID/prompt_async` - async prompt.
 - [ ] `POST /session/:sessionID/command` - run command.

+ 148 - 2
packages/opencode/src/server/routes/instance/httpapi/session.ts

@@ -6,10 +6,11 @@ import { SessionShare } from "@/share"
 import { Session } from "@/session"
 import { MessageV2 } from "@/session/message-v2"
 import { SessionPrompt } from "@/session/prompt"
+import { SessionRunState } from "@/session/run-state"
 import { SessionStatus } from "@/session/status"
 import { SessionSummary } from "@/session/summary"
 import { Todo } from "@/session/todo"
-import { MessageID, SessionID } from "@/session/schema"
+import { MessageID, PartID, SessionID } from "@/session/schema"
 import { Snapshot } from "@/snapshot"
 import { Effect, Layer, Schema, Struct } from "effect"
 import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
@@ -57,6 +58,10 @@ export const SessionPaths = {
   update: `${root}/:sessionID`,
   fork: `${root}/:sessionID/fork`,
   abort: `${root}/:sessionID/abort`,
+  share: `${root}/:sessionID/share`,
+  deleteMessage: `${root}/:sessionID/message/:messageID`,
+  deletePart: `${root}/:sessionID/message/:messageID/part/:partID`,
+  updatePart: `${root}/:sessionID/message/:messageID/part/:partID`,
 } as const
 
 export const SessionApi = HttpApi.make("session")
@@ -196,6 +201,56 @@ export const SessionApi = HttpApi.make("session")
             description: "Abort an active session and stop any ongoing AI processing or command execution.",
           }),
         ),
+        HttpApiEndpoint.post("share", SessionPaths.share, {
+          params: { sessionID: SessionID },
+          success: Session.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.share",
+            summary: "Share session",
+            description: "Create a shareable link for a session, allowing others to view the conversation.",
+          }),
+        ),
+        HttpApiEndpoint.delete("unshare", SessionPaths.share, {
+          params: { sessionID: SessionID },
+          success: Session.Info,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.unshare",
+            summary: "Unshare session",
+            description: "Remove the shareable link for a session, making it private again.",
+          }),
+        ),
+        HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, {
+          params: { sessionID: SessionID, messageID: MessageID },
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "session.deleteMessage",
+            summary: "Delete message",
+            description:
+              "Permanently delete a specific message and all of its parts from a session without reverting file changes.",
+          }),
+        ),
+        HttpApiEndpoint.delete("deletePart", SessionPaths.deletePart, {
+          params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
+          success: Schema.Boolean,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "part.delete",
+            description: "Delete a part from a message.",
+          }),
+        ),
+        HttpApiEndpoint.patch("updatePart", SessionPaths.updatePart, {
+          params: { sessionID: SessionID, messageID: MessageID, partID: PartID },
+          payload: MessageV2.Part,
+          success: MessageV2.Part,
+        }).annotateMerge(
+          OpenApi.annotations({
+            identifier: "part.update",
+            description: "Update a part in a message.",
+          }),
+        ),
       )
       .annotateMerge(
         OpenApi.annotations({
@@ -379,6 +434,91 @@ export const sessionHandlers = Layer.unwrap(
       return true
     })
 
+    const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) {
+      const instance = yield* InstanceState.context
+      return yield* Effect.promise(() =>
+        Instance.restore(instance, () =>
+          AppRuntime.runPromise(
+            Effect.gen(function* () {
+              const share = yield* SessionShare.Service
+              const session = yield* Session.Service
+              yield* share.share(ctx.params.sessionID)
+              return yield* session.get(ctx.params.sessionID)
+            }).pipe(Effect.provide(SessionShare.defaultLayer)),
+          ),
+        ),
+      )
+    })
+
+    const unshare = Effect.fn("SessionHttpApi.unshare")(function* (ctx: { params: { sessionID: SessionID } }) {
+      const instance = yield* InstanceState.context
+      return yield* Effect.promise(() =>
+        Instance.restore(instance, () =>
+          AppRuntime.runPromise(
+            Effect.gen(function* () {
+              const share = yield* SessionShare.Service
+              const session = yield* Session.Service
+              yield* share.unshare(ctx.params.sessionID)
+              return yield* session.get(ctx.params.sessionID)
+            }).pipe(Effect.provide(SessionShare.defaultLayer)),
+          ),
+        ),
+      )
+    })
+
+    const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: {
+      params: { sessionID: SessionID; messageID: MessageID }
+    }) {
+      const instance = yield* InstanceState.context
+      yield* Effect.promise(() =>
+        Instance.restore(instance, () =>
+          AppRuntime.runPromise(
+            Effect.gen(function* () {
+              const state = yield* SessionRunState.Service
+              const session = yield* Session.Service
+              yield* state.assertNotBusy(ctx.params.sessionID)
+              yield* session.removeMessage(ctx.params)
+            }).pipe(Effect.provide(SessionRunState.defaultLayer), Effect.provide(Session.defaultLayer)),
+          ),
+        ),
+      )
+      return true
+    })
+
+    const deletePart = Effect.fn("SessionHttpApi.deletePart")(function* (ctx: {
+      params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
+    }) {
+      const instance = yield* InstanceState.context
+      yield* Effect.promise(() =>
+        Instance.restore(instance, () =>
+          AppRuntime.runPromise(Session.Service.use((svc) => svc.removePart(ctx.params)).pipe(Effect.provide(Session.defaultLayer))),
+        ),
+      )
+      return true
+    })
+
+    const updatePart = Effect.fn("SessionHttpApi.updatePart")(function* (ctx: {
+      params: { sessionID: SessionID; messageID: MessageID; partID: PartID }
+      payload: typeof MessageV2.Part.Type
+    }) {
+      const payload = MessageV2.Part.zod.parse(ctx.payload)
+      if (
+        payload.id !== ctx.params.partID ||
+        payload.messageID !== ctx.params.messageID ||
+        payload.sessionID !== ctx.params.sessionID
+      ) {
+        throw new Error(
+          `Part mismatch: body.id='${payload.id}' vs partID='${ctx.params.partID}', body.messageID='${payload.messageID}' vs messageID='${ctx.params.messageID}', body.sessionID='${payload.sessionID}' vs sessionID='${ctx.params.sessionID}'`,
+        )
+      }
+      const instance = yield* InstanceState.context
+      return yield* Effect.promise(() =>
+        Instance.restore(instance, () =>
+          AppRuntime.runPromise(Session.Service.use((svc) => svc.updatePart(payload)).pipe(Effect.provide(Session.defaultLayer))),
+        ),
+      )
+    })
+
     return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
       handlers
         .handle("list", list)
@@ -393,11 +533,17 @@ export const sessionHandlers = Layer.unwrap(
         .handle("remove", remove)
         .handle("update", update)
         .handle("fork", fork)
-        .handle("abort", abort),
+        .handle("abort", abort)
+        .handle("share", share)
+        .handle("unshare", unshare)
+        .handle("deleteMessage", deleteMessage)
+        .handle("deletePart", deletePart)
+        .handle("updatePart", updatePart),
     )
   }),
 ).pipe(
   Layer.provide(Session.defaultLayer),
+  Layer.provide(SessionRunState.defaultLayer),
   Layer.provide(SessionStatus.defaultLayer),
   Layer.provide(Todo.defaultLayer),
   Layer.provide(SessionSummary.defaultLayer),

+ 5 - 0
packages/opencode/src/server/routes/instance/index.ts

@@ -107,6 +107,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
     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))
+    app.post(SessionPaths.share, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.share, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.deleteMessage, (c) => handler(c.req.raw, context))
+    app.delete(SessionPaths.deletePart, (c) => handler(c.req.raw, context))
+    app.patch(SessionPaths.updatePart, (c) => handler(c.req.raw, context))
   }
 
   return app

+ 50 - 4
packages/opencode/test/server/httpapi-session.test.ts

@@ -53,14 +53,14 @@ async function createTextMessage(directory: string, sessionID: SessionID, text:
             model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
             time: { created: Date.now() },
           })
-          yield* svc.updatePart({
+          const part = yield* svc.updatePart({
             id: PartID.ascending(),
             sessionID,
             messageID: info.id,
             type: "text",
             text,
           })
-          return info
+          return { info, part }
         }),
       ),
   })
@@ -123,11 +123,11 @@ describe("session HttpApi", () => {
 
     expect(
       await json<MessageV2.WithParts>(
-        await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.id }), {
+        await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), {
           headers,
         }),
       ),
-    ).toMatchObject({ info: { id: message.id } })
+    ).toMatchObject({ info: { id: message.info.id } })
   })
 
   test("serves lifecycle mutation routes through Hono bridge", async () => {
@@ -173,4 +173,50 @@ describe("session HttpApi", () => {
       ),
     ).toBe(true)
   })
+
+  test("serves message mutation routes through Hono bridge", async () => {
+    await using tmp = await tmpdir({ git: true, config: { formatter: false, lsp: false } })
+    const headers = { "x-opencode-directory": tmp.path, "content-type": "application/json" }
+    const session = await createSession(tmp.path, { title: "messages" })
+    const first = await createTextMessage(tmp.path, session.id, "first")
+    const second = await createTextMessage(tmp.path, session.id, "second")
+
+    const updated = await json<MessageV2.Part>(
+      await app().request(
+        pathFor(SessionPaths.updatePart, {
+          sessionID: session.id,
+          messageID: first.info.id,
+          partID: first.part.id,
+        }),
+        {
+          method: "PATCH",
+          headers,
+          body: JSON.stringify({ ...first.part, text: "updated" }),
+        },
+      ),
+    )
+    expect(updated).toMatchObject({ id: first.part.id, type: "text", text: "updated" })
+
+    expect(
+      await json<boolean>(
+        await app().request(
+          pathFor(SessionPaths.deletePart, {
+            sessionID: session.id,
+            messageID: first.info.id,
+            partID: first.part.id,
+          }),
+          { method: "DELETE", headers },
+        ),
+      ),
+    ).toBe(true)
+
+    expect(
+      await json<boolean>(
+        await app().request(pathFor(SessionPaths.deleteMessage, { sessionID: session.id, messageID: second.info.id }), {
+          method: "DELETE",
+          headers,
+        }),
+      ),
+    ).toBe(true)
+  })
 })