refactor(protocol): simplify interactive resources

This commit is contained in:
Dax Raad
2026-09-15 18:23:35 -04:00
parent b629b458f7
commit 12e53ee6d4
57 changed files with 2639 additions and 3035 deletions
+18 -18
View File
@@ -166,7 +166,7 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [x] 065 | `POST` | `/api/experimental/session/{sessionID}/wait` | `experimental.session.wait` | Experimental-only | Race-free idle barrier retained outside the stable API. |
| [x] 066 | `POST` | `/api/session/{sessionID}/generate` | `session.generate` | Keep | Transient generation from session context retained. |
| [x] 067 | `POST` | `/api/session/{sessionID}/interrupt` | `session.interrupt` | Change | Renamed `continue` to `resume` across public and internal interruption APIs. |
| [x] 068 | `PUT` | `/api/experimental/session/{sessionID}/environment` | `experimental.session.environment` | Experimental-only | Process-local environment replacement retained outside the stable API. |
| [x] 068 | `PUT` | `/api/session/{sessionID}/environment` | `session.environment` | Keep | Process-local environment replacement retained in the stable API. |
| [x] 069 | `POST` | `/api/session/{sessionID}/view` | `session.view` | Change | Idle watermark now uses the standard epoch-millisecond timestamp schema. |
## Group 6: Session history and recovery
@@ -196,23 +196,23 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [x] 083 | `GET` | `/api/session/{sessionID}/inbox` | `session.inbox.list` | Change | Inbox timestamps now use the standard nested `time.created` shape. |
| [ ] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | | |
| [ ] 085 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/steer` | `session.inbox.steer` | | |
| [ ] 086 | `POST` | `/api/session/{sessionID}/inbox/{inboxID}/queue` | `session.inbox.queue` | | |
| [ ] 087 | `GET` | `/api/form/request` | `form.request.list` | | |
| [ ] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | | |
| [ ] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | | |
| [ ] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | | |
| [ ] 091 | `GET` | `/api/session/{sessionID}/form/{formID}/state` | `session.form.state` | | |
| [ ] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | | |
| [ ] 093 | `POST` | `/api/session/{sessionID}/form/{formID}/cancel` | `session.form.cancel` | | |
| [ ] 094 | `GET` | `/api/permission/request` | `permission.request.list` | | |
| [ ] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | | |
| [ ] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | | |
| [ ] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | | |
| [ ] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | | |
| [ ] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | | |
| [ ] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | | |
| [x] 084 | `DELETE` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.cancel` | Change | Cancellation is idempotent and returns `204` when the session exists. |
| [x] 085 | `PATCH` | `/api/session/{sessionID}/inbox/{inboxID}` | `session.inbox.update` | Change | Consolidated delivery mutation with `delivery: "steer" | "queue"`. |
| [x] 086 | — | — | — | Remove | Replaced by `session.inbox.update`. |
| [x] 087 | `GET` | `/api/form` | `form.list` | Change | Removed redundant `request` path and operation namespace. |
| [x] 088 | `GET` | `/api/session/{sessionID}/form` | `session.form.list` | Keep | Pending session form list retained with temporary MCP sentinel compatibility. |
| [x] 089 | `POST` | `/api/session/{sessionID}/form` | `session.form.create` | Keep | External form creation and temporary MCP sentinel ownership retained. |
| [x] 090 | `GET` | `/api/session/{sessionID}/form/{formID}` | `session.form.get` | Change | Form definition and lifecycle state are now returned together. |
| [x] 091 | — | — | — | Remove | State is included by `session.form.get`. |
| [x] 092 | `POST` | `/api/session/{sessionID}/form/{formID}/reply` | `session.form.reply` | Keep | One-shot validated form reply retained. |
| [x] 093 | `DELETE` | `/api/session/{sessionID}/form/{formID}` | `session.form.cancel` | Change | Form cancellation now deletes the pending form resource. |
| [x] 094 | `GET` | `/api/permission/request` | `permission.request.list` | Keep | Pending-request namespace retained alongside saved permissions. |
| [x] 095 | `GET` | `/api/permission/saved` | `permission.saved.list` | Change | Added persisted creation and update timestamps under `time`. |
| [x] 096 | `DELETE` | `/api/permission/saved/{id}` | `permission.saved.remove` | Keep | Idempotent saved-permission deletion retained. |
| [x] 097 | `POST` | `/api/session/{sessionID}/permission` | `session.permission.create` | Keep | Non-blocking permission evaluation and pending-request creation retained. |
| [x] 098 | `GET` | `/api/session/{sessionID}/permission` | `session.permission.list` | Keep | Pending session permission list retained. |
| [x] 099 | `GET` | `/api/session/{sessionID}/permission/{requestID}` | `session.permission.get` | Keep | Specific pending permission read with ownership validation retained. |
| [x] 100 | `POST` | `/api/session/{sessionID}/permission/{requestID}/reply` | `session.permission.reply` | Change | Renamed request field from `reply` to `decision`. |
| [ ] 101 | `PUT` | `/api/session/{sessionID}/permission/rules` | `session.permission.rules` | | |
## Group 8: Filesystem, worktrees, and VCS
@@ -61,7 +61,7 @@ test("session settings use the remote server context", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-pending-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -153,7 +153,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
@@ -180,14 +180,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) =>
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-background-a",
body: { reply: "once" },
body: { decision: "once" },
},
{
origin: serverA,
directory: undefined,
sessionID: childSessionA.id,
permissionID: "permission-background-a-child",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -243,7 +243,7 @@ test("auto-accept sweeps again after a reconnect", async ({ page }) => {
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-offline-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
// The reconnect sweep must resync active sessions instead of trusting
@@ -280,7 +280,7 @@ test("auto-accept approves a request discovered by opening a session", async ({
directory: undefined,
sessionID: sessionA.id,
permissionID: "permission-synced-a",
body: { reply: "once" },
body: { decision: "once" },
},
])
})
@@ -74,7 +74,7 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
item: { type: "user", payload: row.payload, delivery: row.delivery },
})
},
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => {
onInboxChange: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => {
changes.push({ inboxID: input.inboxID, action: input.action })
log.push(`${input.action}:${input.inboxID}`)
const index = rows.findIndex((row) => row.id === input.inboxID)
@@ -85,11 +85,11 @@ function createQueueMock(seed: string[], messages: SessionMessageInfo[] = []) {
emit("session.inbox.cancelled", { sessionID: input.sessionID, inboxID: input.inboxID })
return
}
row.delivery = "steer"
row.delivery = input.action
emit("session.inbox.delivery.changed", {
sessionID: input.sessionID,
inboxID: input.inboxID,
delivery: "steer",
delivery: input.action,
})
},
}
@@ -48,7 +48,10 @@ test("shows a pending question dock", async ({ page }) => {
const rejectRequests: string[] = []
page.on("request", (request) => {
if (request.method() !== "POST") return
if (new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/cancel`)
if (
request.method() === "DELETE" &&
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request`
)
rejectRequests.push(request.url())
})
@@ -107,7 +110,7 @@ test("shows a pending permission dock", async ({ page }) => {
await permission.getByRole("button", { name: "Allow once" }).click()
const request = await reply
expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`)
expect(request.postDataJSON()).toEqual({ reply: "once" })
expect(request.postDataJSON()).toEqual({ decision: "once" })
})
test("restores the draft caret before typing after a request dock closes", async ({ page }) => {
@@ -587,7 +587,7 @@ async function mockServer(page: Page) {
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/model/default")
return json(route, { location: { directory: sessionA.directory }, data: null })
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form/request")
if (url.pathname === "/api/permission/request" || url.pathname === "/api/form")
return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp") return json(route, { location: { directory: sessionA.directory }, data: [] })
if (url.pathname === "/api/mcp/resource")
+4 -3
View File
@@ -112,7 +112,7 @@ const Group = HttpApiGroup.make("mock")
)
.add(HttpApiEndpoint.get("location", "/api/location", { success: Json }))
.add(HttpApiEndpoint.get("permissionRequests", "/api/permission/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form/request", { success: Json }))
.add(HttpApiEndpoint.get("formRequests", "/api/form", { success: Json }))
.add(HttpApiEndpoint.get("vcs", "/api/vcs", { success: Json }))
.add(HttpApiEndpoint.get("vcsStatus", "/api/vcs/status", { success: Json }))
.add(HttpApiEndpoint.get("vcsBranches", "/api/vcs/branches", { success: Json }))
@@ -173,7 +173,7 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionFormCancel", "/api/session/:sessionID/form/:formID/cancel", {
HttpApiEndpoint.delete("sessionFormCancel", "/api/session/:sessionID/form/:formID", {
params: { ...SessionParams, formID: Schema.String },
success: NoContent,
}),
@@ -218,8 +218,9 @@ const Group = HttpApiGroup.make("mock")
}),
)
.add(
HttpApiEndpoint.post("sessionInboxSteer", "/api/session/:sessionID/inbox/:inboxID/steer", {
HttpApiEndpoint.patch("sessionInboxUpdate", "/api/session/:sessionID/inbox/:inboxID", {
params: { ...SessionParams, inboxID: Schema.String },
payload: Schema.Struct({ delivery: Schema.Literals(["steer", "queue"]) }),
success: NoContent,
}),
)
+7 -3
View File
@@ -42,7 +42,7 @@ export interface MockServerConfig {
sessionStatus?: Record<string, unknown> | (() => Record<string, unknown>)
inbox?: unknown[] | (() => unknown[])
onPrompt?: (input: { sessionID: string; body: Record<string, unknown> }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" }) => void
onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void
}
type MockStreamWindow = Window & {
@@ -454,9 +454,13 @@ function mockHandlers(config: MockServerConfig, state: { cursors: Map<string, st
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "cancel" }),
).pipe(Effect.andThen(noContent)),
sessionInboxSteer: (ctx) =>
sessionInboxUpdate: (ctx) =>
Effect.sync(() =>
config.onInboxChange?.({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID, action: "steer" }),
config.onInboxChange?.({
sessionID: ctx.params.sessionID,
inboxID: ctx.params.inboxID,
action: ctx.payload.delivery,
}),
).pipe(Effect.andThen(noContent)),
sessionSwitchAgent: () => noContent,
sessionSwitchModel: () => noContent,
+3 -1
View File
@@ -130,7 +130,9 @@ export function createSessionQueue(input: {
}
const steer = (id: string) => {
if (state.editing?.id === id) cancelEdit()
return server.api.session.inbox.steer({ sessionID: input.sessionID, inboxID: id }).catch(() => notify())
return server.api.session.inbox
.update({ sessionID: input.sessionID, inboxID: id, delivery: "steer" })
.catch(() => notify())
}
const remove = (id: string) => {
if (state.editing?.id === id) cancelEdit()
@@ -115,7 +115,7 @@ export function createPermissionAutoApprover(input: { sdk: ServerSDK; data: Data
if (state.disposed || !enabled() || state.responded.has(permission.id)) return
remember(permission.id)
input.sdk.api.permission
.reply({ sessionID: permission.sessionID, requestID: permission.id, reply: "once" })
.reply({ sessionID: permission.sessionID, requestID: permission.id, decision: "once" })
.catch(() => {
// A reply failure leaves the request pending but invisible (the UI
// hides prompts while auto-approve is on), so retry a bounded number
+1 -1
View File
@@ -112,7 +112,7 @@ export function createSessionRequestModel() {
setStore("responding", perm.id)
serverSDK.api.permission
.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response })
.reply({ sessionID: perm.sessionID, requestID: perm.id, decision: response })
.catch((err: unknown) => {
const description = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description })
@@ -237,7 +237,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
const replyMutation = useMutation(() => ({
mutationFn: (answer: FormAnswer) =>
serverSDK.api.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
serverSDK.api.session.form.reply({ sessionID: props.request.sessionID, formID: props.request.id, answer }),
onMutate: () => {
props.onSubmit()
},
@@ -249,7 +249,7 @@ export const SessionQuestionDock: Component<{ request: FormInfo; onSubmit: () =>
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => serverSDK.api.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
mutationFn: () => serverSDK.api.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { FormAnswer, FormCreated, FormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import type { FormAnswer, FormCreated, SessionFormReplyInput, OpenCodeEvent } from "@opencode/client/promise"
import { replyWebSearch } from "./websearch"
const consent: FormCreated["data"]["form"] = {
@@ -28,7 +28,7 @@ const provider: FormCreated["data"]["form"] = {
function fixture() {
const listeners = new Set<(event: OpenCodeEvent) => void>()
const replies: FormReplyInput[] = []
const replies: SessionFormReplyInput[] = []
const abort = new AbortController()
const emit = (event: OpenCodeEvent) => listeners.forEach((listener) => listener(event))
return {
@@ -45,7 +45,7 @@ function fixture() {
}
},
},
reply: async (input: FormReplyInput) => {
reply: async (input: SessionFormReplyInput) => {
replies.push(input)
},
create: (form = provider) => emit({ id: "evt_create", created: 0, type: "form.created", data: { form } }),
@@ -1,4 +1,4 @@
import type { FormInfo, FormOption, FormReplyInput, FormStringField } from "@opencode/client/promise"
import type { FormInfo, FormOption, SessionFormReplyInput, FormStringField } from "@opencode/client/promise"
import { createEffect, createMemo, createResource, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { OpenCodeEventStream } from "@/runtime/server/client"
@@ -14,7 +14,7 @@ export function createWebSearchRequest(input: {
connected: () => boolean
request: () => FormInfo | undefined
providers: (sessionID: string) => Promise<FormOption[]>
reply: (input: FormReplyInput) => Promise<unknown>
reply: (input: SessionFormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
const [store, setStore] = createStore({
@@ -93,7 +93,7 @@ export async function replyWebSearch(input: {
form: FormInfo
selection: string | false
signal: AbortSignal
reply: (input: FormReplyInput) => Promise<unknown>
reply: (input: SessionFormReplyInput) => Promise<unknown>
events: Pick<OpenCodeEventStream, "listen">
}) {
if (input.signal.aborted) return
+1 -1
View File
@@ -165,7 +165,7 @@ export async function streamTurn(input: {
continue
}
if (event.type === "form.created" && (event.data.form.sessionID === input.sessionID || child)) {
await input.client.form
await input.client.session.form
.cancel({ sessionID: event.data.form.sessionID, formID: event.data.form.id })
.catch(() => input.client.session.interrupt({ sessionID: event.data.form.sessionID }).catch(() => {}))
continue
+1 -1
View File
@@ -55,7 +55,7 @@ export async function replyPermission(input: {
await input.client.permission.reply({
sessionID: input.sessionID,
requestID: input.event.data.id,
reply,
decision: reply,
})
}
+4 -4
View File
@@ -145,7 +145,7 @@ export async function runNonInteractivePrompt(input: Input) {
.reply({
sessionID: input.sessionID,
requestID: request.id,
reply: input.auto ? "once" : "reject",
decision: input.auto ? "once" : "reject",
})
.catch(() => {})
if (!input.auto) {
@@ -155,7 +155,7 @@ export async function runNonInteractivePrompt(input: Input) {
const cancelForm = async (request: Pick<FormRequest, "id" | "sessionID">) => {
try {
await input.client.form.cancel(
await input.client.session.form.cancel(
{ sessionID: request.sessionID, formID: request.id },
...formRequestOptions(request.sessionID === GLOBAL_FORM_SESSION_ID ? input.location : undefined),
)
@@ -695,10 +695,10 @@ export async function runNonInteractivePrompt(input: Input) {
const [permissions, forms, globals] = await Promise.all([
input.client.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.client.session.form.list({ sessionID: input.sessionID }).catch(() => undefined),
input.attached
? Promise.resolve(undefined)
: input.client.form.request
: input.client.form
.list({
location: { directory: input.location.directory },
})
+3 -1
View File
@@ -748,7 +748,9 @@ describe("acp event behavior", () => {
expect(response.stopReason).toBe("end_turn")
expect(
fixture.requests.some((request) => request.path === "/api/session/ses_form/form/frm_question/cancel"),
fixture.requests.some(
(request) => request.method === "DELETE" && request.path === "/api/session/ses_form/form/frm_question",
),
).toBe(true)
} finally {
await fixture.stop()
+11 -11
View File
@@ -240,17 +240,17 @@ async function run(input: {
})()
spyOn(sdk.event, "subscribe").mockImplementation(() => stream)
spyOn(sdk.permission, "list").mockImplementation(() => ok([]) as never)
spyOn(sdk.form, "list").mockImplementation(
spyOn(sdk.session.form, "list").mockImplementation(
(request) => ok(input.pendingForms?.filter((item) => item.sessionID === request.sessionID) ?? []) as never,
)
spyOn(sdk.form.request, "list").mockImplementation(
spyOn(sdk.form, "list").mockImplementation(
() =>
ok({
location: { ...location, project: { id: "proj_1", directory: location.directory } },
data: input.pendingForms?.filter((item) => item.sessionID === "global") ?? [],
}) as never,
)
spyOn(sdk.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
spyOn(sdk.session.form, "cancel").mockImplementation((request) => (input.cancel?.(request) ?? ok(undefined)) as never)
let promptID = "msg_prompt"
spyOn(sdk.session, "wait").mockImplementation(() => input.wait?.() ?? wait.promise)
spyOn(sdk.message, "list").mockImplementation(() =>
@@ -426,10 +426,10 @@ describe("runNonInteractivePrompt", () => {
"x-opencode-directory": "%2Fwork%20tree",
},
}
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.request.list).toHaveBeenCalledWith({
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, globalOptions)
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "global", formID: "frm_pending_global" }, globalOptions)
expect(sdk.form.list).toHaveBeenCalledWith({
location: { directory: "/work tree" },
})
})
@@ -440,10 +440,10 @@ describe("runNonInteractivePrompt", () => {
pendingForms: [form("frm_pending", "ses_1"), form("frm_pending_global", "global")],
turn: (messageID) => [formCreated(form("frm_live", "global")), prompted(messageID), settled()],
})
expect(sdk.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.request.list).not.toHaveBeenCalled()
expect(sdk.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.form.cancel).not.toHaveBeenCalledWith(
expect(sdk.session.form.cancel).toHaveBeenCalledWith({ sessionID: "ses_1", formID: "frm_pending" })
expect(sdk.form.list).not.toHaveBeenCalled()
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith({ sessionID: "global", formID: "frm_live" }, expect.anything())
expect(sdk.session.form.cancel).not.toHaveBeenCalledWith(
{ sessionID: "global", formID: "frm_pending_global" },
expect.anything(),
)
+60 -56
View File
@@ -23,8 +23,8 @@ import type { Event } from "@opencode/schema/event"
import type { EventLog } from "@opencode/schema/event-log"
import type { Shell } from "@opencode/schema/shell"
import type { Provider } from "@opencode/schema/provider"
import type { Integration } from "@opencode/schema/integration"
import type { Form } from "@opencode/schema/form"
import type { Integration } from "@opencode/schema/integration"
import type { Mcp } from "@opencode/schema/mcp"
import type { Credential } from "@opencode/schema/credential"
import type { PermissionSaved } from "@opencode/schema/permission-saved"
@@ -360,17 +360,15 @@ export type SessionInboxCancelOperation<E = never> = (
input: SessionInboxCancelInput,
) => Effect.Effect<SessionInboxCancelOutput, E>
export type SessionInboxSteerInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxSteerOutput = void
export type SessionInboxSteerOperation<E = never> = (
input: SessionInboxSteerInput,
) => Effect.Effect<SessionInboxSteerOutput, E>
export type SessionInboxQueueInput = { readonly sessionID: Session.ID; readonly inboxID: SessionMessage.ID }
export type SessionInboxQueueOutput = void
export type SessionInboxQueueOperation<E = never> = (
input: SessionInboxQueueInput,
) => Effect.Effect<SessionInboxQueueOutput, E>
export type SessionInboxUpdateInput = {
readonly sessionID: Session.ID
readonly inboxID: SessionMessage.ID
readonly delivery: SessionInbox.Delivery
}
export type SessionInboxUpdateOutput = void
export type SessionInboxUpdateOperation<E = never> = (
input: SessionInboxUpdateInput,
) => Effect.Effect<SessionInboxUpdateOutput, E>
export type SessionInstructionsEntryListInput = { readonly sessionID: Session.ID }
export type SessionInstructionsEntryListOutput = ReadonlyArray<InstructionEntry.Info>
@@ -1325,6 +1323,44 @@ export type SessionMessageGetOperation<E = never> = (
input: SessionMessageGetInput,
) => Effect.Effect<SessionMessageGetOutput, E>
export type SessionFormListInput = { readonly sessionID: string }
export type SessionFormListOutput = ReadonlyArray<Form.Info>
export type SessionFormListOperation<E = never> = (
input: SessionFormListInput,
) => Effect.Effect<SessionFormListOutput, E>
export type SessionFormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type SessionFormCreateOutput = Form.Info
export type SessionFormCreateOperation<E = never> = (
input: SessionFormCreateInput,
) => Effect.Effect<SessionFormCreateOutput, E>
export type SessionFormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormGetOutput = Form.Detail
export type SessionFormGetOperation<E = never> = (input: SessionFormGetInput) => Effect.Effect<SessionFormGetOutput, E>
export type SessionFormReplyInput = {
readonly sessionID: string
readonly formID: Form.ID
readonly answer: Form.Answer
}
export type SessionFormReplyOutput = void
export type SessionFormReplyOperation<E = never> = (
input: SessionFormReplyInput,
) => Effect.Effect<SessionFormReplyOutput, E>
export type SessionFormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type SessionFormCancelOutput = void
export type SessionFormCancelOperation<E = never> = (
input: SessionFormCancelInput,
) => Effect.Effect<SessionFormCancelOutput, E>
export type SessionEnvironmentInput = {
readonly sessionID: Session.ID
readonly variables: { readonly [x: string]: string }
@@ -1369,8 +1405,7 @@ export interface SessionApi<E = never> {
readonly inbox: {
readonly list: SessionInboxListOperation<E>
readonly cancel: SessionInboxCancelOperation<E>
readonly steer: SessionInboxSteerOperation<E>
readonly queue: SessionInboxQueueOperation<E>
readonly update: SessionInboxUpdateOperation<E>
}
readonly instructions: {
readonly entry: {
@@ -1384,6 +1419,13 @@ export interface SessionApi<E = never> {
readonly interrupt: SessionInterruptOperation<E>
readonly background: SessionBackgroundOperation<E>
readonly message: { readonly get: SessionMessageGetOperation<E> }
readonly form: {
readonly list: SessionFormListOperation<E>
readonly create: SessionFormCreateOperation<E>
readonly get: SessionFormGetOperation<E>
readonly reply: SessionFormReplyOperation<E>
readonly cancel: SessionFormCancelOperation<E>
}
readonly environment: SessionEnvironmentOperation<E>
readonly view: SessionViewOperation<E>
}
@@ -1684,50 +1726,12 @@ export interface ProjectApi<E = never> {
readonly update: ProjectUpdateOperation<E>
}
export type FormRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormRequestListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormRequestListOperation<E = never> = (
input?: FormRequestListInput,
) => Effect.Effect<FormRequestListOutput, E>
export type FormListInput = { readonly sessionID: string }
export type FormListOutput = ReadonlyArray<Form.Info>
export type FormListOperation<E = never> = (input: FormListInput) => Effect.Effect<FormListOutput, E>
export type FormCreateInput = {
readonly sessionID: string
readonly id?: Form.ID | undefined
readonly title: string
readonly metadata?: Form.Metadata | undefined
readonly fields: Form.Fields
}
export type FormCreateOutput = Form.Info
export type FormCreateOperation<E = never> = (input: FormCreateInput) => Effect.Effect<FormCreateOutput, E>
export type FormGetInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormGetOutput = Form.Info
export type FormGetOperation<E = never> = (input: FormGetInput) => Effect.Effect<FormGetOutput, E>
export type FormStateInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormStateOutput = Form.State
export type FormStateOperation<E = never> = (input: FormStateInput) => Effect.Effect<FormStateOutput, E>
export type FormReplyInput = { readonly sessionID: string; readonly formID: Form.ID; readonly answer: Form.Answer }
export type FormReplyOutput = void
export type FormReplyOperation<E = never> = (input: FormReplyInput) => Effect.Effect<FormReplyOutput, E>
export type FormCancelInput = { readonly sessionID: string; readonly formID: Form.ID }
export type FormCancelOutput = void
export type FormCancelOperation<E = never> = (input: FormCancelInput) => Effect.Effect<FormCancelOutput, E>
export type FormListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
export type FormListOutput = { readonly location: Location.PublicRef; readonly data: ReadonlyArray<Form.Info> }
export type FormListOperation<E = never> = (input?: FormListInput) => Effect.Effect<FormListOutput, E>
export interface FormApi<E = never> {
readonly request: { readonly list: FormRequestListOperation<E> }
readonly list: FormListOperation<E>
readonly create: FormCreateOperation<E>
readonly get: FormGetOperation<E>
readonly state: FormStateOperation<E>
readonly reply: FormReplyOperation<E>
readonly cancel: FormCancelOperation<E>
}
export type PermissionRequestListInput = { readonly location?: { readonly directory?: string | undefined } | undefined }
@@ -1777,7 +1781,7 @@ export type PermissionGetOperation<E = never> = (input: PermissionGetInput) => E
export type PermissionReplyInput = {
readonly sessionID: Session.ID
readonly requestID: Permission.ID
readonly reply: Permission.Reply
readonly decision: Permission.Reply
readonly message?: string | undefined
}
export type PermissionReplyOutput = void
+72 -92
View File
@@ -71,10 +71,8 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -91,6 +89,16 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -150,20 +158,8 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -605,18 +601,12 @@ const EndpointSessionInboxCancel = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionInboxSteer = (raw: RawClient["server.session"]) => (input: SessionInboxSteerInput) =>
preserveEffect<SessionInboxSteerOutput>()(
raw["session.inbox.steer"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionInboxQueue = (raw: RawClient["server.session"]) => (input: SessionInboxQueueInput) =>
preserveEffect<SessionInboxQueueOutput>()(
raw["session.inbox.queue"]({ params: { sessionID: input["sessionID"], inboxID: input["inboxID"] } }).pipe(
Effect.mapError(mapClientError),
),
const EndpointSessionInboxUpdate = (raw: RawClient["server.session"]) => (input: SessionInboxUpdateInput) =>
preserveEffect<SessionInboxUpdateOutput>()(
raw["session.inbox.update"]({
params: { sessionID: input["sessionID"], inboxID: input["inboxID"] },
payload: { delivery: input["delivery"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionInstructionsEntryList =
@@ -686,6 +676,48 @@ const EndpointSessionMessageGet = (raw: RawClient["server.session"]) => (input:
),
)
const EndpointSessionFormList = (raw: RawClient["server.session"]) => (input: SessionFormListInput) =>
preserveEffect<SessionFormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormCreate = (raw: RawClient["server.session"]) => (input: SessionFormCreateInput) =>
preserveEffect<SessionFormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormGet = (raw: RawClient["server.session"]) => (input: SessionFormGetInput) =>
preserveEffect<SessionFormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointSessionFormReply = (raw: RawClient["server.session"]) => (input: SessionFormReplyInput) =>
preserveEffect<SessionFormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointSessionFormCancel = (raw: RawClient["server.session"]) => (input: SessionFormCancelInput) =>
preserveEffect<SessionFormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointSessionEnvironment = (raw: RawClient["server.session"]) => (input: SessionEnvironmentInput) =>
preserveEffect<SessionEnvironmentOutput>()(
raw["session.environment"]({
@@ -732,8 +764,7 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
inbox: {
list: EndpointSessionInboxList(raw),
cancel: EndpointSessionInboxCancel(raw),
steer: EndpointSessionInboxSteer(raw),
queue: EndpointSessionInboxQueue(raw),
update: EndpointSessionInboxUpdate(raw),
},
instructions: {
entry: {
@@ -747,6 +778,13 @@ const adaptGroupSession = (raw: RawClient["server.session"]) => ({
interrupt: EndpointSessionInterrupt(raw),
background: EndpointSessionBackground(raw),
message: { get: EndpointSessionMessageGet(raw) },
form: {
list: EndpointSessionFormList(raw),
create: EndpointSessionFormCreate(raw),
get: EndpointSessionFormGet(raw),
reply: EndpointSessionFormReply(raw),
cancel: EndpointSessionFormCancel(raw),
},
environment: EndpointSessionEnvironment(raw),
view: EndpointSessionView(raw),
})
@@ -1006,70 +1044,12 @@ const adaptGroupProject = (raw: RawClient["server.project"]) => ({
update: EndpointProjectUpdate(raw),
})
const EndpointFormRequestList = (raw: RawClient["server.form"]) => (input?: FormRequestListInput) =>
preserveEffect<FormRequestListOutput>()(
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormList = (raw: RawClient["server.form"]) => (input: FormListInput) =>
const EndpointFormList = (raw: RawClient["server.form"]) => (input?: FormListInput) =>
preserveEffect<FormListOutput>()(
raw["session.form.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
raw["form.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCreate = (raw: RawClient["server.form"]) => (input: FormCreateInput) =>
preserveEffect<FormCreateOutput>()(
raw["session.form.create"]({
params: { sessionID: input["sessionID"] },
payload: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
}).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormGet = (raw: RawClient["server.form"]) => (input: FormGetInput) =>
preserveEffect<FormGetOutput>()(
raw["session.form.get"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormState = (raw: RawClient["server.form"]) => (input: FormStateInput) =>
preserveEffect<FormStateOutput>()(
raw["session.form.state"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
)
const EndpointFormReply = (raw: RawClient["server.form"]) => (input: FormReplyInput) =>
preserveEffect<FormReplyOutput>()(
raw["session.form.reply"]({
params: { sessionID: input["sessionID"], formID: input["formID"] },
payload: { answer: input["answer"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointFormCancel = (raw: RawClient["server.form"]) => (input: FormCancelInput) =>
preserveEffect<FormCancelOutput>()(
raw["session.form.cancel"]({ params: { sessionID: input["sessionID"], formID: input["formID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const adaptGroupForm = (raw: RawClient["server.form"]) => ({
request: { list: EndpointFormRequestList(raw) },
list: EndpointFormList(raw),
create: EndpointFormCreate(raw),
get: EndpointFormGet(raw),
state: EndpointFormState(raw),
reply: EndpointFormReply(raw),
cancel: EndpointFormCancel(raw),
})
const adaptGroupForm = (raw: RawClient["server.form"]) => ({ list: EndpointFormList(raw) })
const EndpointPermissionRequestList = (raw: RawClient["server.permission"]) => (input?: PermissionRequestListInput) =>
preserveEffect<PermissionRequestListOutput>()(
@@ -1128,7 +1108,7 @@ const EndpointPermissionReply = (raw: RawClient["server.permission"]) => (input:
preserveEffect<PermissionReplyOutput>()(
raw["session.permission.reply"]({
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
payload: { reply: input["reply"], message: input["message"] },
payload: { decision: input["decision"], message: input["message"] },
}).pipe(Effect.mapError(mapClientError)),
)
+84 -109
View File
@@ -65,10 +65,8 @@ import type {
SessionInboxListOutput,
SessionInboxCancelInput,
SessionInboxCancelOutput,
SessionInboxSteerInput,
SessionInboxSteerOutput,
SessionInboxQueueInput,
SessionInboxQueueOutput,
SessionInboxUpdateInput,
SessionInboxUpdateOutput,
SessionInstructionsEntryListInput,
SessionInstructionsEntryListOutput,
SessionInstructionsEntryPutInput,
@@ -85,6 +83,16 @@ import type {
SessionBackgroundOutput,
SessionMessageGetInput,
SessionMessageGetOutput,
SessionFormListInput,
SessionFormListOutput,
SessionFormCreateInput,
SessionFormCreateOutput,
SessionFormGetInput,
SessionFormGetOutput,
SessionFormReplyInput,
SessionFormReplyOutput,
SessionFormCancelInput,
SessionFormCancelOutput,
SessionEnvironmentInput,
SessionEnvironmentOutput,
SessionViewInput,
@@ -144,20 +152,8 @@ import type {
ProjectListOutput,
ProjectUpdateInput,
ProjectUpdateOutput,
FormRequestListInput,
FormRequestListOutput,
FormListInput,
FormListOutput,
FormCreateInput,
FormCreateOutput,
FormGetInput,
FormGetOutput,
FormStateInput,
FormStateOutput,
FormReplyInput,
FormReplyOutput,
FormCancelInput,
FormCancelOutput,
PermissionRequestListInput,
PermissionRequestListOutput,
PermissionSavedListInput,
@@ -851,27 +847,17 @@ export function make(options: ClientOptions) {
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
),
steer: (input: SessionInboxSteerInput, requestOptions?: RequestOptions) =>
request<SessionInboxSteerOutput>(
update: (input: SessionInboxUpdateInput, requestOptions?: RequestOptions) =>
request<SessionInboxUpdateOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/steer`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
queue: (input: SessionInboxQueueInput, requestOptions?: RequestOptions) =>
request<SessionInboxQueueOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}/queue`,
method: "PATCH",
path: `/api/session/${encodeURIComponent(input.sessionID)}/inbox/${encodeURIComponent(input.inboxID)}`,
body: { delivery: input["delivery"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
@@ -977,11 +963,70 @@ export function make(options: ClientOptions) {
requestOptions,
).then((value) => value.data),
},
form: {
list: (input: SessionFormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormListOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: SessionFormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: SessionFormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: SessionFormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: SessionFormReplyInput, requestOptions?: RequestOptions) =>
request<SessionFormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: SessionFormCancelInput, requestOptions?: RequestOptions) =>
request<SessionFormCancelOutput>(
{
method: "DELETE",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
environment: (input: SessionEnvironmentInput, requestOptions?: RequestOptions) =>
request<SessionEnvironmentOutput>(
{
method: "PUT",
path: `/api/experimental/session/${encodeURIComponent(input.sessionID)}/environment`,
path: `/api/session/${encodeURIComponent(input.sessionID)}/environment`,
body: { variables: input["variables"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1367,87 +1412,17 @@ export function make(options: ClientOptions) {
),
},
form: {
request: {
list: (input?: FormRequestListInput, requestOptions?: RequestOptions) =>
request<FormRequestListOutput>(
{
method: "GET",
path: `/api/form/request`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
),
},
list: (input: FormListInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormListOutput }>(
list: (input?: FormListInput, requestOptions?: RequestOptions) =>
request<FormListOutput>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
path: `/api/form`,
query: { location: input?.["location"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
declaredStatuses: [400, 401],
empty: false,
},
requestOptions,
).then((value) => value.data),
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormCreateOutput }>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
body: { id: input["id"], title: input["title"], metadata: input["metadata"], fields: input["fields"] },
successStatus: 200,
declaredStatuses: [400, 401, 404, 409],
empty: false,
},
requestOptions,
).then((value) => value.data),
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormGetOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
request<{ readonly data: FormStateOutput }>(
{
method: "GET",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
).then((value) => value.data),
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
request<FormReplyOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
body: { answer: input["answer"] },
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
request<FormCancelOutput>(
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
successStatus: 204,
declaredStatuses: [400, 401, 404, 409],
empty: true,
},
requestOptions,
),
},
permission: {
@@ -1537,7 +1512,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/permission/${encodeURIComponent(input.requestID)}/reply`,
body: { reply: input["reply"], message: input["message"] },
body: { decision: input["decision"], message: input["message"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
empty: true,
File diff suppressed because it is too large Load Diff
+9 -9
View File
@@ -7,9 +7,9 @@ import type {
AgentInfo,
CommandInfo,
ConfigEntry,
FormCancelInput,
SessionFormCancelInput,
FormInfo,
FormReplyInput,
SessionFormReplyInput,
IntegrationInfo,
LocationRef,
LocationGetOutput,
@@ -296,7 +296,7 @@ export function createData(config: CreateDataInput) {
return true
}
function settleForm(input: FormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
function settleForm(input: SessionFormCancelInput, ref: LocationRef | undefined, request: Promise<void>) {
return request
.catch((error: unknown) => {
if ((!isFormNotFoundError(error) && !isFormAlreadySettledError(error)) || error.id !== input.formID) throw error
@@ -1742,7 +1742,7 @@ export function createData(config: CreateDataInput) {
const key = `session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`
return sync.run(key, async () => {
if (sessionID === "global") {
const response = await api().form.request.list({
const response = await api().form.list({
location: locationQuery(ref ?? defaultLocation()),
})
const location = {
@@ -1757,7 +1757,7 @@ export function createData(config: CreateDataInput) {
])
return
}
setStore("session", "form", sessionID, await api().form.list({ sessionID }))
setStore("session", "form", sessionID, await api().session.form.list({ sessionID }))
})
},
invalidate(sessionID: string, ref?: LocationRef) {
@@ -1765,11 +1765,11 @@ export function createData(config: CreateDataInput) {
`session.form:${sessionID}:${sessionID === "global" ? locationKey(ref ?? defaultLocation()) : ""}`,
)
},
reply(input: FormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.reply(input, formRequestOptions(input.sessionID, ref)))
reply(input: SessionFormReplyInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.reply(input, formRequestOptions(input.sessionID, ref)))
},
cancel(input: FormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().form.cancel(input, formRequestOptions(input.sessionID, ref)))
cancel(input: SessionFormCancelInput, ref?: LocationRef) {
return settleForm(input, ref, api().session.form.cancel(input, formRequestOptions(input.sessionID, ref)))
},
},
},
+4 -4
View File
@@ -546,13 +546,13 @@ test("session.inbox mutations use the public HTTP contract", async () => {
})
await client.session.inbox.cancel({ sessionID: "ses_test", inboxID: "msg_cancel" })
await client.session.inbox.steer({ sessionID: "ses_test", inboxID: "msg_steer" })
await client.session.inbox.queue({ sessionID: "ses_test", inboxID: "msg_queue" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_steer", delivery: "steer" })
await client.session.inbox.update({ sessionID: "ses_test", inboxID: "msg_queue", delivery: "queue" })
expect(requests).toEqual([
{ method: "DELETE", url: "http://localhost:3000/api/session/ses_test/inbox/msg_cancel" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer/steer" },
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue/queue" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_steer" },
{ method: "PATCH", url: "http://localhost:3000/api/session/ses_test/inbox/msg_queue" },
])
})
+5 -1
View File
@@ -1,7 +1,7 @@
export * as PermissionSaved from "./saved.js"
import { eq } from "drizzle-orm"
import { Context, Effect, Layer, Schema } from "effect"
import { Context, DateTime, Effect, Layer, Schema } from "effect"
import { Project } from "@opencode/schema/project"
import { Database } from "../database/database.js"
import { makeGlobalNode } from "@opencode/util/effect/app-node"
@@ -52,6 +52,10 @@ const layer = Layer.effect(
projectID: row.project_id,
action: row.action,
resource: row.resource,
time: {
created: DateTime.makeUnsafe(row.time_created),
updated: DateTime.makeUnsafe(row.time_updated),
},
}),
)
})
+1 -1
View File
@@ -411,7 +411,7 @@ export const make = Effect.fn("PluginHost.make")(function* (
.pipe(
Effect.flatMap((request) =>
request?.sessionID === input.sessionID
? permission.reply({ requestID: input.requestID, reply: input.reply, message: input.message })
? permission.reply({ requestID: input.requestID, reply: input.decision, message: input.message })
: Effect.fail(new Error(`Permission request not found: ${input.requestID}`)),
),
),
+3 -1
View File
@@ -329,7 +329,9 @@ describe("Permission", () => {
).toMatchObject([{ action: "read", resource: "src/*" }])
const saved = yield* PermissionSaved.Service
const id = (yield* saved.list())[0]!.id
expect(yield* saved.list()).toEqual([{ id, projectID: Project.ID.global, action: "read", resource: "src/*" }])
expect(yield* saved.list()).toMatchObject([
{ id, projectID: Project.ID.global, action: "read", resource: "src/*" },
])
yield* service.assert(assertion({ id: Permission.ID.create("per_next"), resources: ["src/next.ts"] }))
yield* saved.remove(id)
expect(yield* saved.list()).toEqual([])
+4 -4
View File
@@ -1,9 +1,9 @@
import type {
AgentInfo,
CommandInfo,
FormCancelInput,
SessionFormCancelInput,
FormInfo,
FormReplyInput,
SessionFormReplyInput,
IntegrationInfo,
LocationRef,
McpResource,
@@ -95,8 +95,8 @@ export interface Data {
list(sessionID: string, location?: LocationRef): Array<FormInfo & { readonly location?: LocationRef }> | undefined
sync(sessionID: string, location?: LocationRef): Promise<void>
invalidate(sessionID: string, location?: LocationRef): void
reply(input: FormReplyInput, location?: LocationRef): Promise<void>
cancel(input: FormCancelInput, location?: LocationRef): Promise<void>
reply(input: SessionFormReplyInput, location?: LocationRef): Promise<void>
cancel(input: SessionFormCancelInput, location?: LocationRef): Promise<void>
}
}
readonly project: {
File diff suppressed because it is too large Load Diff
+16 -12
View File
@@ -54,16 +54,20 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof VcsGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ConfigGroup, LocationId>
type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLocationService> =
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
| typeof MessageGroup
type FormGroups<
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
type SessionGroups<
SessionLocationId extends HttpApiMiddleware.AnyId,
SessionLocationService,
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
> = ReturnType<typeof makeFormGroup<LocationId, LocationService, FormLocationId, FormLocationService>>
> =
| ReturnType<
typeof makeSessionGroup<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
>
| typeof MessageGroup
type FormGroups<LocationId extends HttpApiMiddleware.AnyId, LocationService> = ReturnType<
typeof makeFormGroup<LocationId, LocationService>
>
type MixedMiddlewareGroups<
LocationId extends HttpApiMiddleware.AnyId,
@@ -89,8 +93,8 @@ type ApiGroups<
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
| FormGroups<LocationId, LocationService>
| SessionGroups<SessionLocationId, SessionLocationService, FormLocationId, FormLocationService>
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
| Event
@@ -151,7 +155,7 @@ const makeApiFromGroup = <
.add(LocationGroup.middleware(locationMiddleware))
.add(AgentGroup.middleware(locationMiddleware))
.add(PluginGroup.middleware(locationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware))
.add(makeSessionGroup(sessionLocationMiddleware, formLocationMiddleware))
.add(MessageGroup)
.add(ModelGroup.middleware(locationMiddleware))
.add(GenerateGroup)
@@ -160,7 +164,7 @@ const makeApiFromGroup = <
.add(McpGroup.middleware(locationMiddleware))
.add(CredentialGroup)
.add(ProjectGroup.middleware(locationMiddleware))
.add(makeFormGroup(locationMiddleware, formLocationMiddleware))
.add(makeFormGroup(locationMiddleware))
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
.add(FileSystemGroup.middleware(locationMiddleware))
.add(CommandGroup.middleware(locationMiddleware))
+6 -123
View File
@@ -1,143 +1,26 @@
import { Form } from "@opencode/schema/form"
import { Location } from "@opencode/schema/location"
import { Context, Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import {
ConflictError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
SessionNotFoundError,
} from "../errors.js"
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
const CreatePayload = Schema.Struct({
id: Form.ID.pipe(Schema.optional),
title: Form.Info.fields.title,
metadata: Form.Info.fields.metadata,
fields: Form.Info.fields.fields,
}).annotate({ identifier: "Form.CreatePayload" })
export type CreatePayload = typeof CreatePayload.Type
// Form routes intentionally look session-scoped, but use a form-specific middleware instead of
// SessionLocationMiddleware. The middleware treats real session IDs normally and has an
// undocumented `global` sentinel branch for MCP elicitation forms that are still Location-scoped
// but not session-owned. This is temporary and should disappear once elicitations are attributable.
export const makeFormGroup = <
LocationId extends HttpApiMiddleware.AnyId,
LocationService,
FormLocationId extends HttpApiMiddleware.AnyId,
FormLocationService,
>(
export const makeFormGroup = <LocationId extends HttpApiMiddleware.AnyId, LocationService>(
locationMiddleware: Context.Key<LocationId, LocationService>,
formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
) =>
HttpApiGroup.make("server.form")
.add(
HttpApiEndpoint.get("form.request.list", "/api/form/request", {
HttpApiEndpoint.get("form.list", "/api/form", {
query: LocationQuery,
success: Location.response(Schema.Array(Form.Info)),
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "form.request.list",
summary: "List pending form requests",
identifier: "form.list",
summary: "List pending forms",
description: "Retrieve pending forms for a location.",
}),
),
)
.middleware(locationMiddleware)
.add(
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
payload: CreatePayload,
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, ConflictError, InvalidRequestError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.create",
summary: "Create session form",
description: "Create a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.get",
summary: "Get session form",
description: "Retrieve a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.State }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.state",
summary: "Get form state",
description: "Retrieve the current state for a form.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
params: { sessionID: Schema.String, formID: Form.ID },
payload: Form.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.reply",
summary: "Reply to form",
description: "Submit an answer to a pending form.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", {
params: { sessionID: Schema.String, formID: Form.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.cancel",
summary: "Cancel form",
description: "Cancel a pending form.",
}),
),
)
.annotateMerge(OpenApi.annotations({ title: "form", description: "Session form routes." }))
.annotateMerge(OpenApi.annotations({ title: "form", description: "Location form routes." }))
+1 -1
View File
@@ -117,7 +117,7 @@ export const makePermissionGroup = <
HttpApiEndpoint.post("session.permission.reply", "/api/session/:sessionID/permission/:requestID/reply", {
params: { sessionID: Session.ID, requestID: Permission.ID },
payload: Schema.Struct({
reply: Permission.Reply,
decision: Permission.Reply,
message: Schema.String.pipe(Schema.optional),
}),
success: HttpApiSchema.NoContent,
+103 -22
View File
@@ -20,6 +20,9 @@ import {
ConflictError,
CommandExecutionError,
CommandNotFoundError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidCursorError,
InvalidRequestError,
MessageNotFoundError,
@@ -37,6 +40,7 @@ import { Location } from "@opencode/schema/location"
import { SessionEvent } from "@opencode/schema/session-event"
import { EventLog } from "@opencode/schema/event-log"
import { FileDiff } from "@opencode/schema/file-diff"
import { Form } from "@opencode/schema/form"
import { PublicSessionMessage } from "./message.js"
const ParentIDFilter = Schema.Union([
@@ -140,6 +144,13 @@ const PublicInboxInfo = Schema.Union([
PublicMove,
]).annotate({ identifier: "Session.Inbox.Info" })
const FormCreatePayload = Schema.Struct({
id: Form.ID.pipe(Schema.optional),
title: Form.Info.fields.title,
metadata: Form.Info.fields.metadata,
fields: Form.Info.fields.fields,
}).annotate({ identifier: "Form.CreatePayload" })
const BooleanFromString = Schema.Literals(["true", "false"]).pipe(
Schema.decodeTo(Schema.Boolean, {
decode: SchemaGetter.transform((value) => value === "true"),
@@ -159,7 +170,12 @@ export const SessionsQuery = Schema.Struct({
cursor: SessionsQueryCursor.pipe(Schema.optional),
}).annotate({ identifier: "SessionsQuery" })
export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLocationMiddleware: Context.Key<I, S>) =>
export const makeSessionGroup = <
I extends HttpApiMiddleware.AnyId,
S,
FormI extends HttpApiMiddleware.AnyId,
FormS,
>(sessionLocationMiddleware: Context.Key<I, S>, formLocationMiddleware: Context.Key<FormI, FormS>) =>
HttpApiGroup.make("server.session")
.add(
HttpApiEndpoint.get("session.list", "/api/session", {
@@ -599,38 +615,26 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
HttpApiEndpoint.delete("session.inbox.cancel", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.cancel",
summary: "Cancel inbox input",
description: "Cancel an inbox item that has not yet been delivered.",
description: "Cancel an inbox item that has not yet been delivered. Unavailable items are a no-op.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.steer", "/api/session/:sessionID/inbox/:inboxID/steer", {
HttpApiEndpoint.patch("session.inbox.update", "/api/session/:sessionID/inbox/:inboxID", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
payload: Schema.Struct({ delivery: SessionInbox.Delivery }),
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.steer",
summary: "Steer queued item",
description: "Change a queued inbox item to steer delivery and wake session execution.",
}),
),
)
.add(
HttpApiEndpoint.post("session.inbox.queue", "/api/session/:sessionID/inbox/:inboxID/queue", {
params: { sessionID: Session.ID, inboxID: SessionMessage.ID },
success: HttpApiSchema.NoContent,
error: [ConflictError, SessionNotFoundError],
}).annotateMerge(
OpenApi.annotations({
identifier: "session.inbox.queue",
summary: "Queue steered item",
description: "Change a steered inbox item to queued delivery.",
identifier: "session.inbox.update",
summary: "Update inbox item",
description: "Change a pending inbox item's delivery mode. Steering wakes session execution.",
}),
),
)
@@ -783,14 +787,91 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.put("session.environment", "/api/experimental/session/:sessionID/environment", {
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
success: Schema.Struct({ data: Schema.Array(Form.Info) }),
error: SessionNotFoundError,
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.list",
summary: "List session forms",
description: "Retrieve pending forms for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
params: { sessionID: Schema.String },
payload: FormCreatePayload,
success: Schema.Struct({ data: Form.Info }),
error: [SessionNotFoundError, ConflictError, InvalidRequestError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.create",
summary: "Create session form",
description: "Create a form for a session.",
}),
),
)
.add(
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: Schema.Struct({ data: Form.Detail }),
error: [SessionNotFoundError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.get",
summary: "Get session form",
description: "Retrieve a form and its current state for a session.",
}),
),
)
.add(
HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
params: { sessionID: Schema.String, formID: Form.ID },
payload: Form.Reply,
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.reply",
summary: "Reply to form",
description: "Submit an answer to a pending form.",
}),
),
)
.add(
HttpApiEndpoint.delete("session.form.cancel", "/api/session/:sessionID/form/:formID", {
params: { sessionID: Schema.String, formID: Form.ID },
success: HttpApiSchema.NoContent,
error: [SessionNotFoundError, FormAlreadySettledError, FormNotFoundError],
})
.middleware(formLocationMiddleware)
.annotateMerge(
OpenApi.annotations({
identifier: "session.form.cancel",
summary: "Cancel form",
description: "Cancel a pending form.",
}),
),
)
.add(
HttpApiEndpoint.put("session.environment", "/api/session/:sessionID/environment", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ variables: Schema.Record(Schema.String, Schema.String) }),
success: HttpApiSchema.NoContent,
error: SessionNotFoundError,
}).annotateMerge(
OpenApi.annotations({
identifier: "experimental.session.environment",
identifier: "session.environment",
summary: "Set session environment",
description: "Replace the process environment used by local shell commands for this session.",
}),
+6
View File
@@ -152,6 +152,12 @@ export const State = Schema.Union([
.annotate({ identifier: "Form.State" })
export type State = typeof State.Type
export const Detail = Schema.Struct({
...Info.fields,
state: State,
}).annotate({ identifier: "Form.Detail" })
export interface Detail extends Schema.Schema.Type<typeof Detail> {}
export const Reply = Schema.Struct({
answer: Answer,
}).annotate({ identifier: "Form.Reply" })
+5 -1
View File
@@ -3,7 +3,7 @@ export * as PermissionSaved from "./permission-saved.js"
import { Schema } from "effect"
import { ascending } from "./identifier.js"
import { ProjectID } from "./project-id.js"
import { statics } from "./schema.js"
import { DateTimeUtcFromMillis, statics } from "./schema.js"
export const ID = Schema.String.pipe(
Schema.brand("PermissionSaved.ID"),
@@ -16,5 +16,9 @@ export const Info = Schema.Struct({
projectID: ProjectID,
action: Schema.String,
resource: Schema.String,
time: Schema.Struct({
created: DateTimeUtcFromMillis,
updated: DateTimeUtcFromMillis,
}),
}).annotate({ identifier: "PermissionSaved.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
+1 -1
View File
@@ -90,7 +90,7 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b
expect(setups).toEqual([])
// Permission and form lists read instance services, so they acquire the Session's instance.
expect(await first.permission.list({ sessionID })).toEqual([])
expect(await first.form.list({ sessionID })).toEqual([])
expect(await first.session.form.list({ sessionID })).toEqual([])
expect(configured).toEqual(["first:alpha"])
await Promise.all(
+8 -116
View File
@@ -1,123 +1,15 @@
import { Form } from "@opencode/core/form"
import { Instance } from "@opencode/core/instance/service"
import { LocationServiceMap } from "@opencode/core/location-services"
import { Session } from "@opencode/core/session"
import {
ConflictError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
} from "@opencode/protocol/errors"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { HttpApiBuilder } from "effect/unstable/httpapi"
import { Api } from "../api"
import { requestRef, response, sessionInfo } from "../location"
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
import { response } from "../location"
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
Effect.gen(function* () {
const locations = yield* LocationServiceMap.Service
const instances = yield* Instance.Service
const sessions = yield* Session.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
handlers.handle(
"form.list",
Effect.fn(function* () {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
if (info.sessionID !== sessionID) return yield* missingForm(formID)
return { form, info }
})
return handlers
.handle(
"form.request.list",
Effect.fn(function* () {
const form = yield* Form.Service
return yield* response(form.list())
}),
)
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const session =
ctx.params.sessionID === "global" ? undefined : yield* sessionInfo(sessions, ctx.params.sessionID)
const read = Form.Service.use((form) => form.list({ sessionID: ctx.params.sessionID }))
const forms = yield* session
? read.pipe(instances.provide(session))
: read.pipe(Effect.provide(locations.get(requestRef(ctx.request))))
return { data: forms }
}),
)
.handle(
"session.form.create",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
const created = yield* form
.create({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
title: ctx.payload.title,
metadata: ctx.payload.metadata,
fields: ctx.payload.fields,
})
.pipe(
Effect.catchTags({
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
"Form.InvalidFormError": (error) =>
new InvalidRequestError({ message: error.message, field: "fields" }),
}),
)
return { data: created }
}),
)
.handle(
"session.form.get",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
return { data: owned.info }
}),
)
.handle(
"session.form.state",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
const data = yield* owned.form
.state(ctx.params.formID)
.pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID)))
return { data }
}),
)
.handle(
"session.form.reply",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.InvalidAnswerError": (error) =>
new FormInvalidAnswerError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.form.cancel",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.cancel(ctx.params.formID).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
return yield* response(form.list())
}),
),
)
+1 -1
View File
@@ -78,7 +78,7 @@ export const PermissionHandler = HttpApiBuilder.group(Api, "server.permission",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedRequest(ctx.params.sessionID, ctx.params.requestID)
yield* owned.permission
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.reply, message: ctx.payload.message })
.reply({ requestID: ctx.params.requestID, reply: ctx.payload.decision, message: ctx.payload.message })
.pipe(Effect.catchTag("Permission.NotFoundError", () => missingRequest(ctx.params.requestID)))
return HttpApiSchema.NoContent.make()
}),
+92 -15
View File
@@ -3,6 +3,7 @@ import { SessionStats } from "@opencode/core/session/stats"
import { SessionTitle } from "@opencode/core/session/title"
import { SessionTransfer } from "@opencode/core/session/transfer"
import { InstructionEntry } from "@opencode/core/session/instruction-entry"
import { Form } from "@opencode/core/form"
import { DateTime, Effect, Stream } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -11,6 +12,9 @@ import {
ConflictError,
CommandExecutionError,
CommandNotFoundError,
FormAlreadySettledError,
FormInvalidAnswerError,
FormNotFoundError,
InvalidRequestError,
InvalidCursorError,
MessageNotFoundError,
@@ -23,10 +27,20 @@ import { failedMessageDecode, failedSnapshot, missingMessage, missingSession } f
const DefaultSessionsLimit = 50
function missingForm(id: Form.ID) {
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
}
export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handlers) =>
Effect.gen(function* () {
const session = yield* Session.Service
const transfer = yield* SessionTransfer.Service
const requireOwnedForm = Effect.fnUntraced(function* (sessionID: Form.Info["sessionID"], formID: Form.ID) {
const form = yield* Form.Service
const info = yield* form.get(formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(formID)))
if (info.sessionID !== sessionID) return yield* missingForm(formID)
return { form, info }
})
const busySession = (error: Session.BusyError) =>
new SessionBusyError({
sessionID: error.sessionID,
@@ -517,27 +531,21 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
.handle(
"session.inbox.cancel",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input can no longer be cancelled",
yield* session.cancelInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.InboxConflictError", () => Effect.void),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.inbox.steer",
"session.inbox.update",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.steerInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input is no longer queued",
)
}),
)
.handle(
"session.inbox.queue",
Effect.fn(function* (ctx) {
return yield* pendingMutation(
session.queueInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
"Pending input is no longer a steer",
ctx.payload.delivery === "steer"
? session.steerInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID })
: session.queueInbox({ sessionID: ctx.params.sessionID, inboxID: ctx.params.inboxID }),
`Pending input cannot change to ${ctx.payload.delivery}`,
)
}),
)
@@ -614,5 +622,74 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
})
}),
)
.handle(
"session.form.list",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
return { data: yield* form.list({ sessionID: ctx.params.sessionID }) }
}),
)
.handle(
"session.form.create",
Effect.fn(function* (ctx) {
const form = yield* Form.Service
const created = yield* form
.create({
id: ctx.payload.id,
sessionID: ctx.params.sessionID,
title: ctx.payload.title,
metadata: ctx.payload.metadata,
fields: ctx.payload.fields,
})
.pipe(
Effect.catchTags({
"Form.AlreadyExistsError": (error) => new ConflictError({ resource: error.id, message: error.message }),
"Form.InvalidFormError": (error) =>
new InvalidRequestError({ message: error.message, field: "fields" }),
}),
)
return { data: created }
}),
)
.handle(
"session.form.get",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
const state = yield* owned.form
.state(ctx.params.formID)
.pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID)))
return { data: { ...owned.info, state } }
}),
)
.handle(
"session.form.reply",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.InvalidAnswerError": (error) =>
new FormInvalidAnswerError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"session.form.cancel",
Effect.fn(function* (ctx) {
const owned = yield* requireOwnedForm(ctx.params.sessionID, ctx.params.formID)
yield* owned.form.cancel(ctx.params.formID).pipe(
Effect.catchTags({
"Form.AlreadySettledError": (error) =>
new FormAlreadySettledError({ id: error.id, message: error.message }),
"Form.NotFoundError": () => missingForm(ctx.params.formID),
}),
)
return HttpApiSchema.NoContent.make()
}),
)
}),
)
@@ -295,7 +295,7 @@ it.live(
).toBe(404)
expect(
(yield* request(`/api/session/${entry.session.id}/permission/${entry.foreignPermission.id}/reply`, {
reply: "once",
decision: "once",
})).status,
).toBe(404)
yield* Effect.gen(function* () {
@@ -315,7 +315,7 @@ it.live(
).toBe(204)
expect(
(yield* request(`/api/session/${entry.session.id}/permission/${entry.permission.id}/reply`, {
reply: "once",
decision: "once",
})).status,
).toBe(204)
yield* Effect.gen(function* () {
+2 -2
View File
@@ -1006,14 +1006,14 @@ export function createRunDemo(input: Input) {
const permission = (input: PermissionReply): boolean => {
const item = state.perms.get(input.requestID)
if (!item || !input.reply) {
if (!item || !input.decision) {
return false
}
state.perms.delete(input.requestID)
clearBlocker(state)
if (input.reply === "reject") {
if (input.decision === "reject") {
failTool(state, item.ref, input.message || "permission rejected")
return true
}
+2 -2
View File
@@ -79,13 +79,13 @@ export { permissionAlwaysLines }
function permissionReply(
sessionID: string,
requestID: string,
reply: PermissionReply["reply"],
decision: PermissionReply["decision"],
message?: string,
): PermissionReply {
return {
sessionID,
requestID,
reply,
decision,
...(message && message.trim() ? { message: message.trim() } : {}),
}
}
+7 -3
View File
@@ -276,7 +276,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
onFormReply: async (next) => {
if (state.demo?.formReply(next)) return
try {
await state.sdk.form.reply(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
await state.sdk.session.form.reply(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
@@ -285,7 +285,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
onFormCancel: async (next) => {
if (state.demo?.formCancel(next)) return
try {
await state.sdk.form.cancel(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
await state.sdk.session.form.cancel(next, formRequestOptions(next.sessionID === "global" ? next.location : undefined))
} catch (error) {
if (!formAlreadySettled(error)) throw error
}
@@ -395,7 +395,11 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
onQueuedPromptAction: async (action, inboxID) => {
if (!state.sessionID) return
log?.write(`send.pending.${action}`, { sessionID: state.sessionID, inboxID })
await state.sdk.session.inbox[action]({ sessionID: state.sessionID, inboxID })
if (action === "cancel") {
await state.sdk.session.inbox.cancel({ sessionID: state.sessionID, inboxID })
return
}
await state.sdk.session.inbox.update({ sessionID: state.sessionID, inboxID, delivery: action })
},
onSubagentInterrupt: (sessionID) => {
log?.write("send.subagent.interrupt", { sessionID })
+1 -1
View File
@@ -541,7 +541,7 @@ export function createSubagentTracker(input: SubagentTrackerInput): SubagentTrac
replayBlockerEvents(child, "permission")
input.emit()
}),
sdk.form.list({ sessionID: child.sessionID }, { signal }).then((forms) => {
sdk.session.form.list({ sessionID: child.sessionID }, { signal }).then((forms) => {
if (!blockerCurrent(child, epoch, signal)) return
child.forms = forms
replayBlockerEvents(child, "form")
+2 -2
View File
@@ -948,9 +948,9 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
projectedMessages(client, attempt.signal),
client.session.inbox.list({ sessionID: input.sessionID }, options),
client.permission.list({ sessionID: input.sessionID }, options),
client.form.list({ sessionID: input.sessionID }, options),
client.session.form.list({ sessionID: input.sessionID }, options),
input.location
? client.form.request.list(
? client.form.list(
{
location: { directory: input.location.directory },
},
+3 -3
View File
@@ -244,7 +244,7 @@ export function Session(props: {
void data.session.permission
.reply({
sessionID: request.sessionID,
reply: "once",
decision: "once",
requestID: request.id,
})
.catch((error) => {
@@ -545,9 +545,9 @@ export function Session(props: {
const result = await runPendingAction(inboxID, async () => {
const request =
action === "steer"
? client.api.session.inbox.steer({ sessionID: route.sessionID, inboxID })
? client.api.session.inbox.update({ sessionID: route.sessionID, inboxID, delivery: "steer" })
: action === "queue"
? client.api.session.inbox.queue({ sessionID: route.sessionID, inboxID })
? client.api.session.inbox.update({ sessionID: route.sessionID, inboxID, delivery: "queue" })
: client.api.session.inbox.cancel({ sessionID: route.sessionID, inboxID })
const error = await request.then(
() => undefined,
@@ -135,7 +135,7 @@ export function PermissionPrompt(props: { request: PermissionRequest; directory?
function reply(value: PermissionReply, message?: string) {
void data.session.permission
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, reply: value, message })
.reply({ sessionID: props.request.sessionID, requestID: props.request.id, decision: value, message })
.catch((error: unknown) => toast.error(error))
}
+3 -3
View File
@@ -2347,7 +2347,7 @@ test("dismisses a permission that expired before its reply", async () => {
await data.session.permission.reply({
sessionID: request.sessionID,
requestID: request.id,
reply: "once",
decision: "once",
})
expect(replies).toBe(1)
@@ -2501,7 +2501,7 @@ test("syncs global forms once for each requested location", async () => {
const requests: URL[] = []
const other = { directory: "/tmp/opencode-other" }
const calls = createFetch((url) => {
if (url.pathname !== "/api/form/request") return
if (url.pathname !== "/api/form") return
requests.push(url)
const requestedDirectory = url.searchParams.get("location[directory]") ?? directory
return json({
@@ -2579,7 +2579,7 @@ test("resyncs global forms only for the active location after reconnect", async
],
cursor: {},
})
if (url.pathname !== "/api/form/request") return
if (url.pathname !== "/api/form") return
requests.push(url)
const requestedDirectory = url.searchParams.get("location[directory]") ?? home.directory
const count = (counts.get(requestedDirectory) ?? 0) + 1
+1 -1
View File
@@ -67,7 +67,7 @@ async function mountForm(
replies.push(answer)
return response?.reply ? failure(response.reply) : new Response(null, { status: 204 })
})
if (url.pathname === "/api/session/ses_test/form/frm_test/cancel") {
if (url.pathname === "/api/session/ses_test/form/frm_test" && request.method === "DELETE") {
cancellations.push(true)
return response?.cancel ? failure(response.cancel) : new Response(null, { status: 204 })
}
@@ -243,7 +243,7 @@ test("permission layers leave the focused peer's Enter and navigation alone unti
panes.setActive(true)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "once" }])
expect(panes.replies).toEqual([{ decision: "once" }])
expect(panes.submissions).toHaveLength(1)
} finally {
panes.app.renderer.destroy()
@@ -272,7 +272,7 @@ test("permission rejection text keeps its draft and regains focus when its scope
expect(panes.app.renderer.currentFocusedEditor?.id).toBe(input?.id)
panes.app.mockInput.pressEnter()
await panes.app.waitFor(() => panes.replies.length === 1)
expect(panes.replies).toEqual([{ reply: "reject", message: "choose another command" }])
expect(panes.replies).toEqual([{ decision: "reject", message: "choose another command" }])
} finally {
panes.app.renderer.destroy()
}
+1 -1
View File
@@ -142,7 +142,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
data: [],
})
if (url.pathname === "/api/form/request")
if (url.pathname === "/api/form")
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
if (/^\/api\/experimental\/session\/[^/]+\/terminal$/.test(url.pathname)) return json({ data: [] })
@@ -419,7 +419,7 @@ test.each(["once", "always", "reject"] as const)(
app.mockInput.pressEnter()
await settle(app)
}
expect(replies).toEqual([{ sessionID: permission.sessionID, requestID: permission.id, reply: choice }])
expect(replies).toEqual([{ sessionID: permission.sessionID, requestID: permission.id, decision: choice }])
} finally {
footer.destroy()
app.renderer.destroy()
@@ -34,7 +34,7 @@ describe("run permission shared", () => {
expect(out.reply).toEqual({
sessionID: "session-1",
requestID: "perm-1",
reply: "once",
decision: "once",
})
})
@@ -47,7 +47,7 @@ describe("run permission shared", () => {
expect(permissionRun(next.state, "perm-1", "confirm").reply).toEqual({
sessionID: "session-1",
requestID: "perm-1",
reply: "always",
decision: "always",
})
expect(permissionRun(next.state, "perm-1", "cancel").state).toMatchObject({
@@ -64,7 +64,7 @@ describe("run permission shared", () => {
expect(out).toEqual({
sessionID: "session-1",
requestID: "perm-1",
reply: "reject",
decision: "reject",
message: "use rg",
})
+1 -1
View File
@@ -173,7 +173,7 @@ describe("run interactive runtime", () => {
let lifecycle!: LifecycleInput
const settled: Array<{ sessionID: string; formID: string }> = []
stubCatalogLists(sdk)
const reply = spyOn(sdk.form, "reply").mockImplementation(() => ok(undefined))
const reply = spyOn(sdk.session.form, "reply").mockImplementation(() => ok(undefined))
const task = runInteractiveDeferredMode(
{
@@ -174,8 +174,8 @@ function sdk(input: {
}),
)
spyOn(client.permission, "list").mockImplementation((request) => ok(input.permissions?.[request.sessionID] ?? []))
spyOn(client.form, "list").mockImplementation((request) => ok(input.forms?.[request.sessionID] ?? []))
spyOn(client.form.request, "list").mockImplementation(() =>
spyOn(client.session.form, "list").mockImplementation((request) => ok(input.forms?.[request.sessionID] ?? []))
spyOn(client.form, "list").mockImplementation(() =>
ok({
location: {
directory: input.globalLocation?.directory ?? "/tmp",
+518 -630
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff