feat(httpapi): bridge session message mutations (#24487)
This commit is contained in:
@@ -297,15 +297,15 @@ This checklist tracks bridge parity only. Checked routes are available through t
|
|||||||
- [ ] `POST /session/:sessionID/init` - run project init command.
|
- [ ] `POST /session/:sessionID/init` - run project init command.
|
||||||
- [x] `POST /session/:sessionID/fork` - fork session.
|
- [x] `POST /session/:sessionID/fork` - fork session.
|
||||||
- [x] `POST /session/:sessionID/abort` - abort 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.
|
- [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.
|
- [ ] `POST /session/:sessionID/summarize` - summarize session.
|
||||||
- [x] `GET /session/:sessionID/message` - list session messages.
|
- [x] `GET /session/:sessionID/message` - list session messages.
|
||||||
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
- [x] `GET /session/:sessionID/message/:messageID` - get message.
|
||||||
- [ ] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
- [x] `DELETE /session/:sessionID/message/:messageID` - delete message.
|
||||||
- [ ] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
- [x] `DELETE /session/:sessionID/message/:messageID/part/:partID` - delete part.
|
||||||
- [ ] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
|
- [x] `PATCH /session/:sessionID/message/:messageID/part/:partID` - update part.
|
||||||
- [ ] `POST /session/:sessionID/message` - prompt with streaming response.
|
- [ ] `POST /session/:sessionID/message` - prompt with streaming response.
|
||||||
- [ ] `POST /session/:sessionID/prompt_async` - async prompt.
|
- [ ] `POST /session/:sessionID/prompt_async` - async prompt.
|
||||||
- [ ] `POST /session/:sessionID/command` - run command.
|
- [ ] `POST /session/:sessionID/command` - run command.
|
||||||
|
|||||||
@@ -6,10 +6,11 @@ import { SessionShare } from "@/share"
|
|||||||
import { Session } from "@/session"
|
import { Session } from "@/session"
|
||||||
import { MessageV2 } from "@/session/message-v2"
|
import { MessageV2 } from "@/session/message-v2"
|
||||||
import { SessionPrompt } from "@/session/prompt"
|
import { SessionPrompt } from "@/session/prompt"
|
||||||
|
import { SessionRunState } from "@/session/run-state"
|
||||||
import { SessionStatus } from "@/session/status"
|
import { SessionStatus } from "@/session/status"
|
||||||
import { SessionSummary } from "@/session/summary"
|
import { SessionSummary } from "@/session/summary"
|
||||||
import { Todo } from "@/session/todo"
|
import { Todo } from "@/session/todo"
|
||||||
import { MessageID, SessionID } from "@/session/schema"
|
import { MessageID, PartID, SessionID } from "@/session/schema"
|
||||||
import { Snapshot } from "@/snapshot"
|
import { Snapshot } from "@/snapshot"
|
||||||
import { Effect, Layer, Schema, Struct } from "effect"
|
import { Effect, Layer, Schema, Struct } from "effect"
|
||||||
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
|
||||||
@@ -57,6 +58,10 @@ export const SessionPaths = {
|
|||||||
update: `${root}/:sessionID`,
|
update: `${root}/:sessionID`,
|
||||||
fork: `${root}/:sessionID/fork`,
|
fork: `${root}/:sessionID/fork`,
|
||||||
abort: `${root}/:sessionID/abort`,
|
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
|
} as const
|
||||||
|
|
||||||
export const SessionApi = HttpApi.make("session")
|
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.",
|
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(
|
.annotateMerge(
|
||||||
OpenApi.annotations({
|
OpenApi.annotations({
|
||||||
@@ -379,6 +434,91 @@ export const sessionHandlers = Layer.unwrap(
|
|||||||
return true
|
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) =>
|
return HttpApiBuilder.group(SessionApi, "session", (handlers) =>
|
||||||
handlers
|
handlers
|
||||||
.handle("list", list)
|
.handle("list", list)
|
||||||
@@ -393,11 +533,17 @@ export const sessionHandlers = Layer.unwrap(
|
|||||||
.handle("remove", remove)
|
.handle("remove", remove)
|
||||||
.handle("update", update)
|
.handle("update", update)
|
||||||
.handle("fork", fork)
|
.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(
|
).pipe(
|
||||||
Layer.provide(Session.defaultLayer),
|
Layer.provide(Session.defaultLayer),
|
||||||
|
Layer.provide(SessionRunState.defaultLayer),
|
||||||
Layer.provide(SessionStatus.defaultLayer),
|
Layer.provide(SessionStatus.defaultLayer),
|
||||||
Layer.provide(Todo.defaultLayer),
|
Layer.provide(Todo.defaultLayer),
|
||||||
Layer.provide(SessionSummary.defaultLayer),
|
Layer.provide(SessionSummary.defaultLayer),
|
||||||
|
|||||||
@@ -107,6 +107,11 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => {
|
|||||||
app.patch(SessionPaths.update, (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.fork, (c) => handler(c.req.raw, context))
|
||||||
app.post(SessionPaths.abort, (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
|
return app
|
||||||
|
|||||||
@@ -53,14 +53,14 @@ async function createTextMessage(directory: string, sessionID: SessionID, text:
|
|||||||
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
model: { providerID: ProviderID.make("test"), modelID: ModelID.make("test") },
|
||||||
time: { created: Date.now() },
|
time: { created: Date.now() },
|
||||||
})
|
})
|
||||||
yield* svc.updatePart({
|
const part = yield* svc.updatePart({
|
||||||
id: PartID.ascending(),
|
id: PartID.ascending(),
|
||||||
sessionID,
|
sessionID,
|
||||||
messageID: info.id,
|
messageID: info.id,
|
||||||
type: "text",
|
type: "text",
|
||||||
text,
|
text,
|
||||||
})
|
})
|
||||||
return info
|
return { info, part }
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
})
|
})
|
||||||
@@ -123,11 +123,11 @@ describe("session HttpApi", () => {
|
|||||||
|
|
||||||
expect(
|
expect(
|
||||||
await json<MessageV2.WithParts>(
|
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,
|
headers,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
).toMatchObject({ info: { id: message.id } })
|
).toMatchObject({ info: { id: message.info.id } })
|
||||||
})
|
})
|
||||||
|
|
||||||
test("serves lifecycle mutation routes through Hono bridge", async () => {
|
test("serves lifecycle mutation routes through Hono bridge", async () => {
|
||||||
@@ -173,4 +173,50 @@ describe("session HttpApi", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true)
|
).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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user