mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
refactor(protocol): simplify interactive resources
This commit is contained in:
+18
-18
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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)))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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}`)),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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([])
|
||||
|
||||
@@ -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: {
|
||||
|
||||
+518
-630
File diff suppressed because it is too large
Load Diff
@@ -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))
|
||||
|
||||
@@ -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." }))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
|
||||
@@ -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" })
|
||||
|
||||
@@ -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> {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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())
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
|
||||
@@ -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* () {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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() } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
+518
-630
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user