diff --git a/packages/opencode/specs/effect/http-api.md b/packages/opencode/specs/effect/http-api.md index 3ce4aa83af..5f16ef197e 100644 --- a/packages/opencode/specs/effect/http-api.md +++ b/packages/opencode/specs/effect/http-api.md @@ -182,7 +182,7 @@ Use raw Effect HTTP routes where `HttpApi` does not fit. The goal is deleting Ho | `workspace` | `bridged` | adaptor/list/status/create/remove/session-restore | | top-level instance routes | `bridged` | path, vcs, command, agent, skill, lsp, formatter, dispose | | experimental JSON routes | `bridged` | console, tool, worktree list/mutations, global session list, resource list | -| `session` | `bridged` partial | read routes; lifecycle, message mutations, streaming remain | +| `session` | `bridged` | read, lifecycle, prompt, message/part mutations, revert, permission reply | | `sync` | `bridged` | start/replay/history | | `event` | `special` | SSE | | `pty` | `special` | websocket | @@ -294,25 +294,25 @@ This checklist tracks bridge parity only. Checked routes are available through t - [x] `POST /session` - create session. - [x] `DELETE /session/:sessionID` - delete session. - [x] `PATCH /session/:sessionID` - update session metadata. -- [ ] `POST /session/:sessionID/init` - run project init command. +- [x] `POST /session/:sessionID/init` - run project init command. - [x] `POST /session/:sessionID/fork` - fork session. - [x] `POST /session/:sessionID/abort` - abort session. - [x] `POST /session/:sessionID/share` - share session. - [x] `GET /session/:sessionID/diff` - session diff. - [x] `DELETE /session/:sessionID/share` - unshare session. -- [ ] `POST /session/:sessionID/summarize` - summarize session. +- [x] `POST /session/:sessionID/summarize` - summarize session. - [x] `GET /session/:sessionID/message` - list session messages. - [x] `GET /session/:sessionID/message/:messageID` - get message. - [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. -- [ ] `POST /session/:sessionID/shell` - run shell command. -- [ ] `POST /session/:sessionID/revert` - revert message. -- [ ] `POST /session/:sessionID/unrevert` - restore reverted messages. -- [ ] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route. +- [x] `POST /session/:sessionID/message` - prompt with streaming response. +- [x] `POST /session/:sessionID/prompt_async` - async prompt. +- [x] `POST /session/:sessionID/command` - run command. +- [x] `POST /session/:sessionID/shell` - run shell command. +- [x] `POST /session/:sessionID/revert` - revert message. +- [x] `POST /session/:sessionID/unrevert` - restore reverted messages. +- [x] `POST /session/:sessionID/permissions/:permissionID` - deprecated permission response route. ### Event Routes @@ -356,7 +356,7 @@ Prefer smaller PRs from here so route behavior and SDK/OpenAPI fallout stays rev 7. [x] Bridge sync start/replay/history routes. 8. [x] Bridge session read routes: list, status, get, children, todo, diff, messages. 9. [x] Bridge session lifecycle mutation routes: create, delete, update, fork, abort. -10. [ ] Bridge session share/summary/message/part mutation routes. +10. [x] Bridge remaining session mutation and prompt routes. 11. [ ] Replace event SSE with non-Hono Effect HTTP. 12. [ ] Replace pty websocket/control routes with non-Hono Effect HTTP. 13. [ ] Replace tui bridge routes or explicitly isolate them behind a non-Hono compatibility layer. diff --git a/packages/opencode/src/server/routes/instance/httpapi/session.ts b/packages/opencode/src/server/routes/instance/httpapi/session.ts index b38740d93f..36645fd7eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/session.ts @@ -1,22 +1,41 @@ import * as InstanceState from "@/effect/instance-state" import { AppRuntime } from "@/effect/app-runtime" +import { Agent } from "@/agent/agent" +import { Bus } from "@/bus" +import { Command } from "@/command" import { Permission } from "@/permission" +import { PermissionID } from "@/permission/schema" import { Instance } from "@/project/instance" +import { ModelID, ProviderID } from "@/provider/schema" import { SessionShare } from "@/share" import { Session } from "@/session" +import { SessionCompaction } from "@/session/compaction" import { MessageV2 } from "@/session/message-v2" import { SessionPrompt } from "@/session/prompt" +import { SessionRevert } from "@/session/revert" import { SessionRunState } from "@/session/run-state" import { SessionStatus } from "@/session/status" import { SessionSummary } from "@/session/summary" import { Todo } from "@/session/todo" import { MessageID, PartID, SessionID } from "@/session/schema" import { Snapshot } from "@/snapshot" +import { Log } from "@/util" +import { NamedError } from "@opencode-ai/core/util/error" import { Effect, Layer, Schema, Struct } from "effect" +import * as Stream from "effect/Stream" import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http" -import { HttpApi, HttpApiBuilder, HttpApiEndpoint, HttpApiGroup, OpenApi } from "effect/unstable/httpapi" +import { + HttpApi, + HttpApiBuilder, + HttpApiEndpoint, + HttpApiError, + HttpApiGroup, + HttpApiSchema, + OpenApi, +} from "effect/unstable/httpapi" import { Authorization } from "./auth" +const log = Log.create({ service: "server" }) const root = "/session" const ListQuery = Schema.Struct({ directory: Schema.optional(Schema.String), @@ -43,6 +62,31 @@ const UpdatePayload = Schema.Struct({ const ForkPayload = Schema.Struct(Struct.omit(Session.ForkInput.fields, ["sessionID"])).annotate({ identifier: "SessionForkInput", }) +const InitPayload = Schema.Struct({ + modelID: ModelID, + providerID: ProviderID, + messageID: MessageID, +}).annotate({ identifier: "SessionInitInput" }) +const SummarizePayload = Schema.Struct({ + providerID: ProviderID, + modelID: ModelID, + auto: Schema.optional(Schema.Boolean), +}).annotate({ identifier: "SessionSummarizeInput" }) +const PromptPayload = Schema.Struct(Struct.omit(SessionPrompt.PromptInput.fields, ["sessionID"])).annotate({ + identifier: "SessionPromptInput", +}) +const CommandPayload = Schema.Struct(Struct.omit(SessionPrompt.CommandInput.fields, ["sessionID"])).annotate({ + identifier: "SessionCommandInput", +}) +const ShellPayload = Schema.Struct(Struct.omit(SessionPrompt.ShellInput.fields, ["sessionID"])).annotate({ + identifier: "SessionShellInput", +}) +const RevertPayload = Schema.Struct(Struct.omit(SessionRevert.RevertInput.fields, ["sessionID"])).annotate({ + identifier: "SessionRevertInput", +}) +const PermissionResponsePayload = Schema.Struct({ + response: Permission.Reply, +}).annotate({ identifier: "SessionPermissionResponseInput" }) export const SessionPaths = { list: root, @@ -59,6 +103,15 @@ export const SessionPaths = { fork: `${root}/:sessionID/fork`, abort: `${root}/:sessionID/abort`, share: `${root}/:sessionID/share`, + init: `${root}/:sessionID/init`, + summarize: `${root}/:sessionID/summarize`, + prompt: `${root}/:sessionID/message`, + promptAsync: `${root}/:sessionID/prompt_async`, + command: `${root}/:sessionID/command`, + shell: `${root}/:sessionID/shell`, + revert: `${root}/:sessionID/revert`, + unrevert: `${root}/:sessionID/unrevert`, + permissions: `${root}/:sessionID/permissions/:permissionID`, deleteMessage: `${root}/:sessionID/message/:messageID`, deletePart: `${root}/:sessionID/message/:messageID/part/:partID`, updatePart: `${root}/:sessionID/message/:messageID/part/:partID`, @@ -201,6 +254,18 @@ export const SessionApi = HttpApi.make("session") description: "Abort an active session and stop any ongoing AI processing or command execution.", }), ), + HttpApiEndpoint.post("init", SessionPaths.init, { + params: { sessionID: SessionID }, + payload: InitPayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.init", + summary: "Initialize session", + description: + "Analyze the current application and create an AGENTS.md file with project-specific agent configurations.", + }), + ), HttpApiEndpoint.post("share", SessionPaths.share, { params: { sessionID: SessionID }, success: Session.Info, @@ -221,6 +286,95 @@ export const SessionApi = HttpApi.make("session") description: "Remove the shareable link for a session, making it private again.", }), ), + HttpApiEndpoint.post("summarize", SessionPaths.summarize, { + params: { sessionID: SessionID }, + payload: SummarizePayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.summarize", + summary: "Summarize session", + description: "Generate a concise summary of the session using AI compaction to preserve key information.", + }), + ), + HttpApiEndpoint.post("prompt", SessionPaths.prompt, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt", + summary: "Send message", + description: "Create and send a new message to a session, streaming the AI response.", + }), + ), + HttpApiEndpoint.post("promptAsync", SessionPaths.promptAsync, { + params: { sessionID: SessionID }, + payload: PromptPayload, + success: HttpApiSchema.NoContent, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.prompt_async", + summary: "Send async message", + description: + "Create and send a new message to a session asynchronously, starting the session if needed and returning immediately.", + }), + ), + HttpApiEndpoint.post("command", SessionPaths.command, { + params: { sessionID: SessionID }, + payload: CommandPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.command", + summary: "Send command", + description: "Send a new command to a session for execution by the AI assistant.", + }), + ), + HttpApiEndpoint.post("shell", SessionPaths.shell, { + params: { sessionID: SessionID }, + payload: ShellPayload, + success: MessageV2.WithParts, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.shell", + summary: "Run shell command", + description: "Execute a shell command within the session context and return the AI's response.", + }), + ), + HttpApiEndpoint.post("revert", SessionPaths.revert, { + params: { sessionID: SessionID }, + payload: RevertPayload, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.revert", + summary: "Revert message", + description: "Revert a specific message in a session, undoing its effects and restoring the previous state.", + }), + ), + HttpApiEndpoint.post("unrevert", SessionPaths.unrevert, { + params: { sessionID: SessionID }, + success: Session.Info, + }).annotateMerge( + OpenApi.annotations({ + identifier: "session.unrevert", + summary: "Restore reverted messages", + description: "Restore all previously reverted messages in a session.", + }), + ), + HttpApiEndpoint.post("permissionRespond", SessionPaths.permissions, { + params: { sessionID: SessionID, permissionID: PermissionID }, + payload: PermissionResponsePayload, + success: Schema.Boolean, + }).annotateMerge( + OpenApi.annotations({ + identifier: "permission.respond", + summary: "Respond to permission", + description: "Approve or deny a permission request from the AI assistant.", + deprecated: true, + }), + ), HttpApiEndpoint.delete("deleteMessage", SessionPaths.deleteMessage, { params: { sessionID: SessionID, messageID: MessageID }, success: Schema.Boolean, @@ -317,6 +471,14 @@ export const sessionHandlers = Layer.unwrap( params: { sessionID: SessionID } query: typeof MessagesQuery.Type }) { + if (ctx.query.before !== undefined && ctx.query.limit === undefined) return yield* new HttpApiError.BadRequest({}) + if (ctx.query.before !== undefined) { + const before = ctx.query.before + yield* Effect.try({ + try: () => MessageV2.cursor.decode(before), + catch: () => new HttpApiError.BadRequest({}), + }) + } if (ctx.query.limit === undefined || ctx.query.limit === 0) { yield* session.get(ctx.params.sessionID) return yield* session.messages({ sessionID: ctx.params.sessionID }) @@ -434,6 +596,29 @@ export const sessionHandlers = Layer.unwrap( return true }) + const init = Effect.fn("SessionHttpApi.init")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof InitPayload.Type + }) { + const instance = yield* InstanceState.context + yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.command({ + sessionID: ctx.params.sessionID, + messageID: ctx.payload.messageID, + model: `${ctx.payload.providerID}/${ctx.payload.modelID}`, + command: Command.Default.INIT, + arguments: "", + }), + ).pipe(Effect.provide(SessionPrompt.defaultLayer)), + ), + ), + ) + return true + }) + const share = Effect.fn("SessionHttpApi.share")(function* (ctx: { params: { sessionID: SessionID } }) { const instance = yield* InstanceState.context return yield* Effect.promise(() => @@ -466,6 +651,174 @@ export const sessionHandlers = Layer.unwrap( ) }) + const summarize = Effect.fn("SessionHttpApi.summarize")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof SummarizePayload.Type + }) { + const instance = yield* InstanceState.context + yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + Effect.gen(function* () { + const session = yield* Session.Service + const revert = yield* SessionRevert.Service + const compact = yield* SessionCompaction.Service + const prompt = yield* SessionPrompt.Service + const agent = yield* Agent.Service + + yield* revert.cleanup(yield* session.get(ctx.params.sessionID)) + const messages = yield* session.messages({ sessionID: ctx.params.sessionID }) + const defaultAgent = yield* agent.defaultAgent() + const currentAgent = messages.findLast((message) => message.info.role === "user")?.info.agent ?? defaultAgent + + yield* compact.create({ + sessionID: ctx.params.sessionID, + agent: currentAgent, + model: { + providerID: ctx.payload.providerID, + modelID: ctx.payload.modelID, + }, + auto: ctx.payload.auto ?? false, + }) + yield* prompt.loop({ sessionID: ctx.params.sessionID }) + }).pipe( + Effect.provide(SessionRevert.defaultLayer), + Effect.provide(SessionCompaction.defaultLayer), + Effect.provide(SessionPrompt.defaultLayer), + Effect.provide(Agent.defaultLayer), + Effect.provide(Session.defaultLayer), + ), + ), + ), + ) + return true + }) + + const prompt = Effect.fn("SessionHttpApi.prompt")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof PromptPayload.Type + }) { + const instance = yield* InstanceState.context + return HttpServerResponse.stream( + Stream.fromEffect( + Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), + ).pipe(Effect.provide(SessionPrompt.defaultLayer)), + ), + ), + ), + ).pipe(Stream.map((message) => JSON.stringify(message)), Stream.encodeText), + { contentType: "application/json" }, + ) + }) + + const promptAsync = Effect.fn("SessionHttpApi.promptAsync")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof PromptPayload.Type + }) { + const instance = yield* InstanceState.context + yield* Effect.sync(() => { + Instance.restore(instance, () => { + void AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.prompt({ ...ctx.payload, sessionID: ctx.params.sessionID } as unknown as SessionPrompt.PromptInput), + ).pipe(Effect.provide(SessionPrompt.defaultLayer)), + ).catch((error) => { + log.error("prompt_async failed", { sessionID: ctx.params.sessionID, error }) + void Bus.publish(Session.Event.Error, { + sessionID: ctx.params.sessionID, + error: new NamedError.Unknown({ + message: error instanceof Error ? error.message : String(error), + }).toObject(), + }) + }) + }) + }) + return HttpApiSchema.NoContent.make() + }) + + const command = Effect.fn("SessionHttpApi.command")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof CommandPayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.command({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.CommandInput), + ).pipe(Effect.provide(SessionPrompt.defaultLayer)), + ), + ), + ) + }) + + const shell = Effect.fn("SessionHttpApi.shell")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof ShellPayload.Type + }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionPrompt.Service.use((svc) => + svc.shell({ ...ctx.payload, sessionID: ctx.params.sessionID } as SessionPrompt.ShellInput), + ).pipe(Effect.provide(SessionPrompt.defaultLayer)), + ), + ), + ) + }) + + const revert = Effect.fn("SessionHttpApi.revert")(function* (ctx: { + params: { sessionID: SessionID } + payload: typeof RevertPayload.Type + }) { + const instance = yield* InstanceState.context + log.info("revert", ctx.payload) + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionRevert.Service.use((svc) => + svc.revert({ sessionID: ctx.params.sessionID, ...ctx.payload }), + ).pipe(Effect.provide(SessionRevert.defaultLayer)), + ), + ), + ) + }) + + const unrevert = Effect.fn("SessionHttpApi.unrevert")(function* (ctx: { params: { sessionID: SessionID } }) { + const instance = yield* InstanceState.context + return yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + SessionRevert.Service.use((svc) => svc.unrevert({ sessionID: ctx.params.sessionID })).pipe( + Effect.provide(SessionRevert.defaultLayer), + ), + ), + ), + ) + }) + + const permissionRespond = Effect.fn("SessionHttpApi.permissionRespond")(function* (ctx: { + params: { permissionID: PermissionID } + payload: typeof PermissionResponsePayload.Type + }) { + const instance = yield* InstanceState.context + yield* Effect.promise(() => + Instance.restore(instance, () => + AppRuntime.runPromise( + Permission.Service.use((svc) => + svc.reply({ requestID: ctx.params.permissionID, reply: ctx.payload.response }), + ).pipe(Effect.provide(Permission.defaultLayer)), + ), + ), + ) + return true + }) + const deleteMessage = Effect.fn("SessionHttpApi.deleteMessage")(function* (ctx: { params: { sessionID: SessionID; messageID: MessageID } }) { @@ -503,7 +856,7 @@ export const sessionHandlers = Layer.unwrap( params: { sessionID: SessionID; messageID: MessageID; partID: PartID } payload: typeof MessageV2.Part.Type }) { - const payload = MessageV2.Part.zod.parse(ctx.payload) + const payload = ctx.payload as MessageV2.Part if ( payload.id !== ctx.params.partID || payload.messageID !== ctx.params.messageID || @@ -538,8 +891,17 @@ export const sessionHandlers = Layer.unwrap( .handle("update", update) .handle("fork", fork) .handle("abort", abort) + .handle("init", init) .handle("share", share) .handle("unshare", unshare) + .handle("summarize", summarize) + .handle("prompt", prompt) + .handle("promptAsync", promptAsync) + .handle("command", command) + .handle("shell", shell) + .handle("revert", revert) + .handle("unrevert", unrevert) + .handle("permissionRespond", permissionRespond) .handle("deleteMessage", deleteMessage) .handle("deletePart", deletePart) .handle("updatePart", updatePart), diff --git a/packages/opencode/src/server/routes/instance/index.ts b/packages/opencode/src/server/routes/instance/index.ts index cbb46df227..4c0503af5a 100644 --- a/packages/opencode/src/server/routes/instance/index.ts +++ b/packages/opencode/src/server/routes/instance/index.ts @@ -105,10 +105,19 @@ export const InstanceRoutes = (upgrade: UpgradeWebSocket): Hono => { app.post(SessionPaths.create, (c) => handler(c.req.raw, context)) app.delete(SessionPaths.remove, (c) => handler(c.req.raw, context)) app.patch(SessionPaths.update, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.init, (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.post(SessionPaths.summarize, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.prompt, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.promptAsync, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.command, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.shell, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.revert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.unrevert, (c) => handler(c.req.raw, context)) + app.post(SessionPaths.permissions, (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)) diff --git a/packages/opencode/test/server/httpapi-session.test.ts b/packages/opencode/test/server/httpapi-session.test.ts index 7a1e8fd026..e6c0919820 100644 --- a/packages/opencode/test/server/httpapi-session.test.ts +++ b/packages/opencode/test/server/httpapi-session.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test" import type { UpgradeWebSocket } from "hono/ws" import { Effect } from "effect" import { Flag } from "@opencode-ai/core/flag/flag" +import { PermissionID } from "../../src/permission/schema" import { ModelID, ProviderID } from "../../src/provider/schema" import { Instance } from "../../src/project/instance" import { InstanceRoutes } from "../../src/server/routes/instance" @@ -118,9 +119,25 @@ describe("session HttpApi", () => { headers, }) const messagePage = await json(messages) - expect(messages.headers.get("x-next-cursor")).toBeTruthy() + const nextCursor = messages.headers.get("x-next-cursor") + expect(nextCursor).toBeTruthy() expect(messagePage[0]?.parts[0]).toMatchObject({ type: "text" }) + expect( + ( + await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?before=${nextCursor}`, { + headers, + }) + ).status, + ).toBe(400) + expect( + ( + await app().request(`${pathFor(SessionPaths.messages, { sessionID: parent.id })}?limit=1&before=invalid`, { + headers, + }) + ).status, + ).toBe(400) + expect( await json( await app().request(pathFor(SessionPaths.message, { sessionID: parent.id, messageID: message.info.id }), { @@ -219,4 +236,45 @@ describe("session HttpApi", () => { ), ).toBe(true) }) + + test("serves remaining non-LLM session 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: "remaining" }) + + expect( + await json( + await app().request(pathFor(SessionPaths.revert, { sessionID: session.id }), { + method: "POST", + headers, + body: JSON.stringify({ messageID: MessageID.ascending() }), + }), + ), + ).toMatchObject({ id: session.id }) + + expect( + await json( + await app().request(pathFor(SessionPaths.unrevert, { sessionID: session.id }), { + method: "POST", + headers, + }), + ), + ).toMatchObject({ id: session.id }) + + expect( + await json( + await app().request( + pathFor(SessionPaths.permissions, { + sessionID: session.id, + permissionID: String(PermissionID.ascending()), + }), + { + method: "POST", + headers, + body: JSON.stringify({ response: "once" }), + }, + ), + ), + ).toBe(true) + }) })