mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 03:18:31 +00:00
fix(core): revert form service and mcp elicitation (#35080)
This commit is contained in:
parent
1de3c6e4a6
commit
ef2140d121
73 changed files with 5874 additions and 5779 deletions
|
|
@ -10,21 +10,17 @@ const title = "Request dock regression"
|
|||
|
||||
test("shows a pending question dock", async ({ page }) => {
|
||||
await mockServer(page, {
|
||||
forms: [
|
||||
questions: [
|
||||
{
|
||||
id: "frm_question_request",
|
||||
id: "question-request",
|
||||
sessionID,
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
questions: [
|
||||
{
|
||||
key: "question_0",
|
||||
type: "string",
|
||||
title: "Which implementation should be used?",
|
||||
description: "Implementation",
|
||||
header: "Implementation",
|
||||
question: "Which implementation should be used?",
|
||||
options: [
|
||||
{ value: "Minimal", label: "Minimal", description: "Use the smallest correct change" },
|
||||
{ value: "Extended", label: "Extended", description: "Include additional behavior" },
|
||||
{ label: "Minimal", description: "Use the smallest correct change" },
|
||||
{ label: "Extended", description: "Include additional behavior" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -45,8 +41,7 @@ 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`)
|
||||
rejectRequests.push(request.url())
|
||||
if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url())
|
||||
})
|
||||
|
||||
await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click()
|
||||
|
|
@ -68,12 +63,10 @@ test("shows a pending question dock", async ({ page }) => {
|
|||
|
||||
await question.getByRole("radio", { name: /Minimal/ }).click()
|
||||
const reply = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname === `/api/session/${sessionID}/form/frm_question_request/reply`,
|
||||
(request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply",
|
||||
)
|
||||
await question.getByRole("button", { name: "Submit" }).click()
|
||||
expect((await reply).postDataJSON()).toEqual({ answer: { question_0: "Minimal" } })
|
||||
expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] })
|
||||
})
|
||||
|
||||
test("shows a pending permission dock", async ({ page }) => {
|
||||
|
|
@ -112,7 +105,6 @@ async function mockServer(
|
|||
requests: {
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
},
|
||||
) {
|
||||
await mockOpenCodeServer(page, {
|
||||
|
|
@ -156,7 +148,6 @@ async function mockServer(
|
|||
pageMessages: () => ({ items: [] }),
|
||||
permissions: requests.permissions,
|
||||
questions: requests.questions,
|
||||
forms: requests.forms,
|
||||
})
|
||||
await page.addInitScript(() => {
|
||||
localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } }))
|
||||
|
|
|
|||
|
|
@ -17,7 +17,6 @@ export interface MockServerConfig {
|
|||
todos?: (sessionID: string) => unknown[]
|
||||
permissions?: unknown[] | (() => unknown[])
|
||||
questions?: unknown[] | (() => unknown[])
|
||||
forms?: unknown[] | (() => unknown[])
|
||||
}
|
||||
|
||||
export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
||||
|
|
@ -54,17 +53,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
|
|||
return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
|
||||
if (path === "/question")
|
||||
return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? []))
|
||||
if (path === "/api/form/request")
|
||||
return json(route, {
|
||||
location: { directory: config.directory, project: config.project },
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (/^\/api\/session\/[^/]+\/form$/.test(path))
|
||||
return json(route, {
|
||||
location: { directory: config.directory, project: config.project },
|
||||
data: typeof config.forms === "function" ? config.forms() : (config.forms ?? []),
|
||||
})
|
||||
if (/^\/api\/session\/[^/]+\/form\/[^/]+\/(reply|cancel)$/.test(path)) return json(route, undefined, undefined, 204)
|
||||
if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff)
|
||||
if (emptyObject.has(path)) return json(route, {})
|
||||
if (emptyList.has(path)) return json(route, [])
|
||||
|
|
|
|||
|
|
@ -30,13 +30,11 @@ export const createDirSyncContext = (
|
|||
const data = new Proxy({} as State, {
|
||||
get(_, property: keyof State) {
|
||||
if (property === "session_working") return serverSync.session.data.session_working.bind(serverSync.session.data)
|
||||
if (property === "question") return { ...serverSync.session.data.question, global: current()[0].question.global ?? [] }
|
||||
if (sessionFields.has(property)) return serverSync.session.data[property as keyof typeof serverSync.session.data]
|
||||
return current()[0][property]
|
||||
},
|
||||
})
|
||||
const set = ((...input: unknown[]) => {
|
||||
if (input[0] === "question" && input[1] === "global") return (current()[1] as (...args: unknown[]) => unknown)(...input)
|
||||
if (typeof input[0] === "string" && sessionFields.has(input[0])) {
|
||||
return (serverSync.session.set as (...args: unknown[]) => unknown)(...input)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,10 +69,7 @@ describe("bootstrapDirectory", () => {
|
|||
},
|
||||
permission: { list: async () => ({ data: [] }) },
|
||||
question: { list: async () => ({ data: [] }) },
|
||||
v2: {
|
||||
form: { request: { list: async () => ({ data: { data: [] } }) } },
|
||||
reference: { list: async () => ({ data: { data: [] } }) },
|
||||
},
|
||||
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
|
||||
mcp: {
|
||||
status: async () => {
|
||||
mcpReads.push("status")
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
PermissionRequest,
|
||||
Project,
|
||||
ProviderAuthResponse,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
|
|
@ -21,7 +22,6 @@ import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
|||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import { isQuestionForm, type QuestionForm } from "@/utils/question-form"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
|
|
@ -319,11 +319,9 @@ export async function bootstrapDirectory(input: {
|
|||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.sdk.v2.form.request.list().then((x) => {
|
||||
const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
|
||||
const global = forms.filter((question) => question.sessionID === "global")
|
||||
const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global"))
|
||||
const ids = Object.keys(grouped)
|
||||
input.sdk.question.list().then((x) => {
|
||||
const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id)
|
||||
const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID))
|
||||
const warm = input.session
|
||||
? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined)
|
||||
: warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk })
|
||||
|
|
@ -331,22 +329,11 @@ export async function bootstrapDirectory(input: {
|
|||
batch(() => {
|
||||
const current = input.session?.data.question ?? input.store.question
|
||||
for (const sessionID of Object.keys(current)) {
|
||||
if (sessionID === "global") continue
|
||||
if (grouped[sessionID]) continue
|
||||
if (input.session?.get(sessionID)?.directory !== input.directory) continue
|
||||
if (input.session) input.session.set("question", sessionID, [])
|
||||
if (!input.session) input.setStore("question", sessionID, [])
|
||||
}
|
||||
if (global.length > 0 || input.store.question.global) {
|
||||
input.setStore(
|
||||
"question",
|
||||
"global",
|
||||
reconcile(
|
||||
global.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
{ key: "id" },
|
||||
),
|
||||
)
|
||||
}
|
||||
for (const [sessionID, questions] of Object.entries(grouped)) {
|
||||
const value = reconcile(
|
||||
questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)),
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { Message, Part, PermissionRequest, Project, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./event-reducer"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
|
||||
const rootSession = (input: { id: string; parentID?: string; archived?: number }) =>
|
||||
({
|
||||
|
|
@ -49,18 +48,14 @@ const questionRequest = (id: string, sessionID: string, title = id) =>
|
|||
({
|
||||
id,
|
||||
sessionID,
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [
|
||||
questions: [
|
||||
{
|
||||
key: "question_0",
|
||||
title,
|
||||
description: title,
|
||||
type: "string",
|
||||
options: [{ value: title, label: title, description: title }],
|
||||
question: title,
|
||||
header: title,
|
||||
options: [{ label: title, description: title }],
|
||||
},
|
||||
],
|
||||
}) as QuestionForm
|
||||
}) as QuestionRequest
|
||||
|
||||
const baseState = (input: Partial<State> = {}) =>
|
||||
({
|
||||
|
|
@ -510,7 +505,7 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } },
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
|
@ -520,18 +515,17 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } },
|
||||
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
})
|
||||
const form = store.question[sessionID]?.find((x) => x.id === "q_2")
|
||||
expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated")
|
||||
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
|
||||
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
|
|
@ -541,34 +535,6 @@ describe("applyDirectoryEvent", () => {
|
|||
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_3"])
|
||||
})
|
||||
|
||||
test("tracks global form lifecycles when session content is delegated", () => {
|
||||
const [store, setStore] = createStore(baseState())
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.created", properties: { form: questionRequest("q_1", "global") } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global?.map((x) => x.id)).toEqual(["q_1"])
|
||||
|
||||
applyDirectoryEvent({
|
||||
event: { type: "form.cancelled", properties: { sessionID: "global", id: "q_1" } },
|
||||
store,
|
||||
setStore,
|
||||
push() {},
|
||||
directory: "/tmp",
|
||||
loadLsp() {},
|
||||
sessionContent: false,
|
||||
})
|
||||
|
||||
expect(store.question.global).toEqual([])
|
||||
})
|
||||
|
||||
test("updates vcs branch in store and cache", () => {
|
||||
const [store, setStore] = createStore(baseState({ vcs: { branch: "main", default_branch: "main" } }))
|
||||
const [cacheStore, setCacheStore] = createStore({
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import type {
|
|||
Part,
|
||||
PermissionRequest,
|
||||
Project,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
|
|
@ -14,7 +15,6 @@ import type { State, VcsCache } from "./types"
|
|||
import { trimSessions } from "./session-trim"
|
||||
import { dropSessionCaches } from "./session-cache"
|
||||
import { diffs as list, message as clean } from "@/utils/diffs"
|
||||
import { isQuestionForm } from "@/utils/question-form"
|
||||
|
||||
const SKIP_PARTS = new Set(["patch", "step-start", "step-finish"])
|
||||
const SESSION_CONTENT_EVENTS = new Set([
|
||||
|
|
@ -28,9 +28,9 @@ const SESSION_CONTENT_EVENTS = new Set([
|
|||
"message.part.delta",
|
||||
"permission.asked",
|
||||
"permission.replied",
|
||||
"form.created",
|
||||
"form.replied",
|
||||
"form.cancelled",
|
||||
"question.asked",
|
||||
"question.replied",
|
||||
"question.rejected",
|
||||
])
|
||||
|
||||
export function applyGlobalEvent(input: {
|
||||
|
|
@ -120,7 +120,7 @@ export function applyDirectoryEvent(input: {
|
|||
permission?: State["permission"]
|
||||
}) {
|
||||
const event = input.event
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return
|
||||
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type)) return
|
||||
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
|
||||
switch (event.type) {
|
||||
case "server.instance.disposed": {
|
||||
|
|
@ -364,10 +364,8 @@ export function applyDirectoryEvent(input: {
|
|||
)
|
||||
break
|
||||
}
|
||||
case "form.created": {
|
||||
const properties = event.properties as { form?: unknown }
|
||||
if (!isQuestionForm(properties.form)) break
|
||||
const question = properties.form
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = input.store.question[question.sessionID]
|
||||
if (!questions) {
|
||||
input.setStore("question", question.sessionID, [question])
|
||||
|
|
@ -387,12 +385,12 @@ export function applyDirectoryEvent(input: {
|
|||
)
|
||||
break
|
||||
}
|
||||
case "form.replied":
|
||||
case "form.cancelled": {
|
||||
const props = event.properties as { sessionID: string; id: string }
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
const questions = input.store.question[props.sessionID]
|
||||
if (!questions) break
|
||||
const result = Binary.search(questions, props.id, (q) => q.id)
|
||||
const result = Binary.search(questions, props.requestID, (q) => q.id)
|
||||
if (!result.found) break
|
||||
input.setStore(
|
||||
"question",
|
||||
|
|
@ -413,13 +411,3 @@ export function applyDirectoryEvent(input: {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isGlobalQuestionEvent(event: { type: string; properties?: unknown }) {
|
||||
if (event.type === "form.created") {
|
||||
const form = (event.properties as { form?: unknown } | undefined)?.form
|
||||
return isQuestionForm(form) && form.sessionID === "global"
|
||||
}
|
||||
if (event.type !== "form.replied" && event.type !== "form.cancelled") return false
|
||||
const properties = event.properties as { sessionID?: unknown } | undefined
|
||||
return properties?.sessionID === "global"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import type {
|
|||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
|
||||
const msg = (id: string, sessionID: string) =>
|
||||
({
|
||||
|
|
@ -38,7 +38,7 @@ describe("app session cache", () => {
|
|||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: { ses_1: { type: "busy" } as SessionStatus },
|
||||
|
|
@ -47,7 +47,7 @@ describe("app session cache", () => {
|
|||
message: {},
|
||||
part: { msg_1: [part("prt_1", "ses_1", "msg_1")] },
|
||||
permission: { ses_1: [] as PermissionRequest[] },
|
||||
question: { ses_1: [] as QuestionForm[] },
|
||||
question: { ses_1: [] as QuestionRequest[] },
|
||||
part_text_accum_delta: { prt_1: "streamed text" },
|
||||
}
|
||||
|
||||
|
|
@ -72,7 +72,7 @@ describe("app session cache", () => {
|
|||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
} = {
|
||||
session_status: {},
|
||||
|
|
|
|||
|
|
@ -2,11 +2,11 @@ import type {
|
|||
Message,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
|
||||
export const SESSION_CACHE_LIMIT = 40
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ type SessionCache = {
|
|||
message: Record<string, Message[] | undefined>
|
||||
part: Record<string, Part[] | undefined>
|
||||
permission: Record<string, PermissionRequest[] | undefined>
|
||||
question: Record<string, QuestionForm[] | undefined>
|
||||
question: Record<string, QuestionRequest[] | undefined>
|
||||
part_text_accum_delta: Record<string, string | undefined>
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import type {
|
|||
Part,
|
||||
Path,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ReferenceInfo,
|
||||
Session,
|
||||
SessionStatus,
|
||||
|
|
@ -16,7 +17,6 @@ import type {
|
|||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { Accessor } from "solid-js"
|
||||
import type { SetStoreFunction, Store } from "solid-js/store"
|
||||
|
|
@ -60,7 +60,7 @@ export type State = {
|
|||
[sessionID: string]: PermissionRequest[]
|
||||
}
|
||||
question: {
|
||||
[sessionID: string]: QuestionForm[]
|
||||
[sessionID: string]: QuestionRequest[]
|
||||
}
|
||||
mcp_ready: boolean
|
||||
mcp: {
|
||||
|
|
|
|||
|
|
@ -5,12 +5,12 @@ import type {
|
|||
OpencodeClient,
|
||||
Part,
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SessionStatus,
|
||||
SnapshotFileDiff,
|
||||
Todo,
|
||||
} from "@opencode-ai/sdk/v2/client"
|
||||
import { isQuestionForm, type QuestionForm } from "@/utils/question-form"
|
||||
import { batch } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs"
|
||||
|
|
@ -136,7 +136,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
session_diff: {} as Record<string, SnapshotFileDiff[]>,
|
||||
todo: {} as Record<string, Todo[]>,
|
||||
permission: {} as Record<string, PermissionRequest[]>,
|
||||
question: {} as Record<string, QuestionForm[]>,
|
||||
question: {} as Record<string, QuestionRequest[]>,
|
||||
message: {} as Record<string, Message[]>,
|
||||
part: {} as Record<string, Part[]>,
|
||||
part_text_accum_delta: {} as Record<string, string>,
|
||||
|
|
@ -932,10 +932,8 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
)
|
||||
return
|
||||
}
|
||||
case "form.created": {
|
||||
const properties = event.properties as { form?: unknown }
|
||||
if (!isQuestionForm(properties.form)) return
|
||||
const question = properties.form
|
||||
case "question.asked": {
|
||||
const question = event.properties as QuestionRequest
|
||||
const questions = data.question[question.sessionID]
|
||||
if (!questions) {
|
||||
setData("question", question.sessionID, [question])
|
||||
|
|
@ -951,15 +949,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
|
|||
)
|
||||
return
|
||||
}
|
||||
case "form.replied":
|
||||
case "form.cancelled": {
|
||||
const props = event.properties as { sessionID: string; id: string }
|
||||
case "question.replied":
|
||||
case "question.rejected": {
|
||||
const props = event.properties as { sessionID: string; requestID: string }
|
||||
setData(
|
||||
"question",
|
||||
props.sessionID,
|
||||
produce((draft) => {
|
||||
if (!draft) return
|
||||
const result = Binary.search(draft, props.id, (item) => item.id)
|
||||
const result = Binary.search(draft, props.requestID, (item) => item.id)
|
||||
if (result.found) draft.splice(result.index, 1)
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import {
|
|||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } from "./global-sync/event-reducer"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load"
|
||||
import { trimSessions } from "./global-sync/session-trim"
|
||||
import type { ProjectMeta } from "./global-sync/types"
|
||||
|
|
@ -375,7 +375,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
|||
const event = e.details
|
||||
const recent = bootingRoot || Date.now() - bootedAt < 1500
|
||||
|
||||
if (!isGlobalQuestionEvent(event)) session.apply(event)
|
||||
session.apply(event)
|
||||
|
||||
if (directory === "global") {
|
||||
applyGlobalEvent({
|
||||
|
|
|
|||
|
|
@ -48,7 +48,6 @@ import { setNavigate } from "@/utils/notification-click"
|
|||
import { Worktree as WorktreeState } from "@/utils/worktree"
|
||||
import { setSessionHandoff } from "@/pages/session/handoff"
|
||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||
import { isQuestionForm } from "@/utils/question-form"
|
||||
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
|
|
@ -404,22 +403,25 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
return
|
||||
}
|
||||
|
||||
if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") {
|
||||
if (
|
||||
e.details?.type === "question.replied" ||
|
||||
e.details?.type === "question.rejected" ||
|
||||
e.details?.type === "permission.replied"
|
||||
) {
|
||||
const props = e.details.properties as { sessionID: string }
|
||||
const sessionKey = `${e.name}:${props.sessionID}`
|
||||
dismissSessionAlert(sessionKey)
|
||||
return
|
||||
}
|
||||
|
||||
const questionForm = e.details?.type === "form.created" && isQuestionForm(e.details.properties?.form) ? e.details.properties.form : undefined
|
||||
if (e.details?.type !== "permission.asked" && !questionForm) return
|
||||
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") return
|
||||
const title =
|
||||
e.details.type === "permission.asked"
|
||||
? language.t("notification.permission.title")
|
||||
: language.t("notification.question.title")
|
||||
const icon = e.details.type === "permission.asked" ? ("checklist" as const) : ("bubble-5" as const)
|
||||
const directory = e.name
|
||||
const props = questionForm ?? (e.details.properties as { sessionID: string })
|
||||
const props = e.details.properties
|
||||
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
|
||||
|
||||
const [store] = serverSync().child(directory, { bootstrap: false })
|
||||
|
|
@ -448,7 +450,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
}
|
||||
}
|
||||
|
||||
if (questionForm) {
|
||||
if (e.details.type === "question.asked") {
|
||||
if (settings.notifications.agent()) {
|
||||
void platform.notify(title, description, href)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import { todoDockAtBoundary, todoState } from "./session-composer-state"
|
||||
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
|
||||
|
||||
|
|
@ -20,10 +19,8 @@ const question = (id: string, sessionID: string) =>
|
|||
({
|
||||
id,
|
||||
sessionID,
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [],
|
||||
}) as QuestionForm
|
||||
questions: [],
|
||||
}) as QuestionRequest
|
||||
|
||||
describe("sessionPermissionRequest", () => {
|
||||
test("prefers the current session permission", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { createEffect, createMemo, on, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
|
|
@ -34,7 +33,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
|||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
|
||||
const questionRequest = createMemo((): QuestionForm | undefined => {
|
||||
const questionRequest = createMemo((): QuestionRequest | undefined => {
|
||||
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -6,21 +6,13 @@ import { DockPrompt } from "@opencode-ai/session-ui/dock-prompt"
|
|||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import type { QuestionAnswer, QuestionRequest } from "@opencode-ai/sdk/v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ScopedKey } from "@/utils/server-scope"
|
||||
import {
|
||||
questionAllowsCustom,
|
||||
questionAnswer,
|
||||
questionLabel,
|
||||
questionMessage,
|
||||
questionOptions,
|
||||
type QuestionAnswer,
|
||||
type QuestionForm,
|
||||
} from "@/utils/question-form"
|
||||
|
||||
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
|
||||
|
||||
|
|
@ -69,14 +61,14 @@ function Option(props: {
|
|||
)
|
||||
}
|
||||
|
||||
export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: () => void }> = (props) => {
|
||||
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
|
||||
|
||||
const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
|
||||
const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length))
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const total = createMemo(() => questions().length)
|
||||
|
||||
const cached = cache.get(cacheKey)
|
||||
const [store, setStore] = createStore({
|
||||
|
|
@ -98,23 +90,16 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
let focusFrame: number | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const options = createMemo(() => questionOptions(question()))
|
||||
const custom = createMemo(() => questionAllowsCustom(question()))
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const on = createMemo(() => custom() && store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.type === "multiselect")
|
||||
const count = createMemo(() => options().length + (custom() ? 1 : 0))
|
||||
const on = createMemo(() => store.customOn[store.tab] === true)
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const count = createMemo(() => options().length + 1)
|
||||
|
||||
const summary = createMemo(() => {
|
||||
if (props.request.title) return props.request.title
|
||||
const n = Math.min(store.tab + 1, total())
|
||||
return language.t("session.question.progress", { current: n, total: total() })
|
||||
})
|
||||
const body = createMemo(() => {
|
||||
if (props.request.mode === "url") return props.request.title ?? "Open URL request"
|
||||
return questionLabel(question())
|
||||
})
|
||||
const message = createMemo(() => questionMessage(props.request))
|
||||
const customLabel = () => language.t("ui.messagePart.option.typeOwnAnswer")
|
||||
const customPlaceholder = () => language.t("ui.question.custom.placeholder")
|
||||
|
||||
|
|
@ -168,11 +153,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
const clamp = (i: number) => Math.max(0, Math.min(count() - 1, i))
|
||||
|
||||
const pickFocus = (tab: number = store.tab) => {
|
||||
const list = questionOptions(questions()[tab])
|
||||
if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length
|
||||
const list = questions()[tab]?.options ?? []
|
||||
if (store.customOn[tab] === true) return list.length
|
||||
return Math.max(
|
||||
0,
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
|
||||
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -238,12 +223,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
}
|
||||
|
||||
const replyMutation = useMutation(() => ({
|
||||
mutationFn: (answers: QuestionAnswer[]) =>
|
||||
sdk().client.v2.session.form.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
formID: props.request.id,
|
||||
formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) },
|
||||
}),
|
||||
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
@ -255,7 +235,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
}))
|
||||
|
||||
const rejectMutation = useMutation(() => ({
|
||||
mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
|
||||
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
|
||||
onMutate: () => {
|
||||
props.onSubmit()
|
||||
},
|
||||
|
|
@ -282,7 +262,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const answered = (i: number) => {
|
||||
if ((store.answers[i]?.length ?? 0) > 0) return true
|
||||
return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
|
||||
}
|
||||
|
||||
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
|
||||
|
|
@ -303,7 +283,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const customToggle = () => {
|
||||
if (sending()) return
|
||||
if (!custom()) return
|
||||
setStore("focus", options().length)
|
||||
|
||||
if (!multi()) {
|
||||
|
|
@ -329,7 +308,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
|
||||
const customOpen = () => {
|
||||
if (sending()) return
|
||||
if (!custom()) return
|
||||
setStore("focus", options().length)
|
||||
if (!on()) setStore("customOn", store.tab, true)
|
||||
setStore("editing", true)
|
||||
|
|
@ -391,7 +369,6 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
if (sending()) return
|
||||
|
||||
if (optIndex === options().length) {
|
||||
if (!custom()) return
|
||||
customOpen()
|
||||
return
|
||||
}
|
||||
|
|
@ -400,10 +377,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
if (!opt) return
|
||||
if (multi()) {
|
||||
setStore("editing", false)
|
||||
toggle(opt.value)
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.value)
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
const commitCustom = () => {
|
||||
|
|
@ -549,24 +526,11 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
overflow: store.minimized ? "hidden" : undefined,
|
||||
}}
|
||||
>
|
||||
{body()}
|
||||
{question()?.question}
|
||||
</div>
|
||||
<Show when={message()}>
|
||||
<div data-slot="question-hint">{message()}</div>
|
||||
</Show>
|
||||
<Show when={!store.minimized}>
|
||||
<Show
|
||||
when={props.request.mode === "url"}
|
||||
fallback={
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
}
|
||||
>
|
||||
<div data-slot="question-hint">Open this URL, complete the request, then submit.</div>
|
||||
<a href={props.request.mode === "url" ? props.request.url : undefined} target="_blank" rel="noreferrer">
|
||||
{props.request.mode === "url" ? props.request.url : ""}
|
||||
</a>
|
||||
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
|
||||
<div data-slot="question-hint">{language.t("ui.question.multiHint")}</div>
|
||||
</Show>
|
||||
</Show>
|
||||
<div
|
||||
|
|
@ -584,7 +548,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
{(opt, i) => (
|
||||
<Option
|
||||
multi={multi()}
|
||||
picked={picked(opt.value)}
|
||||
picked={picked(opt.label)}
|
||||
label={opt.label}
|
||||
description={opt.description}
|
||||
disabled={sending()}
|
||||
|
|
@ -595,80 +559,78 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
|
|||
)}
|
||||
</For>
|
||||
|
||||
<Show when={custom()}>
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
<Show
|
||||
when={store.editing}
|
||||
fallback={
|
||||
<button
|
||||
type="button"
|
||||
ref={customRef}
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
disabled={sending()}
|
||||
onFocus={() => setStore("focus", options().length)}
|
||||
onClick={customOpen}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
<span data-slot="option-description">{input() || customPlaceholder()}</span>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<form
|
||||
data-slot="question-option"
|
||||
data-custom="true"
|
||||
data-picked={on()}
|
||||
role={multi() ? "checkbox" : "radio"}
|
||||
aria-checked={on()}
|
||||
onMouseDown={(e) => {
|
||||
if (sending()) {
|
||||
e.preventDefault()
|
||||
return
|
||||
}
|
||||
if (e.target instanceof HTMLTextAreaElement) return
|
||||
const input = e.currentTarget.querySelector('[data-slot="question-custom-input"]')
|
||||
if (input instanceof HTMLTextAreaElement) input.focus()
|
||||
}}
|
||||
onSubmit={(e) => {
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
>
|
||||
<Mark multi={multi()} picked={on()} onClick={toggleCustomMark} />
|
||||
<span data-slot="question-option-main">
|
||||
<span data-slot="option-label">{customLabel()}</span>
|
||||
<textarea
|
||||
ref={focusCustom}
|
||||
data-slot="question-custom-input"
|
||||
placeholder={customPlaceholder()}
|
||||
value={input()}
|
||||
rows={1}
|
||||
disabled={sending()}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault()
|
||||
setStore("editing", false)
|
||||
focus(options().length)
|
||||
return
|
||||
}
|
||||
if ((e.metaKey || e.ctrlKey) && !e.altKey) return
|
||||
if (e.key !== "Enter" || e.shiftKey) return
|
||||
e.preventDefault()
|
||||
commitCustom()
|
||||
}}
|
||||
onInput={(e) => {
|
||||
customUpdate(e.currentTarget.value)
|
||||
resizeInput(e.currentTarget)
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</form>
|
||||
</Show>
|
||||
</div>
|
||||
</DockPrompt>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
import type { QuestionForm } from "@/utils/question-form"
|
||||
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
|
||||
|
||||
function sessionTreeRequest<T>(
|
||||
session: Session[],
|
||||
|
|
@ -45,9 +44,9 @@ export function sessionPermissionRequest(
|
|||
|
||||
export function sessionQuestionRequest(
|
||||
session: Session[],
|
||||
request: Record<string, QuestionForm[] | undefined>,
|
||||
request: Record<string, QuestionRequest[] | undefined>,
|
||||
sessionID?: string,
|
||||
include?: (item: QuestionForm) => boolean,
|
||||
include?: (item: QuestionRequest) => boolean,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, include) ?? request.global?.find(include ?? (() => true))
|
||||
return sessionTreeRequest(session, request, sessionID, include)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,36 +0,0 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { questionAnswer, type QuestionField } from "./question-form"
|
||||
|
||||
describe("questionAnswer", () => {
|
||||
test("falls back to field defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "integer", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [[], [], [], []])).toEqual({
|
||||
name: "Ada",
|
||||
age: 42,
|
||||
newsletter: false,
|
||||
colors: ["red"],
|
||||
})
|
||||
})
|
||||
|
||||
test("uses explicit answers over defaults", () => {
|
||||
const fields = [
|
||||
{ key: "name", type: "string", default: "Ada" },
|
||||
{ key: "age", type: "number", default: 42 },
|
||||
{ key: "newsletter", type: "boolean", default: false },
|
||||
{ key: "colors", type: "multiselect", options: [], default: ["red"] },
|
||||
] satisfies QuestionField[]
|
||||
|
||||
expect(questionAnswer(fields, [["Grace"], ["36"], ["true"], ["blue"]])).toEqual({
|
||||
name: "Grace",
|
||||
age: 36,
|
||||
newsletter: true,
|
||||
colors: ["blue"],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
import type { FormAnswer, FormFormInfo, FormUrlInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export type QuestionOption = {
|
||||
value: string
|
||||
label: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export type QuestionField = FormFormInfo["fields"][number]
|
||||
|
||||
export type QuestionForm = FormFormInfo | FormUrlInfo
|
||||
|
||||
export type QuestionAnswer = string[]
|
||||
|
||||
export function isQuestionForm(value: unknown): value is QuestionForm {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const form = value as { id?: unknown; sessionID?: unknown; mode?: unknown; fields?: unknown; url?: unknown }
|
||||
if (typeof form.id !== "string" || typeof form.sessionID !== "string") return false
|
||||
if (form.mode === "url") return typeof form.url === "string"
|
||||
if (form.mode !== "form") return false
|
||||
return Array.isArray(form.fields) && form.fields.every(isQuestionField)
|
||||
}
|
||||
|
||||
function isQuestionField(value: unknown): value is QuestionField {
|
||||
if (typeof value !== "object" || value === null) return false
|
||||
const field = value as { type?: unknown }
|
||||
return ["string", "number", "integer", "boolean", "multiselect"].includes(String(field.type))
|
||||
}
|
||||
|
||||
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>): FormAnswer {
|
||||
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, FormAnswer[string]]> => {
|
||||
const answer = answers[index] ?? []
|
||||
if (answer.length === 0) {
|
||||
if (field.default === undefined) return []
|
||||
if (field.type === "multiselect") return [[field.key, [...field.default]]]
|
||||
return [[field.key, field.default]]
|
||||
}
|
||||
if (field.type === "multiselect") return [[field.key, answer]]
|
||||
if (field.type === "boolean") return [[field.key, answer[0] === "true"]]
|
||||
if (field.type === "number" || field.type === "integer") return [[field.key, Number(answer[0])]]
|
||||
return [[field.key, answer[0] ?? ""]]
|
||||
})
|
||||
return Object.fromEntries(entries)
|
||||
}
|
||||
|
||||
export function questionOptions(field: QuestionField | undefined): QuestionOption[] {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: "false", label: "No" },
|
||||
{ value: "true", label: "Yes" },
|
||||
]
|
||||
if (field.type === "string") return field.options ?? []
|
||||
if (field.type === "multiselect") return field.options
|
||||
return []
|
||||
}
|
||||
|
||||
export function questionAllowsCustom(field: QuestionField | undefined) {
|
||||
if (!field) return false
|
||||
if (field.type === "number" || field.type === "integer") return true
|
||||
if (field.type !== "string" && field.type !== "multiselect") return false
|
||||
return questionOptions(field).length === 0 || field.custom === true
|
||||
}
|
||||
|
||||
export function questionLabel(field: QuestionField | undefined) {
|
||||
return field?.title ?? field?.description ?? field?.key ?? "Form"
|
||||
}
|
||||
|
||||
export function questionMessage(form: QuestionForm) {
|
||||
const message = form.metadata?.message
|
||||
return typeof message === "string" && message !== form.title ? message : undefined
|
||||
}
|
||||
|
|
@ -7,7 +7,6 @@ export { Credential } from "@opencode-ai/schema/credential"
|
|||
export { Event } from "@opencode-ai/schema/event"
|
||||
export { EventLog } from "@opencode-ai/schema/event-log"
|
||||
export { FileSystem } from "@opencode-ai/schema/filesystem"
|
||||
export { Form } from "@opencode-ai/schema/form"
|
||||
export { Integration } from "@opencode-ai/schema/integration"
|
||||
export { Location } from "@opencode-ai/schema/location"
|
||||
export { Model } from "@opencode-ai/schema/model"
|
||||
|
|
@ -17,6 +16,7 @@ export { Project } from "@opencode-ai/schema/project"
|
|||
export { ProjectCopy } from "@opencode-ai/schema/project-copy"
|
||||
export { Provider } from "@opencode-ai/schema/provider"
|
||||
export { Pty } from "@opencode-ai/schema/pty"
|
||||
export { Question } from "@opencode-ai/schema/question"
|
||||
export { Reference } from "@opencode-ai/schema/reference"
|
||||
export { AbsolutePath, RelativePath } from "@opencode-ai/schema/schema"
|
||||
export { Session } from "@opencode-ai/schema/session"
|
||||
|
|
|
|||
|
|
@ -552,136 +552,36 @@ const adaptGroup12 = (raw: RawClient["server.project"]) => ({
|
|||
directories: Endpoint12_1(raw),
|
||||
})
|
||||
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
|
||||
const Endpoint13_0 = (raw: RawClient["server.form"]) => (input?: Endpoint13_0Input) =>
|
||||
raw["form.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
|
||||
type Endpoint13_1Input = {
|
||||
readonly sessionID: Endpoint13_1Request["params"]["sessionID"]
|
||||
readonly location?: Endpoint13_1Request["query"]["location"]
|
||||
}
|
||||
const Endpoint13_1 = (raw: RawClient["server.form"]) => (input: Endpoint13_1Input) =>
|
||||
raw["session.form.list"]({ params: { sessionID: input["sessionID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.create"]>[0]
|
||||
type Endpoint13_2Input = {
|
||||
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
||||
readonly location?: Endpoint13_2Request["query"]["location"]
|
||||
readonly id?: Endpoint13_2Request["payload"]["id"]
|
||||
readonly title?: Endpoint13_2Request["payload"]["title"]
|
||||
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
||||
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
||||
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
||||
readonly url?: Endpoint13_2Request["payload"]["url"]
|
||||
}
|
||||
const Endpoint13_2 = (raw: RawClient["server.form"]) => (input: Endpoint13_2Input) =>
|
||||
raw["session.form.create"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: {
|
||||
id: input["id"],
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
mode: input["mode"],
|
||||
fields: input["fields"],
|
||||
url: input["url"],
|
||||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint13_3Request = Parameters<RawClient["server.form"]["session.form.get"]>[0]
|
||||
type Endpoint13_3Input = {
|
||||
readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_3Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_3Request["query"]["location"]
|
||||
}
|
||||
const Endpoint13_3 = (raw: RawClient["server.form"]) => (input: Endpoint13_3Input) =>
|
||||
raw["session.form.get"]({
|
||||
params: { sessionID: input["sessionID"], formID: input["formID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint13_4Request = Parameters<RawClient["server.form"]["session.form.state"]>[0]
|
||||
type Endpoint13_4Input = {
|
||||
readonly sessionID: Endpoint13_4Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_4Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint13_4 = (raw: RawClient["server.form"]) => (input: Endpoint13_4Input) =>
|
||||
raw["session.form.state"]({
|
||||
params: { sessionID: input["sessionID"], formID: input["formID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint13_5Request = Parameters<RawClient["server.form"]["session.form.reply"]>[0]
|
||||
type Endpoint13_5Input = {
|
||||
readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_5Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_5Request["query"]["location"]
|
||||
readonly answer: Endpoint13_5Request["payload"]["answer"]
|
||||
}
|
||||
const Endpoint13_5 = (raw: RawClient["server.form"]) => (input: Endpoint13_5Input) =>
|
||||
raw["session.form.reply"]({
|
||||
params: { sessionID: input["sessionID"], formID: input["formID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { answer: input["answer"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint13_6Request = Parameters<RawClient["server.form"]["session.form.cancel"]>[0]
|
||||
type Endpoint13_6Input = {
|
||||
readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_6Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_6Request["query"]["location"]
|
||||
}
|
||||
const Endpoint13_6 = (raw: RawClient["server.form"]) => (input: Endpoint13_6Input) =>
|
||||
raw["session.form.cancel"]({
|
||||
params: { sessionID: input["sessionID"], formID: input["formID"] },
|
||||
query: { location: input["location"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup13 = (raw: RawClient["server.form"]) => ({
|
||||
listRequests: Endpoint13_0(raw),
|
||||
list: Endpoint13_1(raw),
|
||||
create: Endpoint13_2(raw),
|
||||
get: Endpoint13_3(raw),
|
||||
state: Endpoint13_4(raw),
|
||||
reply: Endpoint13_5(raw),
|
||||
cancel: Endpoint13_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||
const Endpoint14_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_0Input) =>
|
||||
const Endpoint13_0 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_0Input) =>
|
||||
raw["permission.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint14_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] }
|
||||
const Endpoint14_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint14_1Input) =>
|
||||
type Endpoint13_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
type Endpoint13_1Input = { readonly projectID?: Endpoint13_1Request["query"]["projectID"] }
|
||||
const Endpoint13_1 = (raw: RawClient["server.permission"]) => (input?: Endpoint13_1Input) =>
|
||||
raw["permission.saved.list"]({ query: { projectID: input?.["projectID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
|
||||
const Endpoint14_2 = (raw: RawClient["server.permission"]) => (input: Endpoint14_2Input) =>
|
||||
type Endpoint13_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
type Endpoint13_2Input = { readonly id: Endpoint13_2Request["params"]["id"] }
|
||||
const Endpoint13_2 = (raw: RawClient["server.permission"]) => (input: Endpoint13_2Input) =>
|
||||
raw["permission.saved.remove"]({ params: { id: input["id"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint14_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
type Endpoint14_3Input = {
|
||||
readonly sessionID: Endpoint14_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint14_3Request["payload"]["id"]
|
||||
readonly action: Endpoint14_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint14_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint14_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint14_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint14_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint14_3Request["payload"]["agent"]
|
||||
type Endpoint13_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
type Endpoint13_3Input = {
|
||||
readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint13_3Request["payload"]["id"]
|
||||
readonly action: Endpoint13_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint13_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint13_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint13_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint13_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint13_3Request["payload"]["agent"]
|
||||
}
|
||||
const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14_3Input) =>
|
||||
const Endpoint13_3 = (raw: RawClient["server.permission"]) => (input: Endpoint13_3Input) =>
|
||||
raw["session.permission.create"]({
|
||||
params: { sessionID: input["sessionID"] },
|
||||
payload: {
|
||||
|
|
@ -698,87 +598,87 @@ const Endpoint14_3 = (raw: RawClient["server.permission"]) => (input: Endpoint14
|
|||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint14_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] }
|
||||
const Endpoint14_4 = (raw: RawClient["server.permission"]) => (input: Endpoint14_4Input) =>
|
||||
type Endpoint13_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
type Endpoint13_4Input = { readonly sessionID: Endpoint13_4Request["params"]["sessionID"] }
|
||||
const Endpoint13_4 = (raw: RawClient["server.permission"]) => (input: Endpoint13_4Input) =>
|
||||
raw["session.permission.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint14_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
type Endpoint14_5Input = {
|
||||
readonly sessionID: Endpoint14_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint14_5Request["params"]["requestID"]
|
||||
type Endpoint13_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
type Endpoint13_5Input = {
|
||||
readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint13_5Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint14_5 = (raw: RawClient["server.permission"]) => (input: Endpoint14_5Input) =>
|
||||
const Endpoint13_5 = (raw: RawClient["server.permission"]) => (input: Endpoint13_5Input) =>
|
||||
raw["session.permission.get"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint14_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
type Endpoint14_6Input = {
|
||||
readonly sessionID: Endpoint14_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint14_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint14_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint14_6Request["payload"]["message"]
|
||||
type Endpoint13_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
type Endpoint13_6Input = {
|
||||
readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint13_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint13_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint13_6Request["payload"]["message"]
|
||||
}
|
||||
const Endpoint14_6 = (raw: RawClient["server.permission"]) => (input: Endpoint14_6Input) =>
|
||||
const Endpoint13_6 = (raw: RawClient["server.permission"]) => (input: Endpoint13_6Input) =>
|
||||
raw["session.permission.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { reply: input["reply"], message: input["message"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup14 = (raw: RawClient["server.permission"]) => ({
|
||||
listRequests: Endpoint14_0(raw),
|
||||
listSaved: Endpoint14_1(raw),
|
||||
removeSaved: Endpoint14_2(raw),
|
||||
create: Endpoint14_3(raw),
|
||||
list: Endpoint14_4(raw),
|
||||
get: Endpoint14_5(raw),
|
||||
reply: Endpoint14_6(raw),
|
||||
const adaptGroup13 = (raw: RawClient["server.permission"]) => ({
|
||||
listRequests: Endpoint13_0(raw),
|
||||
listSaved: Endpoint13_1(raw),
|
||||
removeSaved: Endpoint13_2(raw),
|
||||
create: Endpoint13_3(raw),
|
||||
list: Endpoint13_4(raw),
|
||||
get: Endpoint13_5(raw),
|
||||
reply: Endpoint13_6(raw),
|
||||
})
|
||||
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
type Endpoint15_0Input = {
|
||||
readonly location?: Endpoint15_0Request["query"]["location"]
|
||||
readonly path?: Endpoint15_0Request["query"]["path"]
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
type Endpoint14_0Input = {
|
||||
readonly location?: Endpoint14_0Request["query"]["location"]
|
||||
readonly path?: Endpoint14_0Request["query"]["path"]
|
||||
}
|
||||
const Endpoint15_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint15_0Input) =>
|
||||
const Endpoint14_0 = (raw: RawClient["server.fs"]) => (input?: Endpoint14_0Input) =>
|
||||
raw["fs.list"]({ query: { location: input?.["location"], path: input?.["path"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint15_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
type Endpoint15_1Input = {
|
||||
readonly location?: Endpoint15_1Request["query"]["location"]
|
||||
readonly query: Endpoint15_1Request["query"]["query"]
|
||||
readonly type?: Endpoint15_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint15_1Request["query"]["limit"]
|
||||
type Endpoint14_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
type Endpoint14_1Input = {
|
||||
readonly location?: Endpoint14_1Request["query"]["location"]
|
||||
readonly query: Endpoint14_1Request["query"]["query"]
|
||||
readonly type?: Endpoint14_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint14_1Request["query"]["limit"]
|
||||
}
|
||||
const Endpoint15_1 = (raw: RawClient["server.fs"]) => (input: Endpoint15_1Input) =>
|
||||
const Endpoint14_1 = (raw: RawClient["server.fs"]) => (input: Endpoint14_1Input) =>
|
||||
raw["fs.find"]({
|
||||
query: { location: input["location"], query: input["query"], type: input["type"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup15 = (raw: RawClient["server.fs"]) => ({ list: Endpoint15_0(raw), find: Endpoint15_1(raw) })
|
||||
const adaptGroup14 = (raw: RawClient["server.fs"]) => ({ list: Endpoint14_0(raw), find: Endpoint14_1(raw) })
|
||||
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
const Endpoint16_0 = (raw: RawClient["server.command"]) => (input?: Endpoint16_0Input) =>
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
|
||||
const Endpoint15_0 = (raw: RawClient["server.command"]) => (input?: Endpoint15_0Input) =>
|
||||
raw["command.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup16 = (raw: RawClient["server.command"]) => ({ list: Endpoint16_0(raw) })
|
||||
const adaptGroup15 = (raw: RawClient["server.command"]) => ({ list: Endpoint15_0(raw) })
|
||||
|
||||
type Endpoint17_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
|
||||
const Endpoint17_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint17_0Input) =>
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
const Endpoint16_0 = (raw: RawClient["server.skill"]) => (input?: Endpoint16_0Input) =>
|
||||
raw["skill.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
const adaptGroup17 = (raw: RawClient["server.skill"]) => ({ list: Endpoint17_0(raw) })
|
||||
const adaptGroup16 = (raw: RawClient["server.skill"]) => ({ list: Endpoint16_0(raw) })
|
||||
|
||||
const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
|
||||
const Endpoint17_0 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.subscribe"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
|
|
@ -786,7 +686,7 @@ const Endpoint18_0 = (raw: RawClient["server.event"]) => () =>
|
|||
),
|
||||
)
|
||||
|
||||
const Endpoint18_1 = (raw: RawClient["server.event"]) => () =>
|
||||
const Endpoint17_1 = (raw: RawClient["server.event"]) => () =>
|
||||
Stream.unwrap(
|
||||
raw["event.changes"]({}).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
|
|
@ -794,23 +694,23 @@ const Endpoint18_1 = (raw: RawClient["server.event"]) => () =>
|
|||
),
|
||||
)
|
||||
|
||||
const adaptGroup18 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint18_0(raw), changes: Endpoint18_1(raw) })
|
||||
const adaptGroup17 = (raw: RawClient["server.event"]) => ({ subscribe: Endpoint17_0(raw), changes: Endpoint17_1(raw) })
|
||||
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
const Endpoint19_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_0Input) =>
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||
const Endpoint18_0 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_0Input) =>
|
||||
raw["pty.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint19_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
type Endpoint19_1Input = {
|
||||
readonly location?: Endpoint19_1Request["query"]["location"]
|
||||
readonly command?: Endpoint19_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint19_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint19_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint19_1Request["payload"]["env"]
|
||||
type Endpoint18_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
type Endpoint18_1Input = {
|
||||
readonly location?: Endpoint18_1Request["query"]["location"]
|
||||
readonly command?: Endpoint18_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint18_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint18_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint18_1Request["payload"]["env"]
|
||||
}
|
||||
const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Input) =>
|
||||
const Endpoint18_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint18_1Input) =>
|
||||
raw["pty.create"]({
|
||||
query: { location: input?.["location"] },
|
||||
payload: {
|
||||
|
|
@ -822,106 +722,148 @@ const Endpoint19_1 = (raw: RawClient["server.pty"]) => (input?: Endpoint19_1Inpu
|
|||
},
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint19_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
type Endpoint19_2Input = {
|
||||
readonly ptyID: Endpoint19_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_2Request["query"]["location"]
|
||||
type Endpoint18_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
type Endpoint18_2Input = {
|
||||
readonly ptyID: Endpoint18_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint19_2 = (raw: RawClient["server.pty"]) => (input: Endpoint19_2Input) =>
|
||||
const Endpoint18_2 = (raw: RawClient["server.pty"]) => (input: Endpoint18_2Input) =>
|
||||
raw["pty.get"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint19_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
type Endpoint19_3Input = {
|
||||
readonly ptyID: Endpoint19_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_3Request["query"]["location"]
|
||||
readonly title?: Endpoint19_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint19_3Request["payload"]["size"]
|
||||
type Endpoint18_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
type Endpoint18_3Input = {
|
||||
readonly ptyID: Endpoint18_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_3Request["query"]["location"]
|
||||
readonly title?: Endpoint18_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint18_3Request["payload"]["size"]
|
||||
}
|
||||
const Endpoint19_3 = (raw: RawClient["server.pty"]) => (input: Endpoint19_3Input) =>
|
||||
const Endpoint18_3 = (raw: RawClient["server.pty"]) => (input: Endpoint18_3Input) =>
|
||||
raw["pty.update"]({
|
||||
params: { ptyID: input["ptyID"] },
|
||||
query: { location: input["location"] },
|
||||
payload: { title: input["title"], size: input["size"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint19_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
type Endpoint19_4Input = {
|
||||
readonly ptyID: Endpoint19_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_4Request["query"]["location"]
|
||||
type Endpoint18_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
type Endpoint18_4Input = {
|
||||
readonly ptyID: Endpoint18_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint19_4 = (raw: RawClient["server.pty"]) => (input: Endpoint19_4Input) =>
|
||||
const Endpoint18_4 = (raw: RawClient["server.pty"]) => (input: Endpoint18_4Input) =>
|
||||
raw["pty.remove"]({ params: { ptyID: input["ptyID"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup19 = (raw: RawClient["server.pty"]) => ({
|
||||
list: Endpoint19_0(raw),
|
||||
create: Endpoint19_1(raw),
|
||||
get: Endpoint19_2(raw),
|
||||
update: Endpoint19_3(raw),
|
||||
remove: Endpoint19_4(raw),
|
||||
const adaptGroup18 = (raw: RawClient["server.pty"]) => ({
|
||||
list: Endpoint18_0(raw),
|
||||
create: Endpoint18_1(raw),
|
||||
get: Endpoint18_2(raw),
|
||||
update: Endpoint18_3(raw),
|
||||
remove: Endpoint18_4(raw),
|
||||
})
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
|
||||
const Endpoint20_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint20_0Input) =>
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
const Endpoint19_0 = (raw: RawClient["server.shell"]) => (input?: Endpoint19_0Input) =>
|
||||
raw["shell.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint20_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
type Endpoint20_1Input = {
|
||||
readonly location?: Endpoint20_1Request["query"]["location"]
|
||||
readonly command: Endpoint20_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint20_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
|
||||
type Endpoint19_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
type Endpoint19_1Input = {
|
||||
readonly location?: Endpoint19_1Request["query"]["location"]
|
||||
readonly command: Endpoint19_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint19_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint19_1Request["payload"]["metadata"]
|
||||
}
|
||||
const Endpoint20_1 = (raw: RawClient["server.shell"]) => (input: Endpoint20_1Input) =>
|
||||
const Endpoint19_1 = (raw: RawClient["server.shell"]) => (input: Endpoint19_1Input) =>
|
||||
raw["shell.create"]({
|
||||
query: { location: input["location"] },
|
||||
payload: { command: input["command"], cwd: input["cwd"], timeout: input["timeout"], metadata: input["metadata"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint20_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
type Endpoint20_2Input = {
|
||||
readonly id: Endpoint20_2Request["params"]["id"]
|
||||
readonly location?: Endpoint20_2Request["query"]["location"]
|
||||
type Endpoint19_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
type Endpoint19_2Input = {
|
||||
readonly id: Endpoint19_2Request["params"]["id"]
|
||||
readonly location?: Endpoint19_2Request["query"]["location"]
|
||||
}
|
||||
const Endpoint20_2 = (raw: RawClient["server.shell"]) => (input: Endpoint20_2Input) =>
|
||||
const Endpoint19_2 = (raw: RawClient["server.shell"]) => (input: Endpoint19_2Input) =>
|
||||
raw["shell.get"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
type Endpoint20_3Input = {
|
||||
readonly id: Endpoint20_3Request["params"]["id"]
|
||||
readonly location?: Endpoint20_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint20_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint20_3Request["query"]["limit"]
|
||||
type Endpoint19_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
type Endpoint19_3Input = {
|
||||
readonly id: Endpoint19_3Request["params"]["id"]
|
||||
readonly location?: Endpoint19_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint19_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint19_3Request["query"]["limit"]
|
||||
}
|
||||
const Endpoint20_3 = (raw: RawClient["server.shell"]) => (input: Endpoint20_3Input) =>
|
||||
const Endpoint19_3 = (raw: RawClient["server.shell"]) => (input: Endpoint19_3Input) =>
|
||||
raw["shell.output"]({
|
||||
params: { id: input["id"] },
|
||||
query: { location: input["location"], cursor: input["cursor"], limit: input["limit"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
type Endpoint20_4Input = {
|
||||
readonly id: Endpoint20_4Request["params"]["id"]
|
||||
readonly location?: Endpoint20_4Request["query"]["location"]
|
||||
type Endpoint19_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
type Endpoint19_4Input = {
|
||||
readonly id: Endpoint19_4Request["params"]["id"]
|
||||
readonly location?: Endpoint19_4Request["query"]["location"]
|
||||
}
|
||||
const Endpoint20_4 = (raw: RawClient["server.shell"]) => (input: Endpoint20_4Input) =>
|
||||
const Endpoint19_4 = (raw: RawClient["server.shell"]) => (input: Endpoint19_4Input) =>
|
||||
raw["shell.remove"]({ params: { id: input["id"] }, query: { location: input["location"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup20 = (raw: RawClient["server.shell"]) => ({
|
||||
list: Endpoint20_0(raw),
|
||||
create: Endpoint20_1(raw),
|
||||
get: Endpoint20_2(raw),
|
||||
output: Endpoint20_3(raw),
|
||||
remove: Endpoint20_4(raw),
|
||||
const adaptGroup19 = (raw: RawClient["server.shell"]) => ({
|
||||
list: Endpoint19_0(raw),
|
||||
create: Endpoint19_1(raw),
|
||||
get: Endpoint19_2(raw),
|
||||
output: Endpoint19_3(raw),
|
||||
remove: Endpoint19_4(raw),
|
||||
})
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||
type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
|
||||
const Endpoint20_0 = (raw: RawClient["server.question"]) => (input?: Endpoint20_0Input) =>
|
||||
raw["question.request.list"]({ query: { location: input?.["location"] } }).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint20_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||
type Endpoint20_1Input = { readonly sessionID: Endpoint20_1Request["params"]["sessionID"] }
|
||||
const Endpoint20_1 = (raw: RawClient["server.question"]) => (input: Endpoint20_1Input) =>
|
||||
raw["session.question.list"]({ params: { sessionID: input["sessionID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
Effect.map((value) => value.data),
|
||||
)
|
||||
|
||||
type Endpoint20_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||
type Endpoint20_2Input = {
|
||||
readonly sessionID: Endpoint20_2Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint20_2Request["params"]["requestID"]
|
||||
readonly answers: Endpoint20_2Request["payload"]["answers"]
|
||||
}
|
||||
const Endpoint20_2 = (raw: RawClient["server.question"]) => (input: Endpoint20_2Input) =>
|
||||
raw["session.question.reply"]({
|
||||
params: { sessionID: input["sessionID"], requestID: input["requestID"] },
|
||||
payload: { answers: input["answers"] },
|
||||
}).pipe(Effect.mapError(mapClientError))
|
||||
|
||||
type Endpoint20_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||
type Endpoint20_3Input = {
|
||||
readonly sessionID: Endpoint20_3Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint20_3Request["params"]["requestID"]
|
||||
}
|
||||
const Endpoint20_3 = (raw: RawClient["server.question"]) => (input: Endpoint20_3Input) =>
|
||||
raw["session.question.reject"]({ params: { sessionID: input["sessionID"], requestID: input["requestID"] } }).pipe(
|
||||
Effect.mapError(mapClientError),
|
||||
)
|
||||
|
||||
const adaptGroup20 = (raw: RawClient["server.question"]) => ({
|
||||
listRequests: Endpoint20_0(raw),
|
||||
list: Endpoint20_1(raw),
|
||||
reply: Endpoint20_2(raw),
|
||||
reject: Endpoint20_3(raw),
|
||||
})
|
||||
|
||||
type Endpoint21_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||
|
|
@ -991,14 +933,14 @@ const adaptClient = (raw: RawClient) => ({
|
|||
"server.mcp": adaptGroup10(raw["server.mcp"]),
|
||||
credential: adaptGroup11(raw["server.credential"]),
|
||||
project: adaptGroup12(raw["server.project"]),
|
||||
form: adaptGroup13(raw["server.form"]),
|
||||
permission: adaptGroup14(raw["server.permission"]),
|
||||
file: adaptGroup15(raw["server.fs"]),
|
||||
command: adaptGroup16(raw["server.command"]),
|
||||
skill: adaptGroup17(raw["server.skill"]),
|
||||
event: adaptGroup18(raw["server.event"]),
|
||||
pty: adaptGroup19(raw["server.pty"]),
|
||||
shell: adaptGroup20(raw["server.shell"]),
|
||||
permission: adaptGroup13(raw["server.permission"]),
|
||||
file: adaptGroup14(raw["server.fs"]),
|
||||
command: adaptGroup15(raw["server.command"]),
|
||||
skill: adaptGroup16(raw["server.skill"]),
|
||||
event: adaptGroup17(raw["server.event"]),
|
||||
pty: adaptGroup18(raw["server.pty"]),
|
||||
shell: adaptGroup19(raw["server.shell"]),
|
||||
question: adaptGroup20(raw["server.question"]),
|
||||
reference: adaptGroup21(raw["server.reference"]),
|
||||
projectCopy: adaptGroup22(raw["server.projectCopy"]),
|
||||
})
|
||||
|
|
|
|||
|
|
@ -91,20 +91,6 @@ import type {
|
|||
ProjectCurrentOutput,
|
||||
ProjectDirectoriesInput,
|
||||
ProjectDirectoriesOutput,
|
||||
FormListRequestsInput,
|
||||
FormListRequestsOutput,
|
||||
FormListInput,
|
||||
FormListOutput,
|
||||
FormCreateInput,
|
||||
FormCreateOutput,
|
||||
FormGetInput,
|
||||
FormGetOutput,
|
||||
FormStateInput,
|
||||
FormStateOutput,
|
||||
FormReplyInput,
|
||||
FormReplyOutput,
|
||||
FormCancelInput,
|
||||
FormCancelOutput,
|
||||
PermissionListRequestsInput,
|
||||
PermissionListRequestsOutput,
|
||||
PermissionListSavedInput,
|
||||
|
|
@ -151,6 +137,14 @@ import type {
|
|||
ShellOutputOutput,
|
||||
ShellRemoveInput,
|
||||
ShellRemoveOutput,
|
||||
QuestionListRequestsInput,
|
||||
QuestionListRequestsOutput,
|
||||
QuestionListInput,
|
||||
QuestionListOutput,
|
||||
QuestionReplyInput,
|
||||
QuestionReplyOutput,
|
||||
QuestionRejectInput,
|
||||
QuestionRejectOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ProjectCopyCreateInput,
|
||||
|
|
@ -897,101 +891,6 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
form: {
|
||||
listRequests: (input?: FormListRequestsInput, requestOptions?: RequestOptions) =>
|
||||
request<FormListRequestsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/form/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
list: (input: FormListInput, requestOptions?: RequestOptions) =>
|
||||
request<FormListOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [400, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
create: (input: FormCreateInput, requestOptions?: RequestOptions) =>
|
||||
request<FormCreateOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form`,
|
||||
query: { location: input["location"] },
|
||||
body: {
|
||||
id: input["id"],
|
||||
title: input["title"],
|
||||
metadata: input["metadata"],
|
||||
mode: input["mode"],
|
||||
fields: input["fields"],
|
||||
url: input["url"],
|
||||
},
|
||||
successStatus: 200,
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
get: (input: FormGetInput, requestOptions?: RequestOptions) =>
|
||||
request<FormGetOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
state: (input: FormStateInput, requestOptions?: RequestOptions) =>
|
||||
request<FormStateOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/state`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reply: (input: FormReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<FormReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/reply`,
|
||||
query: { location: input["location"] },
|
||||
body: { answer: input["answer"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 400, 404, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
cancel: (input: FormCancelInput, requestOptions?: RequestOptions) =>
|
||||
request<FormCancelOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/form/${encodeURIComponent(input.formID)}/cancel`,
|
||||
query: { location: input["location"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [409, 404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
permission: {
|
||||
listRequests: (input?: PermissionListRequestsInput, requestOptions?: RequestOptions) =>
|
||||
request<PermissionListRequestsOutput>(
|
||||
|
|
@ -1300,6 +1199,54 @@ export function make(options: ClientOptions) {
|
|||
requestOptions,
|
||||
),
|
||||
},
|
||||
question: {
|
||||
listRequests: (input?: QuestionListRequestsInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionListRequestsOutput>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/question/request`,
|
||||
query: { location: input?.["location"] },
|
||||
successStatus: 200,
|
||||
declaredStatuses: [401, 400],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
list: (input: QuestionListInput, requestOptions?: RequestOptions) =>
|
||||
request<{ readonly data: QuestionListOutput }>(
|
||||
{
|
||||
method: "GET",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question`,
|
||||
successStatus: 200,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: false,
|
||||
},
|
||||
requestOptions,
|
||||
).then((value) => value.data),
|
||||
reply: (input: QuestionReplyInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionReplyOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reply`,
|
||||
body: { answers: input["answers"] },
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
reject: (input: QuestionRejectInput, requestOptions?: RequestOptions) =>
|
||||
request<QuestionRejectOutput>(
|
||||
{
|
||||
method: "POST",
|
||||
path: `/api/session/${encodeURIComponent(input.sessionID)}/question/${encodeURIComponent(input.requestID)}/reject`,
|
||||
successStatus: 204,
|
||||
declaredStatuses: [404, 400, 401],
|
||||
empty: true,
|
||||
},
|
||||
requestOptions,
|
||||
),
|
||||
},
|
||||
reference: {
|
||||
list: (input?: ReferenceListInput, requestOptions?: RequestOptions) =>
|
||||
request<ReferenceListOutput>(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -18,7 +18,6 @@ test("exposes every standard HTTP API group", () => {
|
|||
"server.mcp",
|
||||
"credential",
|
||||
"project",
|
||||
"form",
|
||||
"permission",
|
||||
"file",
|
||||
"command",
|
||||
|
|
@ -26,6 +25,7 @@ test("exposes every standard HTTP API group", () => {
|
|||
"event",
|
||||
"pty",
|
||||
"shell",
|
||||
"question",
|
||||
"reference",
|
||||
"projectCopy",
|
||||
])
|
||||
|
|
@ -43,53 +43,6 @@ test("exposes every standard HTTP API group", () => {
|
|||
expect(Object.keys(client.pty)).toEqual(["list", "create", "get", "update", "remove"])
|
||||
expect(Object.keys(client.shell)).toEqual(["list", "create", "get", "output", "remove"])
|
||||
expect(Object.keys(client.project)).toEqual(["current", "directories"])
|
||||
expect(Object.keys(client.form)).toEqual(["listRequests", "list", "create", "get", "state", "reply", "cancel"])
|
||||
})
|
||||
|
||||
test("form methods use the public HTTP contract", async () => {
|
||||
const requests: Array<{ method: string; url: string }> = []
|
||||
const form = {
|
||||
id: "frm_test",
|
||||
sessionID: "ses_test",
|
||||
mode: "form",
|
||||
fields: [{ key: "choice", type: "string", options: [{ value: "yes", label: "Yes" }] }],
|
||||
}
|
||||
const client = OpenCode.make({
|
||||
baseUrl: "http://localhost:3000",
|
||||
fetch: async (input, init) => {
|
||||
const request = input instanceof Request ? input : new Request(input, init)
|
||||
requests.push({ method: request.method, url: request.url })
|
||||
if (request.url.endsWith("/reply") || request.url.endsWith("/cancel")) return new Response(null, { status: 204 })
|
||||
if (request.url.endsWith("/state")) return Response.json({ data: { status: "pending" } })
|
||||
if (request.url.includes("/form/frm_test")) return Response.json({ data: form })
|
||||
if (request.method === "POST") return Response.json({ data: form })
|
||||
return Response.json({
|
||||
location: { directory: "/tmp/project", project: { id: "proj_test", directory: "/tmp/project" } },
|
||||
data: [form],
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
await client.form.listRequests({ location: { directory: "/tmp/project" } })
|
||||
await client.form.list({ sessionID: "ses_test", location: { directory: "/tmp/project" } })
|
||||
await client.form.create({ sessionID: "ses_test", mode: "form", fields: form.fields })
|
||||
await client.form.get({ sessionID: "ses_test", formID: "frm_test" })
|
||||
await client.form.state({ sessionID: "ses_test", formID: "frm_test" })
|
||||
await client.form.reply({ sessionID: "ses_test", formID: "frm_test", answer: { choice: "yes" } })
|
||||
await client.form.cancel({ sessionID: "ses_test", formID: "frm_test" })
|
||||
|
||||
expect(requests).toEqual([
|
||||
{ method: "GET", url: "http://localhost:3000/api/form/request?location%5Bdirectory%5D=%2Ftmp%2Fproject" },
|
||||
{
|
||||
method: "GET",
|
||||
url: "http://localhost:3000/api/session/ses_test/form?location%5Bdirectory%5D=%2Ftmp%2Fproject",
|
||||
},
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/form" },
|
||||
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/form/frm_test" },
|
||||
{ method: "GET", url: "http://localhost:3000/api/session/ses_test/form/frm_test/state" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/form/frm_test/reply" },
|
||||
{ method: "POST", url: "http://localhost:3000/api/session/ses_test/form/frm_test/cancel" },
|
||||
])
|
||||
})
|
||||
|
||||
test("file.read returns binary content from the public HTTP contract", async () => {
|
||||
|
|
|
|||
|
|
@ -1,308 +0,0 @@
|
|||
export * as Form from "./form"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
|
||||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { EventV2 } from "./event"
|
||||
|
||||
const RETENTION = Duration.minutes(10)
|
||||
|
||||
export const ID = Form.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Info = Form.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Field = Form.Field
|
||||
export type Field = Form.Field
|
||||
|
||||
export const State = Form.State
|
||||
export type State = typeof State.Type
|
||||
|
||||
export const Answer = Form.Answer
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Form.Reply
|
||||
export type Reply = typeof Reply.Type
|
||||
|
||||
export const Event = Form.Event
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("Form.NotFoundError", {
|
||||
id: ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Form not found: ${this.id}`
|
||||
}
|
||||
}
|
||||
|
||||
export class AlreadySettledError extends Schema.TaggedErrorClass<AlreadySettledError>()("Form.AlreadySettledError", {
|
||||
id: ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Form already settled: ${this.id}`
|
||||
}
|
||||
}
|
||||
|
||||
export class AlreadyExistsError extends Schema.TaggedErrorClass<AlreadyExistsError>()("Form.AlreadyExistsError", {
|
||||
id: ID,
|
||||
}) {
|
||||
override get message() {
|
||||
return `Form already exists: ${this.id}`
|
||||
}
|
||||
}
|
||||
|
||||
export class InvalidAnswerError extends Schema.TaggedErrorClass<InvalidAnswerError>()("Form.InvalidAnswerError", {
|
||||
id: ID,
|
||||
message: Schema.String,
|
||||
}) {}
|
||||
|
||||
export type CreateInput =
|
||||
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
|
||||
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly id: ID
|
||||
readonly answer: Answer
|
||||
}
|
||||
|
||||
export interface ListInput {
|
||||
readonly sessionID?: string
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly create: (input: CreateInput) => Effect.Effect<Info, AlreadyExistsError>
|
||||
readonly ask: (input: CreateInput) => Effect.Effect<State, AlreadyExistsError>
|
||||
readonly get: (id: ID) => Effect.Effect<Info, NotFoundError>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<ReadonlyArray<Info>>
|
||||
readonly state: (id: ID) => Effect.Effect<State, NotFoundError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, AlreadySettledError | InvalidAnswerError | NotFoundError>
|
||||
readonly cancel: (id: ID) => Effect.Effect<void, AlreadySettledError | NotFoundError>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/Form") {}
|
||||
|
||||
interface Entry {
|
||||
readonly form: Info
|
||||
readonly state: State
|
||||
readonly deferred: Deferred.Deferred<State>
|
||||
}
|
||||
|
||||
export const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const forms = yield* Cache.makeWith<ID, Entry>(() => Effect.die("Form cache must be used via set/getSuccess, never get"), {
|
||||
capacity: Number.MAX_SAFE_INTEGER,
|
||||
timeToLive: (exit) => (Exit.isSuccess(exit) && exit.value.state.status === "pending" ? Duration.infinity : RETENTION),
|
||||
})
|
||||
|
||||
const find = Effect.fn("Form.find")(function* (id: ID) {
|
||||
return yield* Cache.getSuccess(forms, id).pipe(
|
||||
Effect.flatMap((entry) =>
|
||||
Option.match(entry, {
|
||||
onNone: () => Effect.fail(new NotFoundError({ id })),
|
||||
onSome: Effect.succeed,
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
const create = Effect.fn("Form.create")((input: CreateInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const id = input.id ?? ID.create()
|
||||
const existing = yield* Cache.getSuccess(forms, id)
|
||||
if (Option.isSome(existing)) return yield* new AlreadyExistsError({ id })
|
||||
const base = {
|
||||
id,
|
||||
sessionID: input.sessionID,
|
||||
title: input.title,
|
||||
...(input.metadata === undefined ? {} : { metadata: input.metadata }),
|
||||
}
|
||||
const form: Info =
|
||||
input.mode === "form"
|
||||
? { ...base, mode: "form", fields: input.fields }
|
||||
: {
|
||||
...base,
|
||||
mode: "url",
|
||||
url: input.url,
|
||||
}
|
||||
const entry: Entry = {
|
||||
form,
|
||||
state: { status: "pending" },
|
||||
deferred: yield* Deferred.make<State>(),
|
||||
}
|
||||
yield* Cache.set(forms, id, entry)
|
||||
yield* events.publish(Event.Created, { form }).pipe(Effect.onError(() => Cache.invalidate(forms, id)))
|
||||
return form
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("Form.ask")((input: CreateInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const form = yield* create(input)
|
||||
const entry = yield* find(form.id).pipe(Effect.orDie)
|
||||
return yield* restore(Deferred.await(entry.deferred)).pipe(Effect.onInterrupt(() => Effect.ignore(cancel(form.id))))
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const get = Effect.fn("Form.get")(function* (id: ID) {
|
||||
return (yield* find(id)).form
|
||||
})
|
||||
|
||||
const list = Effect.fn("Form.list")(function* (input?: ListInput) {
|
||||
const entries = yield* Cache.values(forms)
|
||||
return Array.from(entries)
|
||||
.filter((entry) => entry.state.status === "pending")
|
||||
.filter((entry) => input?.sessionID === undefined || entry.form.sessionID === input.sessionID)
|
||||
.map((entry) => entry.form)
|
||||
})
|
||||
|
||||
const state = Effect.fn("Form.state")(function* (id: ID) {
|
||||
return (yield* find(id)).state
|
||||
})
|
||||
|
||||
const reply = Effect.fn("Form.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* find(input.id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id: input.id })
|
||||
const invalid = validateAnswer(entry.form, input.answer)
|
||||
if (invalid) return yield* new InvalidAnswerError({ id: input.id, message: invalid })
|
||||
const next: State = { status: "answered", answer: input.answer }
|
||||
yield* events.publish(Event.Replied, { id: input.id, sessionID: entry.form.sessionID, answer: input.answer })
|
||||
yield* Cache.set(forms, input.id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const cancel = Effect.fn("Form.cancel")((id: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const entry = yield* find(id)
|
||||
if (entry.state.status !== "pending") return yield* new AlreadySettledError({ id })
|
||||
const next: State = { status: "cancelled" }
|
||||
yield* events.publish(Event.Cancelled, { id, sessionID: entry.form.sessionID })
|
||||
yield* Cache.set(forms, id, { ...entry, state: next })
|
||||
yield* Deferred.succeed(entry.deferred, next)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Cache.values(forms).pipe(
|
||||
Effect.flatMap((entries) =>
|
||||
Effect.forEach(
|
||||
Array.from(entries).filter((entry) => entry.state.status === "pending"),
|
||||
(entry) => cancel(entry.form.id).pipe(Effect.ignore),
|
||||
{ discard: true },
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
return Service.of({ create, ask, get, list, state, reply, cancel })
|
||||
}),
|
||||
)
|
||||
|
||||
export const locationLayer = layer
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
||||
function validateAnswer(form: Info, answer: Answer) {
|
||||
if (form.mode === "url") {
|
||||
if (Object.keys(answer).length === 0) return
|
||||
return "URL forms must be answered with an empty answer"
|
||||
}
|
||||
const fields = new Map(form.fields.map((field) => [field.key, field]))
|
||||
for (const key of Object.keys(answer)) {
|
||||
if (!fields.has(key)) return `Unknown form field: ${key}`
|
||||
}
|
||||
for (const field of form.fields) {
|
||||
const value = answer[field.key]
|
||||
if (value === undefined) {
|
||||
if (field.required && isActive(field, answer)) return `Missing required form field: ${field.key}`
|
||||
continue
|
||||
}
|
||||
const invalid = validateField(field, value)
|
||||
if (invalid) return invalid
|
||||
}
|
||||
}
|
||||
|
||||
function isActive(field: Form.Field, answer: Answer) {
|
||||
if (!field.when) return true
|
||||
const value = answer[field.when.key]
|
||||
if (field.when.op === "eq") return value === field.when.value
|
||||
return value !== field.when.value
|
||||
}
|
||||
|
||||
function validateField(field: Form.Field, value: Form.Value): string | undefined {
|
||||
if (field.type === "string") {
|
||||
if (typeof value !== "string") return `Expected string for form field: ${field.key}`
|
||||
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
||||
if (field.minLength !== undefined && value.length < field.minLength) return `Form field is too short: ${field.key}`
|
||||
if (field.maxLength !== undefined && value.length > field.maxLength) return `Form field is too long: ${field.key}`
|
||||
if (field.pattern !== undefined) {
|
||||
try {
|
||||
if (!new RegExp(field.pattern).test(value)) return `Form field does not match pattern: ${field.key}`
|
||||
} catch {
|
||||
return `Form field has invalid pattern: ${field.key}`
|
||||
}
|
||||
}
|
||||
if (field.format === "email" && !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value)) return `Expected email for form field: ${field.key}`
|
||||
if (field.format === "uri" && !isUri(value)) return `Expected URI for form field: ${field.key}`
|
||||
if (field.format === "date" && !isDate(value)) return `Expected date for form field: ${field.key}`
|
||||
if (field.format === "date-time" && !isDateTime(value)) return `Expected date-time for form field: ${field.key}`
|
||||
if (field.options && !field.custom && !field.options.some((option) => option.value === value)) {
|
||||
return `Invalid option for form field: ${field.key}`
|
||||
}
|
||||
return
|
||||
}
|
||||
if (field.type === "number" || field.type === "integer") {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return `Expected number for form field: ${field.key}`
|
||||
if (field.type === "integer" && !Number.isInteger(value)) return `Expected integer for form field: ${field.key}`
|
||||
if (field.minimum !== undefined && value < field.minimum) return `Form field is too small: ${field.key}`
|
||||
if (field.maximum !== undefined && value > field.maximum) return `Form field is too large: ${field.key}`
|
||||
return
|
||||
}
|
||||
if (field.type === "boolean") {
|
||||
if (typeof value !== "boolean") return `Expected boolean for form field: ${field.key}`
|
||||
return
|
||||
}
|
||||
if (field.type === "multiselect") {
|
||||
if (!isStringArray(value)) return `Expected string array for form field: ${field.key}`
|
||||
if (field.required && value.length === 0) return `Missing required form field: ${field.key}`
|
||||
if (field.minItems !== undefined && value.length < field.minItems) return `Too few selections for form field: ${field.key}`
|
||||
if (field.maxItems !== undefined && value.length > field.maxItems) return `Too many selections for form field: ${field.key}`
|
||||
if (!field.custom && value.some((item) => !field.options.some((option) => option.value === item))) {
|
||||
return `Invalid option for form field: ${field.key}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isStringArray(value: Form.Value): value is ReadonlyArray<string> {
|
||||
return Array.isArray(value) && value.every((item): item is string => typeof item === "string")
|
||||
}
|
||||
|
||||
function isUri(value: string) {
|
||||
try {
|
||||
new URL(value)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isDate(value: string) {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return false
|
||||
const date = new Date(`${value}T00:00:00.000Z`)
|
||||
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
|
||||
}
|
||||
|
||||
function isDateTime(value: string) {
|
||||
return !Number.isNaN(new Date(value).getTime())
|
||||
}
|
||||
|
|
@ -9,7 +9,6 @@ import { Node } from "./effect/app-node"
|
|||
import { FileMutation } from "./file-mutation"
|
||||
import { FileSystem } from "./filesystem"
|
||||
import { FileSystemSearch } from "./filesystem/search"
|
||||
import { Form } from "./form"
|
||||
import { Generate } from "./generate"
|
||||
import { Watcher } from "./filesystem/watcher"
|
||||
import { Image } from "./image"
|
||||
|
|
@ -25,6 +24,7 @@ import { Policy } from "./policy"
|
|||
import { Project } from "./project"
|
||||
import { ProjectCopy } from "./project/copy"
|
||||
import { Pty } from "./pty"
|
||||
import { QuestionV2 } from "./question"
|
||||
import { Shell } from "./shell"
|
||||
import { Reference } from "./reference"
|
||||
import { ReferenceGuidance } from "./reference/guidance"
|
||||
|
|
@ -83,7 +83,7 @@ export const locationServices = LayerNode.group([
|
|||
ReferenceGuidance.node,
|
||||
SessionTodo.node,
|
||||
SessionContextEntry.node,
|
||||
Form.node,
|
||||
QuestionV2.node,
|
||||
Generate.node,
|
||||
ReadToolFileSystem.node,
|
||||
BuiltInTools.node,
|
||||
|
|
|
|||
|
|
@ -9,13 +9,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
|
|||
import { UnauthorizedError, type OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js"
|
||||
import {
|
||||
CallToolResultSchema,
|
||||
ElicitationCompleteNotificationSchema,
|
||||
ElicitRequestSchema,
|
||||
GetPromptResultSchema,
|
||||
type ElicitRequestFormParams,
|
||||
type ElicitRequestParams,
|
||||
type ElicitRequestURLParams,
|
||||
type ElicitResult,
|
||||
ListPromptsResultSchema,
|
||||
ListRootsRequestSchema,
|
||||
ListToolsResultSchema,
|
||||
|
|
@ -88,22 +82,6 @@ export interface CallToolResult {
|
|||
readonly content: ReadonlyArray<CallToolContent>
|
||||
}
|
||||
|
||||
export type ElicitationFormParams = ElicitRequestFormParams
|
||||
export type ElicitationParams = ElicitRequestParams
|
||||
export type ElicitationResult = ElicitResult
|
||||
|
||||
export interface ElicitationHandler {
|
||||
readonly create: (input: {
|
||||
readonly server: string
|
||||
readonly params: ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) => Effect.Effect<ElicitationResult, Error>
|
||||
readonly complete: (input: {
|
||||
readonly server: string
|
||||
readonly elicitationID: ElicitRequestURLParams["elicitationId"]
|
||||
}) => Effect.Effect<void>
|
||||
}
|
||||
|
||||
export interface LogMessage {
|
||||
readonly level: LoggingMessageNotification["params"]["level"]
|
||||
readonly logger?: LoggingMessageNotification["params"]["logger"]
|
||||
|
|
@ -145,7 +123,6 @@ export const connect = Effect.fnUntraced(function* (
|
|||
// Only consumed by the remote transport; stdio servers have no auth concept. A provider with no
|
||||
// stored token (and a no-op redirect) surfaces an UnauthorizedError, which we map to needs_auth.
|
||||
authProvider?: OAuthClientProvider,
|
||||
elicitation?: ElicitationHandler,
|
||||
) {
|
||||
const transport: Transport = yield* Effect.gen(function* () {
|
||||
if (config.type === "local") {
|
||||
|
|
@ -172,7 +149,6 @@ export const connect = Effect.fnUntraced(function* (
|
|||
{ name: "opencode", version: InstallationVersion },
|
||||
{
|
||||
capabilities: {
|
||||
...(elicitation ? { elicitation: { form: { applyDefaults: true }, url: {} } } : {}),
|
||||
// https://github.com/anomalyco/opencode/issues/2308
|
||||
roots: {},
|
||||
},
|
||||
|
|
@ -181,14 +157,6 @@ export const connect = Effect.fnUntraced(function* (
|
|||
client.setRequestHandler(ListRootsRequestSchema, () =>
|
||||
Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }),
|
||||
)
|
||||
if (elicitation) {
|
||||
client.setRequestHandler(ElicitRequestSchema, (request, extra) =>
|
||||
Effect.runPromise(elicitation.create({ server, params: request.params, signal: extra.signal })),
|
||||
)
|
||||
client.setNotificationHandler(ElicitationCompleteNotificationSchema, (notification) =>
|
||||
Effect.runPromise(elicitation.complete({ server, elicitationID: notification.params.elicitationId })),
|
||||
)
|
||||
}
|
||||
|
||||
const exit = yield* Effect.tryPromise({
|
||||
try: (signal) => client.connect(transport, { timeout: config.timeout?.startup ?? DEFAULT_STARTUP_TIMEOUT, signal }),
|
||||
|
|
|
|||
|
|
@ -10,11 +10,9 @@ import { Config } from "../config"
|
|||
import { ConfigMCP } from "../config/mcp"
|
||||
import { Credential } from "../credential"
|
||||
import { EventV2 } from "../event"
|
||||
import { Form } from "../form"
|
||||
import { Integration } from "../integration"
|
||||
import { IntegrationConnection } from "../integration/connection"
|
||||
import { Location } from "../location"
|
||||
import { waitForAbort } from "../process"
|
||||
import { MCPClient } from "./client"
|
||||
import { MCPOAuth } from "./oauth"
|
||||
|
||||
|
|
@ -147,10 +145,6 @@ type ServerEntry = {
|
|||
integrationID?: Integration.ID
|
||||
}
|
||||
|
||||
// Temporary MCP elicitation escape hatch. Public Form routes remain session-shaped, but MCP
|
||||
// elicitations are still Location-scoped until the protocol path can attribute them to a Session.
|
||||
const GLOBAL_ELICITATION_SESSION_ID = "global"
|
||||
|
||||
export interface Interface {
|
||||
readonly servers: () => Effect.Effect<ServerInfo[]>
|
||||
readonly tools: () => Effect.Effect<Tool[]>
|
||||
|
|
@ -181,7 +175,6 @@ export const layer = Layer.effect(
|
|||
const config = yield* Config.Service
|
||||
const location = yield* Location.Service
|
||||
const events = yield* EventV2.Service
|
||||
const forms = yield* Form.Service
|
||||
const integration = yield* Integration.Service
|
||||
const credentials = yield* Credential.Service
|
||||
const root = yield* Scope.make()
|
||||
|
|
@ -196,7 +189,6 @@ export const layer = Layer.effect(
|
|||
)
|
||||
// Later config files win for duplicate server names; per-server timeout overrides globals.
|
||||
const runtime = new Map<ServerName, ServerEntry>()
|
||||
const urlElicitations = new Map<string, Form.ID>()
|
||||
for (const entry of documents) {
|
||||
for (const [name, server] of Object.entries(entry.info.mcp?.servers ?? {})) {
|
||||
runtime.set(ServerName.make(name), {
|
||||
|
|
@ -316,74 +308,6 @@ export const layer = Layer.effect(
|
|||
})
|
||||
})
|
||||
|
||||
const elicitation = {
|
||||
create: (input: {
|
||||
readonly server: string
|
||||
readonly params: MCPClient.ElicitationParams
|
||||
readonly signal: AbortSignal
|
||||
}) =>
|
||||
Effect.gen(function* () {
|
||||
const toResult = (state: Form.State): MCPClient.ElicitationResult => {
|
||||
if (state.status !== "answered") return { action: "cancel" }
|
||||
if (input.params.mode === "url") return { action: "accept" }
|
||||
return {
|
||||
action: "accept",
|
||||
content: Object.fromEntries(
|
||||
Object.entries(state.answer).map(
|
||||
([key, value]): [string, NonNullable<MCPClient.ElicitationResult["content"]>[string]] => {
|
||||
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean")
|
||||
return [key, value]
|
||||
return [key, Array.from(value)]
|
||||
},
|
||||
),
|
||||
),
|
||||
}
|
||||
}
|
||||
if (input.params.mode === "url") {
|
||||
const formID = Form.ID.create()
|
||||
const key = input.server + "\u0000" + input.params.elicitationId
|
||||
urlElicitations.set(key, formID)
|
||||
return yield* forms
|
||||
.ask({
|
||||
id: formID,
|
||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||
title: `${input.server} is requesting input`,
|
||||
metadata: {
|
||||
kind: "mcp-elicitation",
|
||||
server: input.server,
|
||||
elicitationID: input.params.elicitationId,
|
||||
message: input.params.message,
|
||||
},
|
||||
mode: "url",
|
||||
url: input.params.url,
|
||||
})
|
||||
.pipe(
|
||||
Effect.raceFirst(waitForAbort(input.signal)),
|
||||
Effect.ensuring(Effect.sync(() => urlElicitations.delete(key))),
|
||||
Effect.map(toResult),
|
||||
)
|
||||
}
|
||||
const params = input.params
|
||||
return yield* forms
|
||||
.ask({
|
||||
sessionID: GLOBAL_ELICITATION_SESSION_ID,
|
||||
title: `${input.server} is requesting input`,
|
||||
metadata: { kind: "mcp-elicitation", server: input.server, message: params.message },
|
||||
mode: "form",
|
||||
fields: Object.entries(params.requestedSchema.properties).map(([key, property]) =>
|
||||
toElicitationField(key, property, params.requestedSchema.required?.includes(key) === true),
|
||||
),
|
||||
})
|
||||
.pipe(Effect.raceFirst(waitForAbort(input.signal)), Effect.map(toResult))
|
||||
}),
|
||||
complete: (input: { readonly server: string; readonly elicitationID: string }) =>
|
||||
Effect.gen(function* () {
|
||||
const formID = urlElicitations.get(input.server + "\u0000" + input.elicitationID)
|
||||
if (!formID) return
|
||||
yield* forms.reply({ id: formID, answer: {} }).pipe(Effect.ignore)
|
||||
}),
|
||||
} satisfies MCPClient.ElicitationHandler
|
||||
|
||||
const toTool = (server: ServerName, def: MCPClient.ToolDefinition) =>
|
||||
new Tool({ server, name: def.name, description: def.description, inputSchema: def.inputSchema })
|
||||
|
||||
|
|
@ -472,7 +396,7 @@ export const layer = Layer.effect(
|
|||
const authProvider = yield* connectProvider(entry)
|
||||
// List tools as part of connect so a failure here marks the server failed rather than
|
||||
// leaving it connected with a silently empty tool list and no path to recover.
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider, elicitation).pipe(
|
||||
const result = yield* MCPClient.connect(name, entry.config, location.directory, authProvider).pipe(
|
||||
Effect.flatMap((connection) => connection.tools().pipe(Effect.map((tools) => ({ connection, tools })))),
|
||||
Scope.provide(scope),
|
||||
Effect.exit,
|
||||
|
|
@ -637,74 +561,5 @@ export const layer = Layer.effect(
|
|||
export const node = makeLocationNode({
|
||||
service: Service,
|
||||
layer,
|
||||
deps: [Config.node, Location.node, EventV2.node, Form.node, Integration.node, Credential.node],
|
||||
deps: [Config.node, Location.node, EventV2.node, Integration.node, Credential.node],
|
||||
})
|
||||
|
||||
function toElicitationField(key: string, property: ElicitationProperty, required: boolean): Form.Field {
|
||||
const title = elicitationFieldTitle(key, property)
|
||||
const description = property.description === title ? undefined : property.description
|
||||
const base = {
|
||||
key,
|
||||
...(title === undefined ? {} : { title }),
|
||||
...(description === undefined ? {} : { description }),
|
||||
...(required ? { required: true } : {}),
|
||||
}
|
||||
switch (property.type) {
|
||||
case "boolean":
|
||||
return { ...base, type: "boolean", ...(property.default === undefined ? {} : { default: property.default }) }
|
||||
case "number":
|
||||
case "integer":
|
||||
return {
|
||||
...base,
|
||||
type: property.type,
|
||||
...(property.minimum === undefined ? {} : { minimum: property.minimum }),
|
||||
...(property.maximum === undefined ? {} : { maximum: property.maximum }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
}
|
||||
case "array":
|
||||
return {
|
||||
...base,
|
||||
type: "multiselect",
|
||||
options:
|
||||
"anyOf" in property.items
|
||||
? property.items.anyOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: property.items.enum.map((value) => ({ value, label: value })),
|
||||
custom: false,
|
||||
...(property.minItems === undefined ? {} : { minItems: property.minItems }),
|
||||
...(property.maxItems === undefined ? {} : { maxItems: property.maxItems }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
}
|
||||
case "string": {
|
||||
const options =
|
||||
"oneOf" in property
|
||||
? property.oneOf.map((option) => ({ value: option.const, label: option.title }))
|
||||
: "enum" in property
|
||||
? property.enum.map((value, index) => ({
|
||||
value,
|
||||
label: ("enumNames" in property ? property.enumNames?.[index] : undefined) ?? value,
|
||||
}))
|
||||
: undefined
|
||||
return {
|
||||
...base,
|
||||
type: "string",
|
||||
...(!("format" in property) || property.format === undefined ? {} : { format: property.format }),
|
||||
...(!("minLength" in property) || property.minLength === undefined ? {} : { minLength: property.minLength }),
|
||||
...(!("maxLength" in property) || property.maxLength === undefined ? {} : { maxLength: property.maxLength }),
|
||||
...(property.default === undefined ? {} : { default: property.default }),
|
||||
...(options === undefined ? {} : { options, custom: false }),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function elicitationFieldTitle(key: string, property: ElicitationProperty) {
|
||||
if (property.title && !isSchemaTypeTitle(property.title)) return property.title
|
||||
if (property.description) return property.description
|
||||
return key
|
||||
}
|
||||
|
||||
function isSchemaTypeTitle(title: string) {
|
||||
return /^(boolean|string|number|integer|array|object)(\s+with\b.*|\s+in\b.*)?$/i.test(title.trim())
|
||||
}
|
||||
|
||||
type ElicitationProperty = MCPClient.ElicitationFormParams["requestedSchema"]["properties"][string]
|
||||
|
|
|
|||
151
packages/core/src/question.ts
Normal file
151
packages/core/src/question.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
export * as QuestionV2 from "./question"
|
||||
|
||||
import { makeLocationNode } from "./effect/app-node"
|
||||
import { Context, Deferred, Effect, Layer, Schema } from "effect"
|
||||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { EventV2 } from "./event"
|
||||
import { SessionSchema } from "./session/schema"
|
||||
|
||||
export const ID = Question.ID
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Question.Option
|
||||
export type Option = typeof Option.Type
|
||||
|
||||
export const Info = Question.Info
|
||||
export type Info = typeof Info.Type
|
||||
|
||||
export const Prompt = Question.Prompt
|
||||
export type Prompt = typeof Prompt.Type
|
||||
|
||||
export const Tool = Question.Tool
|
||||
export type Tool = typeof Tool.Type
|
||||
|
||||
export const Request = Question.Request
|
||||
export type Request = typeof Request.Type
|
||||
|
||||
export const Answer = Question.Answer
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Question.Reply
|
||||
export type Reply = typeof Reply.Type
|
||||
|
||||
export const Event = Question.Event
|
||||
|
||||
export class RejectedError extends Schema.TaggedErrorClass<RejectedError>()("QuestionV2.RejectedError", {}) {
|
||||
override get message() {
|
||||
return "The user dismissed this question"
|
||||
}
|
||||
}
|
||||
|
||||
export class NotFoundError extends Schema.TaggedErrorClass<NotFoundError>()("QuestionV2.NotFoundError", {
|
||||
requestID: ID,
|
||||
}) {}
|
||||
|
||||
export interface AskInput {
|
||||
readonly sessionID: SessionSchema.ID
|
||||
readonly questions: ReadonlyArray<Info>
|
||||
readonly tool?: Tool
|
||||
}
|
||||
|
||||
export interface ReplyInput {
|
||||
readonly requestID: ID
|
||||
readonly answers: ReadonlyArray<Answer>
|
||||
}
|
||||
|
||||
export interface Interface {
|
||||
readonly ask: (input: AskInput) => Effect.Effect<ReadonlyArray<Answer>, RejectedError>
|
||||
readonly reply: (input: ReplyInput) => Effect.Effect<void, NotFoundError>
|
||||
readonly reject: (requestID: ID) => Effect.Effect<void, NotFoundError>
|
||||
readonly list: () => Effect.Effect<ReadonlyArray<Request>>
|
||||
}
|
||||
|
||||
export class Service extends Context.Service<Service, Interface>()("@opencode/v2/Question") {}
|
||||
|
||||
interface Pending {
|
||||
readonly request: Request
|
||||
readonly deferred: Deferred.Deferred<ReadonlyArray<Answer>, RejectedError>
|
||||
}
|
||||
|
||||
/**
|
||||
* Location-owned pending prompts. The Location layer map must materialize this
|
||||
* layer once per embedded Location so replies cannot settle another Location's
|
||||
* deferred request.
|
||||
*/
|
||||
const layer = Layer.effect(
|
||||
Service,
|
||||
Effect.gen(function* () {
|
||||
const events = yield* EventV2.Service
|
||||
const pending = new Map<ID, Pending>()
|
||||
|
||||
yield* Effect.addFinalizer(() =>
|
||||
Effect.forEach(pending.values(), (item) => Deferred.fail(item.deferred, new RejectedError()), {
|
||||
discard: true,
|
||||
}).pipe(
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.clear()
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
const ask = Effect.fn("QuestionV2.ask")((input: AskInput) =>
|
||||
Effect.uninterruptibleMask((restore) =>
|
||||
Effect.gen(function* () {
|
||||
const id = ID.ascending()
|
||||
const deferred = yield* Deferred.make<ReadonlyArray<Answer>, RejectedError>()
|
||||
const request: Request = { id, ...input }
|
||||
pending.set(id, { request, deferred })
|
||||
return yield* events.publish(Event.Asked, request).pipe(
|
||||
Effect.andThen(restore(Deferred.await(deferred))),
|
||||
Effect.ensuring(
|
||||
Effect.sync(() => {
|
||||
pending.delete(id)
|
||||
}),
|
||||
),
|
||||
)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reply = Effect.fn("QuestionV2.reply")((input: ReplyInput) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(input.requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID: input.requestID })
|
||||
yield* events.publish(Event.Replied, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
answers: input.answers.map((answer) => [...answer]),
|
||||
})
|
||||
yield* Deferred.succeed(existing.deferred, input.answers)
|
||||
pending.delete(input.requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const reject = Effect.fn("QuestionV2.reject")((requestID: ID) =>
|
||||
Effect.uninterruptible(
|
||||
Effect.gen(function* () {
|
||||
const existing = pending.get(requestID)
|
||||
if (!existing) return yield* new NotFoundError({ requestID })
|
||||
yield* events.publish(Event.Rejected, {
|
||||
sessionID: existing.request.sessionID,
|
||||
requestID: existing.request.id,
|
||||
})
|
||||
yield* Deferred.fail(existing.deferred, new RejectedError())
|
||||
pending.delete(requestID)
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
const list = Effect.fn("QuestionV2.list")(function* () {
|
||||
return Array.from(pending.values(), (item) => item.request)
|
||||
})
|
||||
|
||||
return Service.of({ ask, reply, reject, list })
|
||||
}),
|
||||
)
|
||||
|
||||
export const node = makeLocationNode({ service: Service, layer, deps: [EventV2.node] })
|
||||
|
|
@ -14,9 +14,7 @@ import { Config } from "../../config"
|
|||
import { Database } from "../../database/database"
|
||||
import { EventV2 } from "../../event"
|
||||
import { Location } from "../../location"
|
||||
import { ModelV2 } from "../../model"
|
||||
import { ProviderV2 } from "../../provider"
|
||||
import { QuestionTool } from "../../tool/question"
|
||||
import { QuestionV2 } from "../../question"
|
||||
import { SystemContext } from "../../system-context/index"
|
||||
import { SystemContextBuiltIns } from "../../system-context/builtins"
|
||||
import { InstructionContext } from "../../instruction-context"
|
||||
|
|
@ -152,7 +150,7 @@ const layer = Layer.effect(
|
|||
|
||||
// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
|
||||
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.RejectedError)
|
||||
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
|
||||
|
||||
const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
|
||||
Effect.all(
|
||||
|
|
|
|||
|
|
@ -2,10 +2,9 @@ export * as QuestionTool from "./question"
|
|||
|
||||
import { ToolFailure } from "@opencode-ai/llm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { optional } from "@opencode-ai/schema/schema"
|
||||
import { Form } from "../form"
|
||||
import { makeLocationNode } from "../effect/app-node"
|
||||
import { PermissionV2 } from "../permission"
|
||||
import { QuestionV2 } from "../question"
|
||||
import { ToolRegistry } from "./registry"
|
||||
import { Tool } from "./tool"
|
||||
import { Tools } from "./tools"
|
||||
|
|
@ -23,40 +22,19 @@ Usage notes:
|
|||
- Answers are returned as arrays of labels; set \`multiple: true\` to allow selecting more than one
|
||||
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label`
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
|
||||
description: Schema.String.annotate({ description: "Explanation of choice" }),
|
||||
}).annotate({ identifier: "QuestionTool.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
|
||||
export const Prompt = Schema.Struct({
|
||||
question: Schema.String.annotate({ description: "Complete question" }),
|
||||
header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
|
||||
options: Schema.Array(Option).annotate({ description: "Available choices" }),
|
||||
multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
|
||||
custom: Schema.Boolean.pipe(optional).annotate({ description: "Allow typing a custom answer (default: true)" }),
|
||||
}).annotate({ identifier: "QuestionTool.Prompt" })
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
||||
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionTool.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Input = Schema.Struct({
|
||||
questions: Schema.Array(Prompt).annotate({ description: "Questions to ask" }),
|
||||
questions: Schema.Array(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
|
||||
})
|
||||
|
||||
export const Output = Schema.Struct({
|
||||
answers: Schema.Array(Answer),
|
||||
answers: Schema.Array(QuestionV2.Answer),
|
||||
})
|
||||
export type Output = typeof Output.Type
|
||||
|
||||
export class RejectedError extends Error {
|
||||
constructor() {
|
||||
super("The user dismissed this question")
|
||||
}
|
||||
}
|
||||
|
||||
export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: ReadonlyArray<Answer>) => {
|
||||
export const toModelOutput = (
|
||||
questions: ReadonlyArray<QuestionV2.Prompt>,
|
||||
answers: ReadonlyArray<QuestionV2.Answer>,
|
||||
) => {
|
||||
const formatted = questions
|
||||
.map(
|
||||
(question, index) =>
|
||||
|
|
@ -69,7 +47,7 @@ export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: Readonl
|
|||
const layer = Layer.effectDiscard(
|
||||
Effect.gen(function* () {
|
||||
const tools = yield* Tools.Service
|
||||
const form = yield* Form.Service
|
||||
const question = yield* QuestionV2.Service
|
||||
const permission = yield* PermissionV2.Service
|
||||
|
||||
yield* tools
|
||||
|
|
@ -93,22 +71,15 @@ const layer = Layer.effectDiscard(
|
|||
.pipe(
|
||||
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
|
||||
Effect.andThen(
|
||||
form
|
||||
question
|
||||
.ask({
|
||||
sessionID: context.sessionID,
|
||||
...(input.questions.length === 1 ? {} : { title: "Questions" }),
|
||||
metadata: {
|
||||
kind: "question",
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
},
|
||||
mode: "form",
|
||||
fields: input.questions.map(questionToField),
|
||||
questions: input.questions,
|
||||
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
|
||||
})
|
||||
.pipe(Effect.orDie),
|
||||
),
|
||||
Effect.flatMap((state) =>
|
||||
state.status === "answered" ? Effect.succeed({ answers: formToAnswers(input.questions, state.answer) }) : Effect.die(new RejectedError()),
|
||||
),
|
||||
Effect.map((answers) => ({ answers })),
|
||||
),
|
||||
}),
|
||||
})
|
||||
|
|
@ -119,33 +90,5 @@ const layer = Layer.effectDiscard(
|
|||
export const node = makeLocationNode({
|
||||
name: "tool/question",
|
||||
layer,
|
||||
deps: [ToolRegistry.node, PermissionV2.node, Form.node],
|
||||
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
|
||||
})
|
||||
|
||||
function questionToField(question: Prompt, index: number): Form.Field {
|
||||
const base = {
|
||||
key: key(index),
|
||||
title: question.question,
|
||||
description: question.header,
|
||||
}
|
||||
const options = question.options.map((option) => ({
|
||||
value: option.label,
|
||||
label: option.label,
|
||||
description: option.description,
|
||||
}))
|
||||
if (question.multiple) return { ...base, type: "multiselect", options, custom: question.custom ?? true }
|
||||
return { ...base, type: "string", options, custom: question.custom ?? true }
|
||||
}
|
||||
|
||||
function formToAnswers(questions: ReadonlyArray<Prompt>, answer: Form.Answer): ReadonlyArray<Answer> {
|
||||
return questions.map((_, index) => {
|
||||
const value = answer[key(index)]
|
||||
if (Array.isArray(value)) return value
|
||||
if (typeof value === "string" && value.length > 0) return [value]
|
||||
return []
|
||||
})
|
||||
}
|
||||
|
||||
function key(index: number) {
|
||||
return `question_${index}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Effect, Exit } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const forms = AppNodeBuilder.build(LayerNode.group([EventV2.node, Form.node]))
|
||||
const it = testEffect(forms)
|
||||
|
||||
const formID = Form.ID.create("frm_test")
|
||||
const input = {
|
||||
id: formID,
|
||||
sessionID: "ses_test",
|
||||
mode: "form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
} satisfies Form.CreateInput
|
||||
|
||||
describe("Form", () => {
|
||||
it.effect("cleans up created forms when event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const events = yield* EventV2.Service
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
expect(Exit.isFailure(yield* Effect.exit(service.create(input)))).toBe(true)
|
||||
expect(yield* service.get(formID).pipe(Effect.flip)).toEqual(new Form.NotFoundError({ id: formID }))
|
||||
|
||||
yield* unsubscribe
|
||||
expect(yield* service.create(input)).toMatchObject({ id: formID })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("keeps forms pending when reply event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const events = yield* EventV2.Service
|
||||
yield* service.create(input)
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
|
||||
expect(
|
||||
Exit.isFailure(yield* Effect.exit(service.reply({ id: formID, answer: { name: "Ava" } }))),
|
||||
).toBe(true)
|
||||
expect(yield* service.state(formID)).toEqual({ status: "pending" })
|
||||
|
||||
yield* unsubscribe
|
||||
yield* service.reply({ id: formID, answer: { name: "Ava" } })
|
||||
expect(yield* service.state(formID)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
114
packages/core/test/question.test.ts
Normal file
114
packages/core/test/question.test.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
|
||||
const it = testEffect(questions)
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||
const question: QuestionV2.Info = {
|
||||
question: "Which option?",
|
||||
header: "Option",
|
||||
options: [{ label: "One", description: "First option" }],
|
||||
}
|
||||
|
||||
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
|
||||
service: QuestionV2.Interface,
|
||||
input: QuestionV2.AskInput,
|
||||
) {
|
||||
const events = yield* EventV2.Service
|
||||
const asked = yield* Deferred.make<QuestionV2.Request>()
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === QuestionV2.Event.Asked.type
|
||||
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
return { fiber, request: yield* Deferred.await(asked) }
|
||||
})
|
||||
|
||||
describe("QuestionV2", () => {
|
||||
it.effect("publishes lifecycle events and settles a pending reply", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type.startsWith("question.v2.")) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
expect(request.id).toMatch(/^que_/)
|
||||
expect(yield* service.list()).toEqual([request])
|
||||
yield* service.reply({ requestID: request.id, answers: [["One"]] })
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect(published.map((event) => [event.type, event.data])).toEqual([
|
||||
[QuestionV2.Event.Asked.type, request],
|
||||
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
yield* service.reject(request.id)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
|
||||
|
||||
const unknown = QuestionV2.ID.ascending("que_unknown")
|
||||
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstScope = yield* Scope.make()
|
||||
const secondScope = yield* Scope.make()
|
||||
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
|
||||
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
|
||||
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const request = (yield* first.list())[0]!
|
||||
|
||||
expect(yield* second.list()).toEqual([])
|
||||
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: request.id }),
|
||||
)
|
||||
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -16,12 +16,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
|
@ -39,7 +39,6 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
|
|
@ -277,7 +276,7 @@ const it = testEffect(
|
|||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
Form.node,
|
||||
QuestionV2.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
|
|
@ -2856,19 +2855,14 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const forms = yield* Form.Service
|
||||
const questions = yield* QuestionV2.Service
|
||||
yield* registry.register({
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
forms.ask({ sessionID: context.sessionID, mode: "form", fields: [] }).pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((state) =>
|
||||
state.status === "answered" ? Effect.succeed({}) : Effect.die(new QuestionTool.RejectedError()),
|
||||
),
|
||||
),
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
|
@ -2885,12 +2879,12 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
||||
let pending = yield* forms.list({ sessionID })
|
||||
let pending = yield* questions.list()
|
||||
while (pending.length === 0) {
|
||||
yield* Effect.yieldNow
|
||||
pending = yield* forms.list({ sessionID })
|
||||
pending = yield* questions.list()
|
||||
}
|
||||
yield* forms.cancel(pending[0]!.id)
|
||||
yield* questions.reject(pending[0]!.id)
|
||||
const exit = yield* Fiber.join(run)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
|
|
@ -13,7 +13,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
|
|||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: Form.CreateInput | undefined
|
||||
let captured: QuestionV2.AskInput | undefined
|
||||
let reject = false
|
||||
let deny = false
|
||||
const capturedInput = () => captured
|
||||
|
|
@ -31,27 +31,22 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: (input: Form.CreateInput) =>
|
||||
Effect.gen(function* () {
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
ask: (input: QuestionV2.AskInput) =>
|
||||
Effect.sync(() => {
|
||||
captured = input
|
||||
if (reject) return { status: "cancelled" } as const
|
||||
return { status: "answered", answer: { question_0: "Build" } } as const
|
||||
}),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
reject: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
|
||||
[PermissionV2.node, permission],
|
||||
[Form.node, form],
|
||||
[QuestionV2.node, question],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
|
@ -122,27 +117,8 @@ describe("QuestionTool", () => {
|
|||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "question_0",
|
||||
title: "What should happen?",
|
||||
description: "Action",
|
||||
type: "string",
|
||||
options: [{ value: "Build", label: "Build", description: "Build it" }],
|
||||
custom: true,
|
||||
},
|
||||
{
|
||||
key: "question_1",
|
||||
title: "Which environment?",
|
||||
description: "Environment",
|
||||
type: "string",
|
||||
options: [{ value: "Dev", label: "Dev", description: "Development" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
questions,
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -161,10 +137,8 @@ describe("QuestionTool", () => {
|
|||
})
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
questions: [],
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,4 +1 @@
|
|||
delete process.env["OTEL_EXPORTER_OTLP_ENDPOINT"]
|
||||
delete process.env["OTEL_EXPORTER_OTLP_HEADERS"]
|
||||
|
||||
await import("../test/server/httpapi-exercise/index")
|
||||
|
|
|
|||
|
|
@ -114,7 +114,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
|
||||
const rejectQuestion = async (request: { id: string }) => {
|
||||
questionRejected = true
|
||||
await input.client.question.reject({ requestID: request.id }).catch(() => {})
|
||||
await input.client.v2.session.question.reject({ sessionID: input.sessionID, requestID: request.id }).catch(() => {})
|
||||
}
|
||||
|
||||
const consume = async () => {
|
||||
|
|
@ -127,7 +127,7 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
await replyPermission(event.data)
|
||||
continue
|
||||
}
|
||||
if (event.type === "question.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
if (event.type === "question.v2.asked" && submitted && event.data.sessionID === input.sessionID) {
|
||||
await rejectQuestion(event.data)
|
||||
continue
|
||||
}
|
||||
|
|
@ -408,11 +408,11 @@ export async function runNonInteractivePrompt(input: Input) {
|
|||
|
||||
const [permissions, questions] = await Promise.all([
|
||||
input.client.v2.session.permission.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
input.client.question.list().catch(() => undefined),
|
||||
input.client.v2.session.question.list({ sessionID: input.sessionID }).catch(() => undefined),
|
||||
])
|
||||
await Promise.all([
|
||||
...(permissions?.data?.data ?? []).map(replyPermission),
|
||||
...(questions?.data ?? []).filter((question) => question.sessionID === input.sessionID).map(rejectQuestion),
|
||||
...(questions?.data?.data ?? []).map(rejectQuestion),
|
||||
])
|
||||
await completed
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -255,9 +255,10 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reply({
|
||||
await ctx.sdk.v2.session.question.reply({
|
||||
sessionID: state.sessionID,
|
||||
requestID: next.requestID,
|
||||
answers: next.answers ?? [],
|
||||
questionV2Reply: { answers: next.answers ?? [] },
|
||||
})
|
||||
},
|
||||
onQuestionReject: async (next) => {
|
||||
|
|
@ -265,7 +266,7 @@ async function runInteractiveRuntime(input: RunRuntimeInput, deps: RunRuntimeDep
|
|||
return
|
||||
}
|
||||
|
||||
await ctx.sdk.question.reject(next)
|
||||
await ctx.sdk.v2.session.question.reject({ sessionID: state.sessionID, ...next })
|
||||
},
|
||||
onCycleVariant: () => {
|
||||
if (!state.model || state.variants.length === 0) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type {
|
|||
PermissionRequest,
|
||||
PermissionV2Request,
|
||||
QuestionRequest,
|
||||
QuestionV2Request,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantTool,
|
||||
|
|
@ -132,6 +133,15 @@ function permission(request: PermissionV2Request): PermissionRequest {
|
|||
}
|
||||
}
|
||||
|
||||
function question(request: QuestionV2Request): QuestionRequest {
|
||||
return {
|
||||
id: request.id,
|
||||
sessionID: request.sessionID,
|
||||
questions: request.questions,
|
||||
tool: request.tool,
|
||||
}
|
||||
}
|
||||
|
||||
function sessionID(event: RunV2Event) {
|
||||
return "sessionID" in event.data && typeof event.data.sessionID === "string" ? event.data.sessionID : undefined
|
||||
}
|
||||
|
|
@ -359,13 +369,13 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
{ throwOnError: true },
|
||||
),
|
||||
input.sdk.v2.session.permission.list({ sessionID: input.sessionID }, { throwOnError: true }),
|
||||
input.sdk.question.list({ directory: input.directory }, { throwOnError: true }),
|
||||
input.sdk.v2.session.question.list({ sessionID: input.sessionID }, { throwOnError: true }),
|
||||
input.sdk.v2.session.active({ throwOnError: true }),
|
||||
])
|
||||
const projected = messages.data.data.toReversed()
|
||||
for (const message of projected) renderMessage(message, next.render, next.reuseVisibleWait)
|
||||
state.permissions = permissions.data.data.map(permission)
|
||||
state.questions = questions.data.filter((item) => item.sessionID === input.sessionID)
|
||||
state.questions = questions.data.data.map(question)
|
||||
syncBlockers()
|
||||
await subagents.hydrate({ messages: projected, active: active.data.data })
|
||||
const running = input.sessionID in active.data.data
|
||||
|
|
@ -534,12 +544,12 @@ export async function createSessionTransport(input: StreamInput): Promise<Sessio
|
|||
syncBlockers()
|
||||
return
|
||||
}
|
||||
if (event.type === "question.asked") {
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(event.data)
|
||||
if (event.type === "question.v2.asked") {
|
||||
if (!state.questions.some((item) => item.id === event.data.id)) state.questions.push(question(event.data))
|
||||
syncBlockers()
|
||||
return
|
||||
}
|
||||
if (event.type === "question.replied" || event.type === "question.rejected") {
|
||||
if (event.type === "question.v2.replied" || event.type === "question.v2.rejected") {
|
||||
state.questions = state.questions.filter((item) => item.id !== event.data.requestID)
|
||||
syncBlockers()
|
||||
return
|
||||
|
|
|
|||
|
|
@ -25,6 +25,9 @@ import { SessionApi } from "./groups/session"
|
|||
import { SyncApi } from "./groups/sync"
|
||||
import { TuiApi } from "./groups/tui"
|
||||
import { WorkspaceApi } from "./groups/workspace"
|
||||
import { makeApi } from "@opencode-ai/protocol/api"
|
||||
import { LocationMiddleware } from "@opencode-ai/server/location"
|
||||
import { SessionLocationMiddleware } from "@opencode-ai/server/middleware/session-location"
|
||||
import { GlobalApi } from "./groups/global"
|
||||
import { Authorization } from "./middleware/authorization"
|
||||
import { SchemaErrorMiddleware } from "./middleware/schema-error"
|
||||
|
|
@ -42,6 +45,12 @@ const EventSchema = Schema.Union([
|
|||
InstanceDisposed,
|
||||
]).annotate({ identifier: "Event" })
|
||||
|
||||
export const ServerApi = makeApi({
|
||||
definitions: EventManifest.Latest.values().toArray(),
|
||||
locationMiddleware: LocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
export const RootHttpApi = HttpApi.make("opencode-root")
|
||||
.addHttpApi(ControlApi)
|
||||
.addHttpApi(ControlPlaneApi)
|
||||
|
|
@ -71,6 +80,7 @@ export const OpenCodeHttpApi = HttpApi.make("opencode")
|
|||
.addHttpApi(RootHttpApi)
|
||||
.addHttpApi(EventApi)
|
||||
.addHttpApi(InstanceHttpApi)
|
||||
.addHttpApi(ServerApi)
|
||||
.addHttpApi(PtyConnectApi)
|
||||
.annotate(HttpApi.AdditionalSchemas, [
|
||||
EventSchema,
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { MCP } from "@/mcp"
|
|||
import { McpAuth } from "@/mcp/auth"
|
||||
import { Permission } from "@/permission"
|
||||
import { Plugin } from "@/plugin"
|
||||
import { PluginPtyEnvironment } from "@/plugin/pty-environment"
|
||||
import { InstanceStore } from "@/project/instance-store"
|
||||
import { Project } from "@/project/project"
|
||||
import { Vcs } from "@/project/vcs"
|
||||
|
|
@ -71,11 +72,13 @@ import { CorsConfig, isAllowedCorsOrigin, type CorsOptions } from "@opencode-ai/
|
|||
import { serveUIEffect } from "@/server/shared/ui"
|
||||
import { ServerAuth } from "@/server/auth"
|
||||
import { InstanceHttpApi, RootHttpApi } from "./api"
|
||||
import { Api } from "@opencode-ai/server/api"
|
||||
import { PublicApi } from "./public"
|
||||
import {
|
||||
authorizationLayer,
|
||||
authorizationRouterMiddleware,
|
||||
ptyConnectAuthorizationLayer,
|
||||
serverAuthorizationLayer,
|
||||
} from "./middleware/authorization"
|
||||
import { EventApi } from "./groups/event"
|
||||
import { PtyConnectApi } from "./groups/pty"
|
||||
|
|
@ -97,10 +100,12 @@ import { questionHandlers } from "./handlers/question"
|
|||
import { sessionHandlers } from "./handlers/session"
|
||||
import { syncHandlers } from "./handlers/sync"
|
||||
import { tuiHandlers } from "./handlers/tui"
|
||||
import { handlers } from "@opencode-ai/server/handlers"
|
||||
import { buildLocationServiceMap, LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { layer as locationLayer } from "@opencode-ai/server/location"
|
||||
import { sessionLocationLayer } from "@opencode-ai/server/middleware/session-location"
|
||||
import { PtyEnvironment } from "@opencode-ai/server/pty-environment"
|
||||
import { schemaErrorLayer as v2SchemaErrorLayer } from "@opencode-ai/server/middleware/schema-error"
|
||||
import { workspaceHandlers } from "./handlers/workspace"
|
||||
import { instanceContextLayer } from "./middleware/instance-context"
|
||||
import { workspaceRoutingLayer } from "./middleware/workspace-routing"
|
||||
|
|
@ -132,6 +137,7 @@ const cors = (corsOptions?: CorsOptions) =>
|
|||
const authOnlyRouterLayer = authorizationRouterMiddleware.layer.pipe(Layer.provide(ServerAuth.Config.layer))
|
||||
const httpApiAuthLayer = authorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
|
||||
const ptyConnectHttpApiAuthLayer = ptyConnectAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
|
||||
const serverHttpApiAuthLayer = serverAuthorizationLayer.pipe(Layer.provide(ServerAuth.Config.layer))
|
||||
const workspaceRoutingLive = workspaceRoutingLayer.pipe(Layer.provide(Socket.layerWebSocketConstructorGlobal))
|
||||
const rootApiRoutes = HttpApiBuilder.layer(RootHttpApi).pipe(
|
||||
Layer.provide([controlHandlers, controlPlaneHandlers, globalHandlers]),
|
||||
|
|
@ -169,6 +175,12 @@ const instanceApiRoutes = HttpApiBuilder.layer(InstanceHttpApi).pipe(
|
|||
const instanceRoutes = instanceApiRoutes.pipe(
|
||||
Layer.provide([httpApiAuthLayer, workspaceRoutingLive, instanceContextLayer, schemaErrorLayer]),
|
||||
)
|
||||
const serverRoutes = HttpApiBuilder.layer(Api).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provide(PluginPtyEnvironment.layer),
|
||||
Layer.provide([serverHttpApiAuthLayer, v2SchemaErrorLayer]),
|
||||
)
|
||||
|
||||
// `OpenApi.fromApi` is non-trivial; defer until /doc is actually hit so
|
||||
// processes that never serve it (CLI, scripts) don't pay at module load.
|
||||
// `HttpServerResponse.jsonUnsafe` runs JSON.stringify eagerly, so caching
|
||||
|
|
@ -267,6 +279,7 @@ export function createRoutes(
|
|||
eventApiRoutes,
|
||||
ptyConnectApiRoutes,
|
||||
instanceRoutes,
|
||||
serverRoutes,
|
||||
docRoute,
|
||||
uiRoute,
|
||||
).pipe(
|
||||
|
|
|
|||
|
|
@ -112,8 +112,8 @@ function sdk(input: {
|
|||
}),
|
||||
)
|
||||
spyOn(client.v2.session.permission, "list").mockImplementation(() => ok({ data: [] }))
|
||||
spyOn(client.question, "list").mockImplementation(() => ok([]))
|
||||
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {} }))
|
||||
spyOn(client.v2.session.question, "list").mockImplementation(() => ok({ data: [] }))
|
||||
spyOn(client.v2.session, "active").mockImplementation(() => ok({ data: input.active?.() ?? {}, watermarks: {} }))
|
||||
spyOn(client.v2.session, "switchAgent").mockImplementation(() => ok(undefined))
|
||||
spyOn(client.v2.session, "switchModel").mockImplementation(() => ok(undefined))
|
||||
// The generated methods have conditional return types for throwOnError; the
|
||||
|
|
|
|||
|
|
@ -811,6 +811,11 @@ const scenarios: Scenario[] = [
|
|||
object(body.location)
|
||||
array(body.data)
|
||||
}),
|
||||
http.protected.get("/api/question/request", "v2.question.request.list").json(200, (body) => {
|
||||
object(body)
|
||||
object(body.location)
|
||||
array(body.data)
|
||||
}),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/permission", "v2.session.permission.create")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission create owner" }))
|
||||
|
|
@ -844,6 +849,14 @@ const scenarios: Scenario[] = [
|
|||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.get("/api/session/{sessionID}/question", "v2.session.question.list")
|
||||
.seeded((ctx) => ctx.session({ title: "Question list owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question", { sessionID: ctx.state.id }),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(200, data(array)),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/permission/{requestID}/reply", "v2.session.permission.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Permission owner" }))
|
||||
|
|
@ -856,6 +869,29 @@ const scenarios: Scenario[] = [
|
|||
body: { reply: "once" },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/{requestID}/reply", "v2.session.question.reply")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reply owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/{requestID}/reply", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
body: { answers: [] },
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected
|
||||
.post("/api/session/{sessionID}/question/{requestID}/reject", "v2.session.question.reject")
|
||||
.seeded((ctx) => ctx.session({ title: "Question reject owner" }))
|
||||
.at((ctx) => ({
|
||||
path: route("/api/session/{sessionID}/question/{requestID}/reject", {
|
||||
sessionID: ctx.state.id,
|
||||
requestID: "que_httpapi_missing",
|
||||
}),
|
||||
headers: ctx.headers(),
|
||||
}))
|
||||
.json(404, object, "status"),
|
||||
http.protected.get("/api/permission/saved", "v2.permission.saved.list").json(200, (body) => {
|
||||
object(body)
|
||||
array(body.data)
|
||||
|
|
@ -1733,9 +1769,7 @@ const main = Effect.gen(function* () {
|
|||
const options = parseOptions(Bun.argv.slice(2))
|
||||
const modules = yield* Effect.promise(() => runtime())
|
||||
const effectRoutes = routeKeys(OpenApi.fromApi(modules.PublicApi))
|
||||
const selected = selectedScenarios(options, scenarios).filter((scenario) =>
|
||||
effectRoutes.includes(routeKey(scenario)),
|
||||
)
|
||||
const selected = selectedScenarios(options, scenarios)
|
||||
const missing = effectRoutes.filter((route) => !scenarios.some((scenario) => route === routeKey(scenario)))
|
||||
const extra = scenarios.filter((scenario) => !effectRoutes.includes(routeKey(scenario)))
|
||||
|
||||
|
|
|
|||
|
|
@ -137,7 +137,11 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||
test("preserves required request bodies for v2 mutations", () => {
|
||||
const spec = OpenApi.fromApi(PublicApi) as OpenApiSpec
|
||||
|
||||
for (const path of ["/api/session/{sessionID}/prompt", "/api/session/{sessionID}/permission/{requestID}/reply"]) {
|
||||
for (const path of [
|
||||
"/api/session/{sessionID}/prompt",
|
||||
"/api/session/{sessionID}/permission/{requestID}/reply",
|
||||
"/api/session/{sessionID}/question/{requestID}/reply",
|
||||
]) {
|
||||
expect(spec.paths[path]?.post?.requestBody?.required, path).toBe(true)
|
||||
}
|
||||
})
|
||||
|
|
@ -288,6 +292,15 @@ describe("PublicApi OpenAPI v2 errors", () => {
|
|||
"QuestionNotFoundError",
|
||||
)
|
||||
}
|
||||
for (const route of [
|
||||
["post", "/api/session/{sessionID}/question/{requestID}/reply"],
|
||||
["post", "/api/session/{sessionID}/question/{requestID}/reject"],
|
||||
] as const) {
|
||||
expect(componentNames(spec.paths[route[1]]?.[route[0]]?.responses?.["404"])).toEqual([
|
||||
"QuestionNotFoundError",
|
||||
"SessionNotFoundError",
|
||||
])
|
||||
}
|
||||
})
|
||||
|
||||
test("documents MCP server not-found errors", () => {
|
||||
|
|
|
|||
|
|
@ -458,145 +458,71 @@ export interface ProjectApi<E = never> {
|
|||
readonly directories: ProjectDirectoriesOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.form"]["form.request.list"]>[0]
|
||||
type Endpoint13_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
export type Endpoint13_0Input = { readonly location?: Endpoint13_0Request["query"]["location"] }
|
||||
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.form"]["form.request.list"]>>
|
||||
export type FormListRequestsOperation<E = never> = (input?: Endpoint13_0Input) => Effect.Effect<Endpoint13_0Output, E>
|
||||
|
||||
type Endpoint13_1Request = Parameters<RawClient["server.form"]["session.form.list"]>[0]
|
||||
export type Endpoint13_1Input = {
|
||||
readonly sessionID: Endpoint13_1Request["params"]["sessionID"]
|
||||
readonly location?: Endpoint13_1Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint13_1Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.list"]>>
|
||||
export type FormListOperation<E = never> = (input: Endpoint13_1Input) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
type Endpoint13_2Request = Parameters<RawClient["server.form"]["session.form.create"]>[0]
|
||||
export type Endpoint13_2Input = {
|
||||
readonly sessionID: Endpoint13_2Request["params"]["sessionID"]
|
||||
readonly location?: Endpoint13_2Request["query"]["location"]
|
||||
readonly id?: Endpoint13_2Request["payload"]["id"]
|
||||
readonly title?: Endpoint13_2Request["payload"]["title"]
|
||||
readonly metadata?: Endpoint13_2Request["payload"]["metadata"]
|
||||
readonly mode: Endpoint13_2Request["payload"]["mode"]
|
||||
readonly fields?: Endpoint13_2Request["payload"]["fields"]
|
||||
readonly url?: Endpoint13_2Request["payload"]["url"]
|
||||
}
|
||||
export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.create"]>>
|
||||
export type FormCreateOperation<E = never> = (input: Endpoint13_2Input) => Effect.Effect<Endpoint13_2Output, E>
|
||||
|
||||
type Endpoint13_3Request = Parameters<RawClient["server.form"]["session.form.get"]>[0]
|
||||
export type Endpoint13_3Input = {
|
||||
readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_3Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_3Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint13_3Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.get"]>>
|
||||
export type FormGetOperation<E = never> = (input: Endpoint13_3Input) => Effect.Effect<Endpoint13_3Output, E>
|
||||
|
||||
type Endpoint13_4Request = Parameters<RawClient["server.form"]["session.form.state"]>[0]
|
||||
export type Endpoint13_4Input = {
|
||||
readonly sessionID: Endpoint13_4Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_4Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint13_4Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.state"]>>
|
||||
export type FormStateOperation<E = never> = (input: Endpoint13_4Input) => Effect.Effect<Endpoint13_4Output, E>
|
||||
|
||||
type Endpoint13_5Request = Parameters<RawClient["server.form"]["session.form.reply"]>[0]
|
||||
export type Endpoint13_5Input = {
|
||||
readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_5Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_5Request["query"]["location"]
|
||||
readonly answer: Endpoint13_5Request["payload"]["answer"]
|
||||
}
|
||||
export type Endpoint13_5Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.reply"]>>
|
||||
export type FormReplyOperation<E = never> = (input: Endpoint13_5Input) => Effect.Effect<Endpoint13_5Output, E>
|
||||
|
||||
type Endpoint13_6Request = Parameters<RawClient["server.form"]["session.form.cancel"]>[0]
|
||||
export type Endpoint13_6Input = {
|
||||
readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
|
||||
readonly formID: Endpoint13_6Request["params"]["formID"]
|
||||
readonly location?: Endpoint13_6Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.form"]["session.form.cancel"]>>
|
||||
export type FormCancelOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
|
||||
|
||||
export interface FormApi<E = never> {
|
||||
readonly listRequests: FormListRequestsOperation<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>
|
||||
}
|
||||
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.permission"]["permission.request.list"]>[0]
|
||||
export type Endpoint14_0Input = { readonly location?: Endpoint14_0Request["query"]["location"] }
|
||||
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
|
||||
export type Endpoint13_0Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.request.list"]>>
|
||||
export type PermissionListRequestsOperation<E = never> = (
|
||||
input?: Endpoint14_0Input,
|
||||
) => Effect.Effect<Endpoint14_0Output, E>
|
||||
input?: Endpoint13_0Input,
|
||||
) => Effect.Effect<Endpoint13_0Output, E>
|
||||
|
||||
type Endpoint14_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
export type Endpoint14_1Input = { readonly projectID?: Endpoint14_1Request["query"]["projectID"] }
|
||||
export type Endpoint14_1Output = EffectValue<
|
||||
type Endpoint13_1Request = Parameters<RawClient["server.permission"]["permission.saved.list"]>[0]
|
||||
export type Endpoint13_1Input = { readonly projectID?: Endpoint13_1Request["query"]["projectID"] }
|
||||
export type Endpoint13_1Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["permission.saved.list"]>
|
||||
>["data"]
|
||||
export type PermissionListSavedOperation<E = never> = (
|
||||
input?: Endpoint14_1Input,
|
||||
) => Effect.Effect<Endpoint14_1Output, E>
|
||||
input?: Endpoint13_1Input,
|
||||
) => Effect.Effect<Endpoint13_1Output, E>
|
||||
|
||||
type Endpoint14_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
export type Endpoint14_2Input = { readonly id: Endpoint14_2Request["params"]["id"] }
|
||||
export type Endpoint14_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
|
||||
type Endpoint13_2Request = Parameters<RawClient["server.permission"]["permission.saved.remove"]>[0]
|
||||
export type Endpoint13_2Input = { readonly id: Endpoint13_2Request["params"]["id"] }
|
||||
export type Endpoint13_2Output = EffectValue<ReturnType<RawClient["server.permission"]["permission.saved.remove"]>>
|
||||
export type PermissionRemoveSavedOperation<E = never> = (
|
||||
input: Endpoint14_2Input,
|
||||
) => Effect.Effect<Endpoint14_2Output, E>
|
||||
input: Endpoint13_2Input,
|
||||
) => Effect.Effect<Endpoint13_2Output, E>
|
||||
|
||||
type Endpoint14_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
export type Endpoint14_3Input = {
|
||||
readonly sessionID: Endpoint14_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint14_3Request["payload"]["id"]
|
||||
readonly action: Endpoint14_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint14_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint14_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint14_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint14_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint14_3Request["payload"]["agent"]
|
||||
type Endpoint13_3Request = Parameters<RawClient["server.permission"]["session.permission.create"]>[0]
|
||||
export type Endpoint13_3Input = {
|
||||
readonly sessionID: Endpoint13_3Request["params"]["sessionID"]
|
||||
readonly id?: Endpoint13_3Request["payload"]["id"]
|
||||
readonly action: Endpoint13_3Request["payload"]["action"]
|
||||
readonly resources: Endpoint13_3Request["payload"]["resources"]
|
||||
readonly save?: Endpoint13_3Request["payload"]["save"]
|
||||
readonly metadata?: Endpoint13_3Request["payload"]["metadata"]
|
||||
readonly source?: Endpoint13_3Request["payload"]["source"]
|
||||
readonly agent?: Endpoint13_3Request["payload"]["agent"]
|
||||
}
|
||||
export type Endpoint14_3Output = EffectValue<
|
||||
export type Endpoint13_3Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.create"]>
|
||||
>["data"]
|
||||
export type PermissionCreateOperation<E = never> = (input: Endpoint14_3Input) => Effect.Effect<Endpoint14_3Output, E>
|
||||
export type PermissionCreateOperation<E = never> = (input: Endpoint13_3Input) => Effect.Effect<Endpoint13_3Output, E>
|
||||
|
||||
type Endpoint14_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
export type Endpoint14_4Input = { readonly sessionID: Endpoint14_4Request["params"]["sessionID"] }
|
||||
export type Endpoint14_4Output = EffectValue<
|
||||
type Endpoint13_4Request = Parameters<RawClient["server.permission"]["session.permission.list"]>[0]
|
||||
export type Endpoint13_4Input = { readonly sessionID: Endpoint13_4Request["params"]["sessionID"] }
|
||||
export type Endpoint13_4Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.list"]>
|
||||
>["data"]
|
||||
export type PermissionListOperation<E = never> = (input: Endpoint14_4Input) => Effect.Effect<Endpoint14_4Output, E>
|
||||
export type PermissionListOperation<E = never> = (input: Endpoint13_4Input) => Effect.Effect<Endpoint13_4Output, E>
|
||||
|
||||
type Endpoint14_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
export type Endpoint14_5Input = {
|
||||
readonly sessionID: Endpoint14_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint14_5Request["params"]["requestID"]
|
||||
type Endpoint13_5Request = Parameters<RawClient["server.permission"]["session.permission.get"]>[0]
|
||||
export type Endpoint13_5Input = {
|
||||
readonly sessionID: Endpoint13_5Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint13_5Request["params"]["requestID"]
|
||||
}
|
||||
export type Endpoint14_5Output = EffectValue<
|
||||
export type Endpoint13_5Output = EffectValue<
|
||||
ReturnType<RawClient["server.permission"]["session.permission.get"]>
|
||||
>["data"]
|
||||
export type PermissionGetOperation<E = never> = (input: Endpoint14_5Input) => Effect.Effect<Endpoint14_5Output, E>
|
||||
export type PermissionGetOperation<E = never> = (input: Endpoint13_5Input) => Effect.Effect<Endpoint13_5Output, E>
|
||||
|
||||
type Endpoint14_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
export type Endpoint14_6Input = {
|
||||
readonly sessionID: Endpoint14_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint14_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint14_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint14_6Request["payload"]["message"]
|
||||
type Endpoint13_6Request = Parameters<RawClient["server.permission"]["session.permission.reply"]>[0]
|
||||
export type Endpoint13_6Input = {
|
||||
readonly sessionID: Endpoint13_6Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint13_6Request["params"]["requestID"]
|
||||
readonly reply: Endpoint13_6Request["payload"]["reply"]
|
||||
readonly message?: Endpoint13_6Request["payload"]["message"]
|
||||
}
|
||||
export type Endpoint14_6Output = EffectValue<ReturnType<RawClient["server.permission"]["session.permission.reply"]>>
|
||||
export type PermissionReplyOperation<E = never> = (input: Endpoint14_6Input) => Effect.Effect<Endpoint14_6Output, E>
|
||||
export type Endpoint13_6Output = EffectValue<ReturnType<RawClient["server.permission"]["session.permission.reply"]>>
|
||||
export type PermissionReplyOperation<E = never> = (input: Endpoint13_6Input) => Effect.Effect<Endpoint13_6Output, E>
|
||||
|
||||
export interface PermissionApi<E = never> {
|
||||
readonly listRequests: PermissionListRequestsOperation<E>
|
||||
|
|
@ -608,100 +534,100 @@ export interface PermissionApi<E = never> {
|
|||
readonly reply: PermissionReplyOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
export type Endpoint15_0Input = {
|
||||
readonly location?: Endpoint15_0Request["query"]["location"]
|
||||
readonly path?: Endpoint15_0Request["query"]["path"]
|
||||
type Endpoint14_0Request = Parameters<RawClient["server.fs"]["fs.list"]>[0]
|
||||
export type Endpoint14_0Input = {
|
||||
readonly location?: Endpoint14_0Request["query"]["location"]
|
||||
readonly path?: Endpoint14_0Request["query"]["path"]
|
||||
}
|
||||
export type Endpoint15_0Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.list"]>>
|
||||
export type FileListOperation<E = never> = (input?: Endpoint15_0Input) => Effect.Effect<Endpoint15_0Output, E>
|
||||
export type Endpoint14_0Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.list"]>>
|
||||
export type FileListOperation<E = never> = (input?: Endpoint14_0Input) => Effect.Effect<Endpoint14_0Output, E>
|
||||
|
||||
type Endpoint15_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
export type Endpoint15_1Input = {
|
||||
readonly location?: Endpoint15_1Request["query"]["location"]
|
||||
readonly query: Endpoint15_1Request["query"]["query"]
|
||||
readonly type?: Endpoint15_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint15_1Request["query"]["limit"]
|
||||
type Endpoint14_1Request = Parameters<RawClient["server.fs"]["fs.find"]>[0]
|
||||
export type Endpoint14_1Input = {
|
||||
readonly location?: Endpoint14_1Request["query"]["location"]
|
||||
readonly query: Endpoint14_1Request["query"]["query"]
|
||||
readonly type?: Endpoint14_1Request["query"]["type"]
|
||||
readonly limit?: Endpoint14_1Request["query"]["limit"]
|
||||
}
|
||||
export type Endpoint15_1Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.find"]>>
|
||||
export type FileFindOperation<E = never> = (input: Endpoint15_1Input) => Effect.Effect<Endpoint15_1Output, E>
|
||||
export type Endpoint14_1Output = EffectValue<ReturnType<RawClient["server.fs"]["fs.find"]>>
|
||||
export type FileFindOperation<E = never> = (input: Endpoint14_1Input) => Effect.Effect<Endpoint14_1Output, E>
|
||||
|
||||
export interface FileApi<E = never> {
|
||||
readonly list: FileListOperation<E>
|
||||
readonly find: FileFindOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
export type Endpoint16_0Output = EffectValue<ReturnType<RawClient["server.command"]["command.list"]>>
|
||||
export type CommandListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||
type Endpoint15_0Request = Parameters<RawClient["server.command"]["command.list"]>[0]
|
||||
export type Endpoint15_0Input = { readonly location?: Endpoint15_0Request["query"]["location"] }
|
||||
export type Endpoint15_0Output = EffectValue<ReturnType<RawClient["server.command"]["command.list"]>>
|
||||
export type CommandListOperation<E = never> = (input?: Endpoint15_0Input) => Effect.Effect<Endpoint15_0Output, E>
|
||||
|
||||
export interface CommandApi<E = never> {
|
||||
readonly list: CommandListOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint17_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
export type Endpoint17_0Input = { readonly location?: Endpoint17_0Request["query"]["location"] }
|
||||
export type Endpoint17_0Output = EffectValue<ReturnType<RawClient["server.skill"]["skill.list"]>>
|
||||
export type SkillListOperation<E = never> = (input?: Endpoint17_0Input) => Effect.Effect<Endpoint17_0Output, E>
|
||||
type Endpoint16_0Request = Parameters<RawClient["server.skill"]["skill.list"]>[0]
|
||||
export type Endpoint16_0Input = { readonly location?: Endpoint16_0Request["query"]["location"] }
|
||||
export type Endpoint16_0Output = EffectValue<ReturnType<RawClient["server.skill"]["skill.list"]>>
|
||||
export type SkillListOperation<E = never> = (input?: Endpoint16_0Input) => Effect.Effect<Endpoint16_0Output, E>
|
||||
|
||||
export interface SkillApi<E = never> {
|
||||
readonly list: SkillListOperation<E>
|
||||
}
|
||||
|
||||
export type Endpoint18_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
|
||||
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint18_0Output, E>
|
||||
export type Endpoint17_0Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.subscribe"]>>>
|
||||
export type EventSubscribeOperation<E = never> = () => Stream.Stream<Endpoint17_0Output, E>
|
||||
|
||||
export type Endpoint18_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
|
||||
export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint18_1Output, E>
|
||||
export type Endpoint17_1Output = StreamValue<EffectValue<ReturnType<RawClient["server.event"]["event.changes"]>>>
|
||||
export type EventChangesOperation<E = never> = () => Stream.Stream<Endpoint17_1Output, E>
|
||||
|
||||
export interface EventApi<E = never> {
|
||||
readonly subscribe: EventSubscribeOperation<E>
|
||||
readonly changes: EventChangesOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
export type Endpoint19_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
|
||||
export type PtyListOperation<E = never> = (input?: Endpoint19_0Input) => Effect.Effect<Endpoint19_0Output, E>
|
||||
type Endpoint18_0Request = Parameters<RawClient["server.pty"]["pty.list"]>[0]
|
||||
export type Endpoint18_0Input = { readonly location?: Endpoint18_0Request["query"]["location"] }
|
||||
export type Endpoint18_0Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.list"]>>
|
||||
export type PtyListOperation<E = never> = (input?: Endpoint18_0Input) => Effect.Effect<Endpoint18_0Output, E>
|
||||
|
||||
type Endpoint19_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
export type Endpoint19_1Input = {
|
||||
readonly location?: Endpoint19_1Request["query"]["location"]
|
||||
readonly command?: Endpoint19_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint19_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint19_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint19_1Request["payload"]["env"]
|
||||
type Endpoint18_1Request = Parameters<RawClient["server.pty"]["pty.create"]>[0]
|
||||
export type Endpoint18_1Input = {
|
||||
readonly location?: Endpoint18_1Request["query"]["location"]
|
||||
readonly command?: Endpoint18_1Request["payload"]["command"]
|
||||
readonly args?: Endpoint18_1Request["payload"]["args"]
|
||||
readonly cwd?: Endpoint18_1Request["payload"]["cwd"]
|
||||
readonly title?: Endpoint18_1Request["payload"]["title"]
|
||||
readonly env?: Endpoint18_1Request["payload"]["env"]
|
||||
}
|
||||
export type Endpoint19_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
|
||||
export type PtyCreateOperation<E = never> = (input?: Endpoint19_1Input) => Effect.Effect<Endpoint19_1Output, E>
|
||||
export type Endpoint18_1Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.create"]>>
|
||||
export type PtyCreateOperation<E = never> = (input?: Endpoint18_1Input) => Effect.Effect<Endpoint18_1Output, E>
|
||||
|
||||
type Endpoint19_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
export type Endpoint19_2Input = {
|
||||
readonly ptyID: Endpoint19_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_2Request["query"]["location"]
|
||||
type Endpoint18_2Request = Parameters<RawClient["server.pty"]["pty.get"]>[0]
|
||||
export type Endpoint18_2Input = {
|
||||
readonly ptyID: Endpoint18_2Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint19_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
|
||||
export type PtyGetOperation<E = never> = (input: Endpoint19_2Input) => Effect.Effect<Endpoint19_2Output, E>
|
||||
export type Endpoint18_2Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.get"]>>
|
||||
export type PtyGetOperation<E = never> = (input: Endpoint18_2Input) => Effect.Effect<Endpoint18_2Output, E>
|
||||
|
||||
type Endpoint19_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
export type Endpoint19_3Input = {
|
||||
readonly ptyID: Endpoint19_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_3Request["query"]["location"]
|
||||
readonly title?: Endpoint19_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint19_3Request["payload"]["size"]
|
||||
type Endpoint18_3Request = Parameters<RawClient["server.pty"]["pty.update"]>[0]
|
||||
export type Endpoint18_3Input = {
|
||||
readonly ptyID: Endpoint18_3Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_3Request["query"]["location"]
|
||||
readonly title?: Endpoint18_3Request["payload"]["title"]
|
||||
readonly size?: Endpoint18_3Request["payload"]["size"]
|
||||
}
|
||||
export type Endpoint19_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
|
||||
export type PtyUpdateOperation<E = never> = (input: Endpoint19_3Input) => Effect.Effect<Endpoint19_3Output, E>
|
||||
export type Endpoint18_3Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.update"]>>
|
||||
export type PtyUpdateOperation<E = never> = (input: Endpoint18_3Input) => Effect.Effect<Endpoint18_3Output, E>
|
||||
|
||||
type Endpoint19_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
export type Endpoint19_4Input = {
|
||||
readonly ptyID: Endpoint19_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint19_4Request["query"]["location"]
|
||||
type Endpoint18_4Request = Parameters<RawClient["server.pty"]["pty.remove"]>[0]
|
||||
export type Endpoint18_4Input = {
|
||||
readonly ptyID: Endpoint18_4Request["params"]["ptyID"]
|
||||
readonly location?: Endpoint18_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint19_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
|
||||
export type PtyRemoveOperation<E = never> = (input: Endpoint19_4Input) => Effect.Effect<Endpoint19_4Output, E>
|
||||
export type Endpoint18_4Output = EffectValue<ReturnType<RawClient["server.pty"]["pty.remove"]>>
|
||||
export type PtyRemoveOperation<E = never> = (input: Endpoint18_4Input) => Effect.Effect<Endpoint18_4Output, E>
|
||||
|
||||
export interface PtyApi<E = never> {
|
||||
readonly list: PtyListOperation<E>
|
||||
|
|
@ -711,47 +637,47 @@ export interface PtyApi<E = never> {
|
|||
readonly remove: PtyRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
|
||||
export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.list"]>>
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint20_0Input) => Effect.Effect<Endpoint20_0Output, E>
|
||||
type Endpoint19_0Request = Parameters<RawClient["server.shell"]["shell.list"]>[0]
|
||||
export type Endpoint19_0Input = { readonly location?: Endpoint19_0Request["query"]["location"] }
|
||||
export type Endpoint19_0Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.list"]>>
|
||||
export type ShellListOperation<E = never> = (input?: Endpoint19_0Input) => Effect.Effect<Endpoint19_0Output, E>
|
||||
|
||||
type Endpoint20_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
export type Endpoint20_1Input = {
|
||||
readonly location?: Endpoint20_1Request["query"]["location"]
|
||||
readonly command: Endpoint20_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint20_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint20_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint20_1Request["payload"]["metadata"]
|
||||
type Endpoint19_1Request = Parameters<RawClient["server.shell"]["shell.create"]>[0]
|
||||
export type Endpoint19_1Input = {
|
||||
readonly location?: Endpoint19_1Request["query"]["location"]
|
||||
readonly command: Endpoint19_1Request["payload"]["command"]
|
||||
readonly cwd?: Endpoint19_1Request["payload"]["cwd"]
|
||||
readonly timeout?: Endpoint19_1Request["payload"]["timeout"]
|
||||
readonly metadata?: Endpoint19_1Request["payload"]["metadata"]
|
||||
}
|
||||
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
|
||||
export type ShellCreateOperation<E = never> = (input: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
|
||||
export type Endpoint19_1Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.create"]>>
|
||||
export type ShellCreateOperation<E = never> = (input: Endpoint19_1Input) => Effect.Effect<Endpoint19_1Output, E>
|
||||
|
||||
type Endpoint20_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
export type Endpoint20_2Input = {
|
||||
readonly id: Endpoint20_2Request["params"]["id"]
|
||||
readonly location?: Endpoint20_2Request["query"]["location"]
|
||||
type Endpoint19_2Request = Parameters<RawClient["server.shell"]["shell.get"]>[0]
|
||||
export type Endpoint19_2Input = {
|
||||
readonly id: Endpoint19_2Request["params"]["id"]
|
||||
readonly location?: Endpoint19_2Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||
export type Endpoint19_2Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.get"]>>
|
||||
export type ShellGetOperation<E = never> = (input: Endpoint19_2Input) => Effect.Effect<Endpoint19_2Output, E>
|
||||
|
||||
type Endpoint20_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
export type Endpoint20_3Input = {
|
||||
readonly id: Endpoint20_3Request["params"]["id"]
|
||||
readonly location?: Endpoint20_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint20_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint20_3Request["query"]["limit"]
|
||||
type Endpoint19_3Request = Parameters<RawClient["server.shell"]["shell.output"]>[0]
|
||||
export type Endpoint19_3Input = {
|
||||
readonly id: Endpoint19_3Request["params"]["id"]
|
||||
readonly location?: Endpoint19_3Request["query"]["location"]
|
||||
readonly cursor?: Endpoint19_3Request["query"]["cursor"]
|
||||
readonly limit?: Endpoint19_3Request["query"]["limit"]
|
||||
}
|
||||
export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
|
||||
export type Endpoint19_3Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.output"]>>
|
||||
export type ShellOutputOperation<E = never> = (input: Endpoint19_3Input) => Effect.Effect<Endpoint19_3Output, E>
|
||||
|
||||
type Endpoint20_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
export type Endpoint20_4Input = {
|
||||
readonly id: Endpoint20_4Request["params"]["id"]
|
||||
readonly location?: Endpoint20_4Request["query"]["location"]
|
||||
type Endpoint19_4Request = Parameters<RawClient["server.shell"]["shell.remove"]>[0]
|
||||
export type Endpoint19_4Input = {
|
||||
readonly id: Endpoint19_4Request["params"]["id"]
|
||||
readonly location?: Endpoint19_4Request["query"]["location"]
|
||||
}
|
||||
export type Endpoint20_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint20_4Input) => Effect.Effect<Endpoint20_4Output, E>
|
||||
export type Endpoint19_4Output = EffectValue<ReturnType<RawClient["server.shell"]["shell.remove"]>>
|
||||
export type ShellRemoveOperation<E = never> = (input: Endpoint19_4Input) => Effect.Effect<Endpoint19_4Output, E>
|
||||
|
||||
export interface ShellApi<E = never> {
|
||||
readonly list: ShellListOperation<E>
|
||||
|
|
@ -761,6 +687,42 @@ export interface ShellApi<E = never> {
|
|||
readonly remove: ShellRemoveOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint20_0Request = Parameters<RawClient["server.question"]["question.request.list"]>[0]
|
||||
export type Endpoint20_0Input = { readonly location?: Endpoint20_0Request["query"]["location"] }
|
||||
export type Endpoint20_0Output = EffectValue<ReturnType<RawClient["server.question"]["question.request.list"]>>
|
||||
export type QuestionListRequestsOperation<E = never> = (
|
||||
input?: Endpoint20_0Input,
|
||||
) => Effect.Effect<Endpoint20_0Output, E>
|
||||
|
||||
type Endpoint20_1Request = Parameters<RawClient["server.question"]["session.question.list"]>[0]
|
||||
export type Endpoint20_1Input = { readonly sessionID: Endpoint20_1Request["params"]["sessionID"] }
|
||||
export type Endpoint20_1Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.list"]>>["data"]
|
||||
export type QuestionListOperation<E = never> = (input: Endpoint20_1Input) => Effect.Effect<Endpoint20_1Output, E>
|
||||
|
||||
type Endpoint20_2Request = Parameters<RawClient["server.question"]["session.question.reply"]>[0]
|
||||
export type Endpoint20_2Input = {
|
||||
readonly sessionID: Endpoint20_2Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint20_2Request["params"]["requestID"]
|
||||
readonly answers: Endpoint20_2Request["payload"]["answers"]
|
||||
}
|
||||
export type Endpoint20_2Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reply"]>>
|
||||
export type QuestionReplyOperation<E = never> = (input: Endpoint20_2Input) => Effect.Effect<Endpoint20_2Output, E>
|
||||
|
||||
type Endpoint20_3Request = Parameters<RawClient["server.question"]["session.question.reject"]>[0]
|
||||
export type Endpoint20_3Input = {
|
||||
readonly sessionID: Endpoint20_3Request["params"]["sessionID"]
|
||||
readonly requestID: Endpoint20_3Request["params"]["requestID"]
|
||||
}
|
||||
export type Endpoint20_3Output = EffectValue<ReturnType<RawClient["server.question"]["session.question.reject"]>>
|
||||
export type QuestionRejectOperation<E = never> = (input: Endpoint20_3Input) => Effect.Effect<Endpoint20_3Output, E>
|
||||
|
||||
export interface QuestionApi<E = never> {
|
||||
readonly listRequests: QuestionListRequestsOperation<E>
|
||||
readonly list: QuestionListOperation<E>
|
||||
readonly reply: QuestionReplyOperation<E>
|
||||
readonly reject: QuestionRejectOperation<E>
|
||||
}
|
||||
|
||||
type Endpoint21_0Request = Parameters<RawClient["server.reference"]["reference.list"]>[0]
|
||||
export type Endpoint21_0Input = { readonly location?: Endpoint21_0Request["query"]["location"] }
|
||||
export type Endpoint21_0Output = EffectValue<ReturnType<RawClient["server.reference"]["reference.list"]>>
|
||||
|
|
@ -819,7 +781,6 @@ export interface AppApi<E = never> {
|
|||
readonly "server.mcp": ServerMcpApi<E>
|
||||
readonly credential: CredentialApi<E>
|
||||
readonly project: ProjectApi<E>
|
||||
readonly form: FormApi<E>
|
||||
readonly permission: PermissionApi<E>
|
||||
readonly file: FileApi<E>
|
||||
readonly command: CommandApi<E>
|
||||
|
|
@ -827,6 +788,7 @@ export interface AppApi<E = never> {
|
|||
readonly event: EventApi<E>
|
||||
readonly pty: PtyApi<E>
|
||||
readonly shell: ShellApi<E>
|
||||
readonly question: QuestionApi<E>
|
||||
readonly reference: ReferenceApi<E>
|
||||
readonly projectCopy: ProjectCopyApi<E>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import { ProviderGroup } from "./groups/provider.js"
|
|||
import { makeSessionGroup } from "./groups/session.js"
|
||||
import { makePermissionGroup } from "./groups/permission.js"
|
||||
import { FileSystemGroup } from "./groups/fs.js"
|
||||
import { makeFormGroup } from "./groups/form.js"
|
||||
import { CommandGroup } from "./groups/command.js"
|
||||
import { SkillGroup } from "./groups/skill.js"
|
||||
import { EventGroup, makeEventGroup } from "./groups/event.js"
|
||||
|
|
@ -18,6 +17,7 @@ import { PluginGroup } from "./groups/plugin.js"
|
|||
import { HealthGroup } from "./groups/health.js"
|
||||
import { PtyGroup } from "./groups/pty.js"
|
||||
import { ShellGroup } from "./groups/shell.js"
|
||||
import { makeQuestionGroup } from "./groups/question.js"
|
||||
import { ReferenceGroup } from "./groups/reference.js"
|
||||
import { Authorization } from "./middleware/authorization.js"
|
||||
import { LocationGroup } from "./groups/location.js"
|
||||
|
|
@ -50,35 +50,26 @@ type SessionGroups<SessionLocationId extends HttpApiMiddleware.AnyId, SessionLoc
|
|||
| ReturnType<typeof makeSessionGroup<SessionLocationId, SessionLocationService>>
|
||||
| HttpApiGroup.AddMiddleware<typeof MessageGroup, SessionLocationId>
|
||||
|
||||
type FormGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
> = ReturnType<
|
||||
typeof makeFormGroup<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
>
|
||||
|
||||
type MixedMiddlewareGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
> =
|
||||
ReturnType<typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
|
||||
| ReturnType<
|
||||
typeof makePermissionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>
|
||||
>
|
||||
| ReturnType<typeof makeQuestionGroup<LocationId, LocationService, SessionLocationId, SessionLocationService>>
|
||||
|
||||
type ApiGroups<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
Event extends HttpApiGroup.Any,
|
||||
> =
|
||||
| typeof HealthGroup
|
||||
| LocationGroups<LocationId>
|
||||
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
|
||||
| SessionGroups<SessionLocationId, SessionLocationService>
|
||||
| MixedMiddlewareGroups<LocationId, LocationService, SessionLocationId, SessionLocationService>
|
||||
| Event
|
||||
|
|
@ -88,8 +79,6 @@ type EventGroupFor<Definitions extends ReadonlyArray<Definition>> = ReturnType<t
|
|||
export type Api<
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
Event extends HttpApiGroup.Any,
|
||||
|
|
@ -97,7 +86,7 @@ export type Api<
|
|||
"server",
|
||||
HttpApiGroup.AddMiddleware<
|
||||
HttpApiGroup.AddMiddleware<
|
||||
ApiGroups<LocationId, LocationService, FormLocationId, FormLocationService, SessionLocationId, SessionLocationService, Event>,
|
||||
ApiGroups<LocationId, LocationService, SessionLocationId, SessionLocationService, Event>,
|
||||
Authorization
|
||||
>,
|
||||
SchemaErrorMiddleware
|
||||
|
|
@ -109,16 +98,13 @@ const makeApiFromGroup = <
|
|||
const Group extends HttpApiGroup.Any,
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
>(
|
||||
eventGroup: Group,
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
|
||||
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
|
||||
): Api<LocationId, LocationService, FormLocationId, FormLocationService, SessionLocationId, SessionLocationService, Group> =>
|
||||
): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, Group> =>
|
||||
HttpApi.make("server")
|
||||
.add(HealthGroup)
|
||||
.add(LocationGroup.middleware(locationMiddleware))
|
||||
|
|
@ -133,7 +119,6 @@ const makeApiFromGroup = <
|
|||
.add(McpGroup.middleware(locationMiddleware))
|
||||
.add(CredentialGroup.middleware(locationMiddleware))
|
||||
.add(ProjectGroup.middleware(locationMiddleware))
|
||||
.add(makeFormGroup(locationMiddleware, formLocationMiddleware))
|
||||
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(FileSystemGroup.middleware(locationMiddleware))
|
||||
.add(CommandGroup.middleware(locationMiddleware))
|
||||
|
|
@ -141,6 +126,7 @@ const makeApiFromGroup = <
|
|||
.add(eventGroup)
|
||||
.add(PtyGroup.middleware(locationMiddleware))
|
||||
.add(ShellGroup.middleware(locationMiddleware))
|
||||
.add(makeQuestionGroup(locationMiddleware, sessionLocationMiddleware))
|
||||
.add(ReferenceGroup.middleware(locationMiddleware))
|
||||
.add(ProjectCopyGroup.middleware(locationMiddleware))
|
||||
.annotateMerge(
|
||||
|
|
@ -157,48 +143,22 @@ export const makeApi = <
|
|||
const Definitions extends ReadonlyArray<Definition>,
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
>(options: {
|
||||
readonly definitions: Definitions
|
||||
readonly locationMiddleware: Context.Key<LocationId, LocationService>
|
||||
readonly formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>
|
||||
readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
|
||||
}): Api<
|
||||
LocationId,
|
||||
LocationService,
|
||||
FormLocationId,
|
||||
FormLocationService,
|
||||
SessionLocationId,
|
||||
SessionLocationService,
|
||||
EventGroupFor<Definitions>
|
||||
> =>
|
||||
makeApiFromGroup(
|
||||
makeEventGroup(options.definitions),
|
||||
options.locationMiddleware,
|
||||
options.formLocationMiddleware,
|
||||
options.sessionLocationMiddleware,
|
||||
)
|
||||
}): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, EventGroupFor<Definitions>> =>
|
||||
makeApiFromGroup(makeEventGroup(options.definitions), options.locationMiddleware, options.sessionLocationMiddleware)
|
||||
|
||||
export const makeDefaultApi = <
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
FormLocationId extends HttpApiMiddleware.AnyId,
|
||||
FormLocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
>(options: {
|
||||
readonly locationMiddleware: Context.Key<LocationId, LocationService>
|
||||
readonly formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>
|
||||
readonly sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>
|
||||
}): Api<
|
||||
LocationId,
|
||||
LocationService,
|
||||
FormLocationId,
|
||||
FormLocationService,
|
||||
SessionLocationId,
|
||||
SessionLocationService,
|
||||
typeof EventGroup
|
||||
> => makeApiFromGroup(EventGroup, options.locationMiddleware, options.formLocationMiddleware, options.sessionLocationMiddleware)
|
||||
}): Api<LocationId, LocationService, SessionLocationId, SessionLocationService, typeof EventGroup> =>
|
||||
makeApiFromGroup(EventGroup, options.locationMiddleware, options.sessionLocationMiddleware)
|
||||
|
|
|
|||
|
|
@ -19,16 +19,11 @@ type ClientApiShape = Api<
|
|||
Context.Service.Shape<typeof LocationMiddleware>,
|
||||
Context.Service.Identifier<typeof SessionLocationMiddleware>,
|
||||
Context.Service.Shape<typeof SessionLocationMiddleware>,
|
||||
Context.Service.Identifier<typeof SessionLocationMiddleware>,
|
||||
Context.Service.Shape<typeof SessionLocationMiddleware>,
|
||||
typeof EventGroup
|
||||
>
|
||||
|
||||
export const ClientApi: ClientApiShape = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
// The real server uses a form-specific middleware with an undocumented `global` sentinel branch.
|
||||
// The generated client only needs a middleware identity for API typing.
|
||||
formLocationMiddleware: SessionLocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
||||
|
|
@ -45,13 +40,13 @@ export const groupNames = {
|
|||
"server.integration": "integration",
|
||||
"server.credential": "credential",
|
||||
"server.permission": "permission",
|
||||
"server.form": "form",
|
||||
"server.fs": "file",
|
||||
"server.command": "command",
|
||||
"server.skill": "skill",
|
||||
"server.event": "event",
|
||||
"server.pty": "pty",
|
||||
"server.shell": "shell",
|
||||
"server.question": "question",
|
||||
"server.reference": "reference",
|
||||
"server.project": "project",
|
||||
"server.projectCopy": "projectCopy",
|
||||
|
|
@ -73,7 +68,7 @@ export const endpointNames = {
|
|||
"permission.request.list": "listRequests",
|
||||
"permission.saved.list": "listSaved",
|
||||
"permission.saved.remove": "removeSaved",
|
||||
"form.request.list": "listRequests",
|
||||
"question.request.list": "listRequests",
|
||||
} as const
|
||||
|
||||
export const promiseOmitEndpoints = new Set(["pty.connect", "pty.connectToken"])
|
||||
|
|
|
|||
|
|
@ -122,33 +122,15 @@ export class PermissionNotFoundError extends Schema.TaggedErrorClass<PermissionN
|
|||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class FormNotFoundError extends Schema.TaggedErrorClass<FormNotFoundError>()(
|
||||
"FormNotFoundError",
|
||||
export class QuestionNotFoundError extends Schema.TaggedErrorClass<QuestionNotFoundError>()(
|
||||
"QuestionNotFoundError",
|
||||
{
|
||||
id: Schema.String,
|
||||
requestID: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 404 },
|
||||
) {}
|
||||
|
||||
export class FormAlreadySettledError extends Schema.TaggedErrorClass<FormAlreadySettledError>()(
|
||||
"FormAlreadySettledError",
|
||||
{
|
||||
id: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 409 },
|
||||
) {}
|
||||
|
||||
export class FormInvalidAnswerError extends Schema.TaggedErrorClass<FormInvalidAnswerError>()(
|
||||
"FormInvalidAnswerError",
|
||||
{
|
||||
id: Schema.String,
|
||||
message: Schema.String,
|
||||
},
|
||||
{ httpApiStatus: 400 },
|
||||
) {}
|
||||
|
||||
export class ForbiddenError extends Schema.TaggedErrorClass<ForbiddenError>()(
|
||||
"ForbiddenError",
|
||||
{ message: Schema.String },
|
||||
|
|
|
|||
|
|
@ -1,151 +0,0 @@
|
|||
import { Form } from "@opencode-ai/schema/form"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { ConflictError, FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError, InvalidRequestError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
const CreatePayload = Schema.Struct({
|
||||
id: Form.ID.pipe(Schema.optional),
|
||||
title: Form.FormInfo.fields.title,
|
||||
metadata: Form.FormInfo.fields.metadata,
|
||||
mode: Schema.Literals(["form", "url"]),
|
||||
fields: Form.FormInfo.fields.fields.pipe(Schema.optional),
|
||||
url: Form.UrlInfo.fields.url.pipe(Schema.optional),
|
||||
}).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,
|
||||
>(
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
formLocationMiddleware: Context.Key<FormLocationId, FormLocationService>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.form")
|
||||
.add(
|
||||
HttpApiEndpoint.get("form.request.list", "/api/form/request", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Form.Info)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.form.request.list",
|
||||
summary: "List pending form requests",
|
||||
description: "Retrieve pending forms for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.middleware(locationMiddleware)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.form.list", "/api/session/:sessionID/form", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Form.Info)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.list",
|
||||
summary: "List session forms",
|
||||
description: "Retrieve pending forms for a session.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.form.create", "/api/session/:sessionID/form", {
|
||||
params: { sessionID: Schema.String },
|
||||
query: LocationQuery,
|
||||
payload: CreatePayload,
|
||||
success: Location.response(Form.Info),
|
||||
error: [ConflictError, InvalidRequestError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.create",
|
||||
summary: "Create session form",
|
||||
description: "Create a form for a session.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.form.get", "/api/session/:sessionID/form/:formID", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
query: LocationQuery,
|
||||
success: Location.response(Form.Info),
|
||||
error: FormNotFoundError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.get",
|
||||
summary: "Get session form",
|
||||
description: "Retrieve a form for a session.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.form.state", "/api/session/:sessionID/form/:formID/state", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
query: LocationQuery,
|
||||
success: Location.response(Form.State),
|
||||
error: FormNotFoundError,
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.state",
|
||||
summary: "Get form state",
|
||||
description: "Retrieve the current state for a form.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.form.reply", "/api/session/:sessionID/form/:formID/reply", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
query: LocationQuery,
|
||||
payload: Form.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [FormAlreadySettledError, FormInvalidAnswerError, FormNotFoundError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.reply",
|
||||
summary: "Reply to form",
|
||||
description: "Submit an answer to a pending form.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.form.cancel", "/api/session/:sessionID/form/:formID/cancel", {
|
||||
params: { sessionID: Schema.String, formID: Form.ID },
|
||||
query: LocationQuery,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [FormAlreadySettledError, FormNotFoundError],
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.form.cancel",
|
||||
summary: "Cancel form",
|
||||
description: "Cancel a pending form.",
|
||||
}),
|
||||
)
|
||||
.middleware(formLocationMiddleware),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "forms", description: "Session form routes." }))
|
||||
84
packages/protocol/src/groups/question.ts
Normal file
84
packages/protocol/src/groups/question.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import { Question } from "@opencode-ai/schema/question"
|
||||
import { Location } from "@opencode-ai/schema/location"
|
||||
import { Session } from "@opencode-ai/schema/session"
|
||||
import { Context, Schema } from "effect"
|
||||
import { HttpApiEndpoint, HttpApiGroup, HttpApiMiddleware, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
|
||||
import { QuestionNotFoundError, SessionNotFoundError } from "../errors.js"
|
||||
import { LocationQuery, locationQueryOpenApi } from "./location.js"
|
||||
|
||||
export const makeQuestionGroup = <
|
||||
LocationId extends HttpApiMiddleware.AnyId,
|
||||
LocationService,
|
||||
SessionLocationId extends HttpApiMiddleware.AnyId,
|
||||
SessionLocationService,
|
||||
>(
|
||||
locationMiddleware: Context.Key<LocationId, LocationService>,
|
||||
sessionLocationMiddleware: Context.Key<SessionLocationId, SessionLocationService>,
|
||||
) =>
|
||||
HttpApiGroup.make("server.question")
|
||||
.add(
|
||||
HttpApiEndpoint.get("question.request.list", "/api/question/request", {
|
||||
query: LocationQuery,
|
||||
success: Location.response(Schema.Array(Question.Request)),
|
||||
})
|
||||
.annotateMerge(locationQueryOpenApi)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.question.request.list",
|
||||
summary: "List pending question requests",
|
||||
description: "Retrieve pending question requests for a location.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(OpenApi.annotations({ title: "questions", description: "Experimental question routes." }))
|
||||
// Effect applies group middleware only to endpoints already added; session endpoints use session placement below.
|
||||
.middleware(locationMiddleware)
|
||||
.add(
|
||||
HttpApiEndpoint.get("session.question.list", "/api/session/:sessionID/question", {
|
||||
params: { sessionID: Session.ID },
|
||||
success: Schema.Struct({ data: Schema.Array(Question.Request) }),
|
||||
error: SessionNotFoundError,
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.list",
|
||||
summary: "List session question requests",
|
||||
description: "Retrieve pending question requests owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.question.reply", "/api/session/:sessionID/question/:requestID/reply", {
|
||||
params: { sessionID: Session.ID, requestID: Question.ID },
|
||||
payload: Question.Reply,
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reply",
|
||||
summary: "Reply to pending question request",
|
||||
description: "Answer a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.post("session.question.reject", "/api/session/:sessionID/question/:requestID/reject", {
|
||||
params: { sessionID: Session.ID, requestID: Question.ID },
|
||||
success: HttpApiSchema.NoContent,
|
||||
error: [SessionNotFoundError, QuestionNotFoundError],
|
||||
})
|
||||
.middleware(sessionLocationMiddleware)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({
|
||||
identifier: "v2.session.question.reject",
|
||||
summary: "Reject pending question request",
|
||||
description: "Reject a pending question request owned by a session.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
.annotateMerge(
|
||||
OpenApi.annotations({ title: "session questions", description: "Experimental session question routes." }),
|
||||
)
|
||||
|
|
@ -7,7 +7,6 @@ import { Durable } from "./durable-event-manifest.js"
|
|||
import { Event } from "./event.js"
|
||||
import { FileSystem } from "./filesystem.js"
|
||||
import { FileSystemWatcher } from "./filesystem-watcher.js"
|
||||
import { Form } from "./form.js"
|
||||
import { InstallationEvent } from "./installation-event.js"
|
||||
import { Integration } from "./integration.js"
|
||||
import { LegacyEvent } from "./legacy-event.js"
|
||||
|
|
@ -20,6 +19,7 @@ import { Plugin } from "./plugin.js"
|
|||
import { Project } from "./project.js"
|
||||
import { ProjectDirectories } from "./project-directories.js"
|
||||
import { Pty } from "./pty.js"
|
||||
import { Question } from "./question.js"
|
||||
import { QuestionV1 } from "./question-v1.js"
|
||||
import { Reference } from "./reference.js"
|
||||
import { ServerEvent } from "./server-event.js"
|
||||
|
|
@ -59,7 +59,7 @@ const featureDefinitions = Event.inventory(
|
|||
...FileSystemWatcher.Event.Definitions,
|
||||
...Pty.Event.Definitions,
|
||||
...Shell.Event.Definitions,
|
||||
...Form.Event.Definitions,
|
||||
...Question.Event.Definitions,
|
||||
)
|
||||
|
||||
export const ServerDefinitions = Event.inventory(
|
||||
|
|
|
|||
|
|
@ -1,149 +0,0 @@
|
|||
export * as Form from "./form.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { define, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { NonNegativeInt, optional, statics } from "./schema.js"
|
||||
|
||||
const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
|
||||
|
||||
export const ID = IDSchema.pipe(
|
||||
statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "frm_" + ascending()) })),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Metadata = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Form.Metadata" })
|
||||
export type Metadata = typeof Metadata.Type
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
value: Schema.String,
|
||||
label: Schema.String,
|
||||
description: Schema.String.pipe(optional),
|
||||
}).annotate({ identifier: "Form.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
|
||||
export const When = Schema.Struct({
|
||||
key: Schema.String,
|
||||
op: Schema.Literals(["eq", "neq"]),
|
||||
value: Schema.String,
|
||||
}).annotate({ identifier: "Form.When" })
|
||||
export interface When extends Schema.Schema.Type<typeof When> {}
|
||||
|
||||
const FieldBase = {
|
||||
key: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
description: Schema.String.pipe(optional),
|
||||
required: Schema.Boolean.pipe(optional),
|
||||
when: When.pipe(optional),
|
||||
}
|
||||
|
||||
export const StringField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("string"),
|
||||
format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
|
||||
minLength: NonNegativeInt.pipe(optional),
|
||||
maxLength: NonNegativeInt.pipe(optional),
|
||||
pattern: Schema.String.pipe(optional),
|
||||
placeholder: Schema.String.pipe(optional),
|
||||
default: Schema.String.pipe(optional),
|
||||
options: Schema.Array(Option).pipe(optional),
|
||||
custom: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Form.StringField" })
|
||||
export interface StringField extends Schema.Schema.Type<typeof StringField> {}
|
||||
|
||||
export const NumberField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("number"),
|
||||
minimum: Schema.Number.pipe(optional),
|
||||
maximum: Schema.Number.pipe(optional),
|
||||
default: Schema.Number.pipe(optional),
|
||||
}).annotate({ identifier: "Form.NumberField" })
|
||||
export interface NumberField extends Schema.Schema.Type<typeof NumberField> {}
|
||||
|
||||
export const IntegerField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("integer"),
|
||||
minimum: Schema.Number.pipe(optional),
|
||||
maximum: Schema.Number.pipe(optional),
|
||||
default: Schema.Number.pipe(optional),
|
||||
}).annotate({ identifier: "Form.IntegerField" })
|
||||
export interface IntegerField extends Schema.Schema.Type<typeof IntegerField> {}
|
||||
|
||||
export const BooleanField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("boolean"),
|
||||
default: Schema.Boolean.pipe(optional),
|
||||
}).annotate({ identifier: "Form.BooleanField" })
|
||||
export interface BooleanField extends Schema.Schema.Type<typeof BooleanField> {}
|
||||
|
||||
export const MultiselectField = Schema.Struct({
|
||||
...FieldBase,
|
||||
type: Schema.Literal("multiselect"),
|
||||
options: Schema.Array(Option),
|
||||
minItems: NonNegativeInt.pipe(optional),
|
||||
maxItems: NonNegativeInt.pipe(optional),
|
||||
custom: Schema.Boolean.pipe(optional),
|
||||
default: Schema.Array(Schema.String).pipe(optional),
|
||||
}).annotate({ identifier: "Form.MultiselectField" })
|
||||
export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
|
||||
|
||||
export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
|
||||
Schema.toTaggedUnion("type"),
|
||||
)
|
||||
export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
|
||||
|
||||
const InfoBase = {
|
||||
id: ID,
|
||||
// Public form flows are session-owned. This is intentionally `string` because the server
|
||||
// currently accepts an undocumented `global` sentinel for MCP elicitation forms that cannot
|
||||
// be attributed to a concrete session yet. Do not document or rely on `global` outside our
|
||||
// own clients; remove the sentinel path once MCP elicitation can carry session ownership.
|
||||
sessionID: Schema.String,
|
||||
title: Schema.String.pipe(optional),
|
||||
metadata: Metadata.pipe(optional),
|
||||
}
|
||||
|
||||
export const FormInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("form"),
|
||||
fields: Schema.Array(Field),
|
||||
}).annotate({ identifier: "Form.FormInfo" })
|
||||
export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
|
||||
|
||||
export const UrlInfo = Schema.Struct({
|
||||
...InfoBase,
|
||||
mode: Schema.Literal("url"),
|
||||
url: Schema.String,
|
||||
}).annotate({ identifier: "Form.UrlInfo" })
|
||||
export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
|
||||
|
||||
export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
|
||||
export type Info = FormInfo | UrlInfo
|
||||
|
||||
export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({
|
||||
identifier: "Form.Value",
|
||||
})
|
||||
export type Value = typeof Value.Type
|
||||
|
||||
export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const State = Schema.Union([
|
||||
Schema.Struct({ status: Schema.Literal("pending") }),
|
||||
Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
|
||||
Schema.Struct({ status: Schema.Literal("cancelled") }),
|
||||
])
|
||||
.pipe(Schema.toTaggedUnion("status"))
|
||||
.annotate({ identifier: "Form.State" })
|
||||
export type State = typeof State.Type
|
||||
|
||||
export const Reply = Schema.Struct({
|
||||
answer: Answer,
|
||||
}).annotate({ identifier: "Form.Reply" })
|
||||
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
|
||||
|
||||
const Created = define({ type: "form.created", schema: { form: Info } })
|
||||
const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: Schema.String, answer: Answer } })
|
||||
const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } })
|
||||
|
||||
export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }
|
||||
|
|
@ -4,7 +4,6 @@ export { Connection } from "./connection.js"
|
|||
export { Credential } from "./credential.js"
|
||||
export { Event } from "./event.js"
|
||||
export { FileSystem } from "./filesystem.js"
|
||||
export { Form } from "./form.js"
|
||||
export { Integration } from "./integration.js"
|
||||
export { LLM } from "./llm.js"
|
||||
export { Location } from "./location.js"
|
||||
|
|
@ -23,6 +22,7 @@ export { Shell } from "./shell.js"
|
|||
export { Skill } from "./skill.js"
|
||||
export { Pty } from "./pty.js"
|
||||
export { PtyTicket } from "./pty-ticket.js"
|
||||
export { Question } from "./question.js"
|
||||
export { Workspace } from "./workspace.js"
|
||||
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js"
|
||||
export { PromptInput } from "./prompt-input.js"
|
||||
|
|
|
|||
86
packages/schema/src/question.ts
Normal file
86
packages/schema/src/question.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
export * as Question from "./question.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { optional } from "./schema.js"
|
||||
import { define, inventory } from "./event.js"
|
||||
import { ascending } from "./identifier.js"
|
||||
import { SessionID } from "./session-id.js"
|
||||
import { statics } from "./schema.js"
|
||||
|
||||
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
|
||||
Schema.brand("QuestionV2.ID"),
|
||||
statics((schema) => {
|
||||
const create = () => schema.make("que_" + ascending())
|
||||
return {
|
||||
create,
|
||||
ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
|
||||
}
|
||||
}),
|
||||
)
|
||||
export type ID = typeof ID.Type
|
||||
|
||||
export const Option = Schema.Struct({
|
||||
label: Schema.String.annotate({ description: "Display text (1-5 words, concise)" }),
|
||||
description: Schema.String.annotate({ description: "Explanation of choice" }),
|
||||
}).annotate({ identifier: "QuestionV2.Option" })
|
||||
export interface Option extends Schema.Schema.Type<typeof Option> {}
|
||||
|
||||
const base = {
|
||||
question: Schema.String.annotate({ description: "Complete question" }),
|
||||
header: Schema.String.annotate({ description: "Very short label (max 30 chars)" }),
|
||||
options: Schema.Array(Option).annotate({ description: "Available choices" }),
|
||||
multiple: Schema.Boolean.pipe(optional).annotate({ description: "Allow selecting multiple choices" }),
|
||||
}
|
||||
|
||||
export const Info = Schema.Struct({
|
||||
...base,
|
||||
custom: Schema.Boolean.pipe(optional).annotate({
|
||||
description: "Allow typing a custom answer (default: true)",
|
||||
}),
|
||||
}).annotate({ identifier: "QuestionV2.Info" })
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
|
||||
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
|
||||
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
|
||||
|
||||
export const Tool = Schema.Struct({
|
||||
messageID: Schema.String,
|
||||
callID: Schema.String,
|
||||
}).annotate({ identifier: "QuestionV2.Tool" })
|
||||
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
|
||||
|
||||
export const Request = Schema.Struct({
|
||||
id: ID,
|
||||
sessionID: SessionID,
|
||||
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
|
||||
tool: Tool.pipe(optional),
|
||||
}).annotate({ identifier: "QuestionV2.Request" })
|
||||
export interface Request extends Schema.Schema.Type<typeof Request> {}
|
||||
|
||||
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
|
||||
export type Answer = typeof Answer.Type
|
||||
|
||||
export const Reply = Schema.Struct({
|
||||
answers: Schema.Array(Answer).annotate({
|
||||
description: "User answers in order of questions (each answer is an array of selected labels)",
|
||||
}),
|
||||
}).annotate({ identifier: "QuestionV2.Reply" })
|
||||
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
|
||||
|
||||
const Asked = define({ type: "question.v2.asked", schema: Request.fields })
|
||||
const Replied = define({
|
||||
type: "question.v2.replied",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
requestID: ID,
|
||||
answers: Schema.Array(Answer),
|
||||
},
|
||||
})
|
||||
const Rejected = define({
|
||||
type: "question.v2.rejected",
|
||||
schema: {
|
||||
sessionID: SessionID,
|
||||
requestID: ID,
|
||||
},
|
||||
})
|
||||
export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }
|
||||
|
|
@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"
|
|||
import { Schema } from "effect"
|
||||
import { Agent } from "../src/agent.js"
|
||||
import { FileSystem } from "../src/filesystem.js"
|
||||
import { Form } from "../src/form.js"
|
||||
import { Model } from "../src/model.js"
|
||||
import { Project } from "../src/project.js"
|
||||
import { Pty } from "../src/pty.js"
|
||||
import { Question } from "../src/question.js"
|
||||
import { Session } from "../src/session.js"
|
||||
import { SessionTodo } from "../src/session-todo.js"
|
||||
import { optional } from "../src/schema.js"
|
||||
|
|
@ -28,7 +28,7 @@ describe("contract hygiene", () => {
|
|||
})
|
||||
|
||||
test("current ID constructors expose create", () => {
|
||||
expect(Form.ID.create()).toStartWith("frm_")
|
||||
expect(Question.ID.create()).toStartWith("que_")
|
||||
expect(Pty.ID.create()).toStartWith("pty_")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
|
||||
import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
|
||||
import { EventManifest } from "../src/event-manifest.js"
|
||||
import { IdeEvent } from "../src/ide-event.js"
|
||||
import { SessionEvent } from "../src/session-event.js"
|
||||
|
|
@ -9,11 +9,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js"
|
|||
|
||||
describe("public event manifest", () => {
|
||||
test("owns the complete public event surface", () => {
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(65)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
|
||||
expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(96)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
|
||||
expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
|
||||
Agent.Event.Updated,
|
||||
])
|
||||
|
|
@ -29,7 +29,7 @@ describe("public event manifest", () => {
|
|||
SessionV1.Event.Diff,
|
||||
SessionV1.Event.Error,
|
||||
])
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(96)
|
||||
expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(Agent.Event.Updated.durable).toBeUndefined()
|
||||
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
|
||||
|
|
@ -43,16 +43,12 @@ describe("public event manifest", () => {
|
|||
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
|
||||
expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
|
||||
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
|
||||
expect(EventManifest.Latest.get("form.created")).toBe(Form.Event.Created)
|
||||
expect(EventManifest.Latest.get("form.replied")).toBe(Form.Event.Replied)
|
||||
expect(EventManifest.Latest.get("form.cancelled")).toBe(Form.Event.Cancelled)
|
||||
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
|
||||
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
|
||||
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
|
||||
expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
|
||||
expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
|
||||
expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
|
||||
expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
|
||||
expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
|
||||
expect(EventManifest.Latest.has("ide.installed")).toBe(false)
|
||||
expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])
|
||||
|
|
|
|||
|
|
@ -91,7 +91,8 @@ await createClient({
|
|||
],
|
||||
})
|
||||
|
||||
const generatedTypes = await Bun.file("./src/v2/gen/types.gen.ts").text()
|
||||
const generatedTypesPath = "./src/v2/gen/types.gen.ts"
|
||||
const generatedTypes = await Bun.file(generatedTypesPath).text()
|
||||
if (/export type SessionNext\w+1 =/.test(generatedTypes)) {
|
||||
throw new Error("Session history generated duplicate Session event variants")
|
||||
}
|
||||
|
|
@ -102,7 +103,28 @@ const logTypesPatched = generatedTypes.replace(
|
|||
if (logTypesPatched === generatedTypes) {
|
||||
throw new Error("Session log numeric query patch did not apply")
|
||||
}
|
||||
await Bun.write("./src/v2/gen/types.gen.ts", logTypesPatched)
|
||||
const sessionListTypesPatched = logTypesPatched.replace(
|
||||
/(export type V2SessionListData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionListTypesPatched === logTypesPatched) {
|
||||
throw new Error("Session list numeric query patch did not apply")
|
||||
}
|
||||
const sessionMessagesTypesPatched = sessionListTypesPatched.replace(
|
||||
/(export type V2SessionMessagesData = \{[\s\S]*?query\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionMessagesTypesPatched === sessionListTypesPatched) {
|
||||
throw new Error("Session messages numeric query patch did not apply")
|
||||
}
|
||||
const eventSubscribeTypesPatched = sessionMessagesTypesPatched.replace(
|
||||
/(export type V2EventSubscribeResponses = \{\s*\/\*\*[\s\S]*?\*\/\s*200: )\{\s*id: string \| null;?\s*event: string;?\s*data: V2EventStreamV2;?\s*\};?/,
|
||||
"$1V2Event",
|
||||
)
|
||||
if (eventSubscribeTypesPatched === sessionMessagesTypesPatched) {
|
||||
throw new Error("Event subscribe response patch did not apply")
|
||||
}
|
||||
await Bun.write(generatedTypesPath, eventSubscribeTypesPatched)
|
||||
|
||||
const querySerializerPath = "./src/v2/gen/client/utils.gen.ts"
|
||||
const querySerializerSource = await Bun.file(querySerializerPath).text()
|
||||
|
|
@ -117,7 +139,8 @@ if (querySerializerPatched === querySerializerSource) {
|
|||
}
|
||||
await Bun.write(querySerializerPath, querySerializerPatched)
|
||||
|
||||
const generatedSdk = await Bun.file("./src/v2/gen/sdk.gen.ts").text()
|
||||
const generatedSdkPath = "./src/v2/gen/sdk.gen.ts"
|
||||
const generatedSdk = await Bun.file(generatedSdkPath).text()
|
||||
const logSdkPatched = generatedSdk.replace(
|
||||
/(Read the session log[\s\S]*?parameters: \{[\s\S]*?after\?: )string(\s*\|\s*null)?/,
|
||||
"$1number$2",
|
||||
|
|
@ -125,7 +148,21 @@ const logSdkPatched = generatedSdk.replace(
|
|||
if (logSdkPatched === generatedSdk) {
|
||||
throw new Error("Session log numeric SDK patch did not apply")
|
||||
}
|
||||
await Bun.write("./src/v2/gen/sdk.gen.ts", logSdkPatched)
|
||||
const sessionListSdkPatched = logSdkPatched.replace(
|
||||
/(List sessions[\s\S]*?parameters\?: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionListSdkPatched === logSdkPatched) {
|
||||
throw new Error("Session list numeric SDK patch did not apply")
|
||||
}
|
||||
const sessionMessagesSdkPatched = sessionListSdkPatched.replace(
|
||||
/(Get session messages[\s\S]*?parameters: \{[\s\S]*?limit\?: )string( \| null)/,
|
||||
"$1number$2",
|
||||
)
|
||||
if (sessionMessagesSdkPatched === sessionListSdkPatched) {
|
||||
throw new Error("Session messages numeric SDK patch did not apply")
|
||||
}
|
||||
await Bun.write(generatedSdkPath, sessionMessagesSdkPatched)
|
||||
|
||||
// Patch a @hey-api/openapi-ts codegen bug: SseFn incorrectly passes the
|
||||
// endpoint's TError into the second generic of ServerSentEventsResult, which
|
||||
|
|
|
|||
|
|
@ -76,8 +76,6 @@ import type {
|
|||
FindTextResponses,
|
||||
FormatterStatusErrors,
|
||||
FormatterStatusResponses,
|
||||
FormCreatePayload,
|
||||
FormReply,
|
||||
GlobalConfigGetErrors,
|
||||
GlobalConfigGetResponses,
|
||||
GlobalConfigUpdateErrors,
|
||||
|
|
@ -145,8 +143,8 @@ import type {
|
|||
ProjectUpdateErrors,
|
||||
ProjectUpdateResponses,
|
||||
PromptAgentAttachment2,
|
||||
PromptInput,
|
||||
PromptInputFileAttachment,
|
||||
PromptInputFileAttachment2,
|
||||
PromptInputV2,
|
||||
ProviderAuthErrors,
|
||||
ProviderAuthResponses,
|
||||
ProviderListErrors,
|
||||
|
|
@ -178,13 +176,14 @@ import type {
|
|||
QuestionRejectResponses,
|
||||
QuestionReplyErrors,
|
||||
QuestionReplyResponses,
|
||||
QuestionV2Reply2,
|
||||
SessionAbortErrors,
|
||||
SessionAbortResponses,
|
||||
SessionChildrenErrors,
|
||||
SessionChildrenResponses,
|
||||
SessionCommandErrors,
|
||||
SessionCommandResponses,
|
||||
SessionContextEntryKey,
|
||||
SessionContextEntryKey2,
|
||||
SessionCreateErrors,
|
||||
SessionCreateResponses,
|
||||
SessionDeleteErrors,
|
||||
|
|
@ -279,8 +278,6 @@ import type {
|
|||
V2EventChangesResponses,
|
||||
V2EventSubscribeErrors,
|
||||
V2EventSubscribeResponses,
|
||||
V2FormRequestListErrors,
|
||||
V2FormRequestListResponses,
|
||||
V2FsFindErrors,
|
||||
V2FsFindResponses,
|
||||
V2FsListErrors,
|
||||
|
|
@ -349,6 +346,8 @@ import type {
|
|||
V2PtyRemoveResponses,
|
||||
V2PtyUpdateErrors,
|
||||
V2PtyUpdateResponses,
|
||||
V2QuestionRequestListErrors,
|
||||
V2QuestionRequestListResponses,
|
||||
V2ReferenceListErrors,
|
||||
V2ReferenceListResponses,
|
||||
V2SessionActiveErrors,
|
||||
|
|
@ -371,18 +370,6 @@ import type {
|
|||
V2SessionCreateResponses,
|
||||
V2SessionForkErrors,
|
||||
V2SessionForkResponses,
|
||||
V2SessionFormCancelErrors,
|
||||
V2SessionFormCancelResponses,
|
||||
V2SessionFormCreateErrors,
|
||||
V2SessionFormCreateResponses,
|
||||
V2SessionFormGetErrors,
|
||||
V2SessionFormGetResponses,
|
||||
V2SessionFormListErrors,
|
||||
V2SessionFormListResponses,
|
||||
V2SessionFormReplyErrors,
|
||||
V2SessionFormReplyResponses,
|
||||
V2SessionFormStateErrors,
|
||||
V2SessionFormStateResponses,
|
||||
V2SessionGetErrors,
|
||||
V2SessionGetResponses,
|
||||
V2SessionInterruptErrors,
|
||||
|
|
@ -405,6 +392,12 @@ import type {
|
|||
V2SessionPermissionReplyResponses,
|
||||
V2SessionPromptErrors,
|
||||
V2SessionPromptResponses,
|
||||
V2SessionQuestionListErrors,
|
||||
V2SessionQuestionListResponses,
|
||||
V2SessionQuestionRejectErrors,
|
||||
V2SessionQuestionRejectResponses,
|
||||
V2SessionQuestionReplyErrors,
|
||||
V2SessionQuestionReplyResponses,
|
||||
V2SessionRenameErrors,
|
||||
V2SessionRenameResponses,
|
||||
V2SessionRevertClearErrors,
|
||||
|
|
@ -5274,7 +5267,7 @@ export class Entry extends HeyApiClient {
|
|||
public remove<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
key: SessionContextEntryKey
|
||||
key: SessionContextEntryKey2
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
|
|
@ -5308,7 +5301,7 @@ export class Entry extends HeyApiClient {
|
|||
public put<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
key: SessionContextEntryKey
|
||||
key: SessionContextEntryKey2
|
||||
value?: unknown
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
|
|
@ -5349,232 +5342,6 @@ export class Context extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Form extends HeyApiClient {
|
||||
/**
|
||||
* List session forms
|
||||
*
|
||||
* Retrieve pending forms for a session.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2SessionFormListResponses, V2SessionFormListErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/form",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Create session form
|
||||
*
|
||||
* Create a form for a session.
|
||||
*/
|
||||
public create<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
formCreatePayload: FormCreatePayload
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "query", key: "location" },
|
||||
{ key: "formCreatePayload", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionFormCreateResponses, V2SessionFormCreateErrors, ThrowOnError>(
|
||||
{
|
||||
url: "/api/session/{sessionID}/form",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get session form
|
||||
*
|
||||
* Retrieve a form for a session.
|
||||
*/
|
||||
public get<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
formID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "formID" },
|
||||
{ in: "query", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2SessionFormGetResponses, V2SessionFormGetErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/form/{formID}",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get form state
|
||||
*
|
||||
* Retrieve the current state for a form.
|
||||
*/
|
||||
public state<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
formID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "formID" },
|
||||
{ in: "query", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).get<V2SessionFormStateResponses, V2SessionFormStateErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/form/{formID}/state",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to form
|
||||
*
|
||||
* Submit an answer to a pending form.
|
||||
*/
|
||||
public reply<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
formID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
formReply: FormReply
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "formID" },
|
||||
{ in: "query", key: "location" },
|
||||
{ key: "formReply", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionFormReplyResponses, V2SessionFormReplyErrors, ThrowOnError>({
|
||||
url: "/api/session/{sessionID}/form/{formID}/reply",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel form
|
||||
*
|
||||
* Cancel a pending form.
|
||||
*/
|
||||
public cancel<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
formID: string
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "formID" },
|
||||
{ in: "query", key: "location" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<V2SessionFormCancelResponses, V2SessionFormCancelErrors, ThrowOnError>(
|
||||
{
|
||||
url: "/api/session/{sessionID}/form/{formID}/cancel",
|
||||
...options,
|
||||
...params,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export class Permission2 extends HeyApiClient {
|
||||
/**
|
||||
* List session permission requests
|
||||
|
|
@ -5730,6 +5497,106 @@ export class Permission2 extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Question2 extends HeyApiClient {
|
||||
/**
|
||||
* List session question requests
|
||||
*
|
||||
* Retrieve pending question requests owned by a session.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "path", key: "sessionID" }] }])
|
||||
return (options?.client ?? this.client).get<
|
||||
V2SessionQuestionListResponses,
|
||||
V2SessionQuestionListErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/question",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reply to pending question request
|
||||
*
|
||||
* Answer a pending question request owned by a session.
|
||||
*/
|
||||
public reply<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
questionV2Reply: QuestionV2Reply2
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "requestID" },
|
||||
{ key: "questionV2Reply", map: "body" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
V2SessionQuestionReplyResponses,
|
||||
V2SessionQuestionReplyErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/question/{requestID}/reply",
|
||||
...options,
|
||||
...params,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
...params.headers,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Reject pending question request
|
||||
*
|
||||
* Reject a pending question request owned by a session.
|
||||
*/
|
||||
public reject<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
requestID: string
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams(
|
||||
[parameters],
|
||||
[
|
||||
{
|
||||
args: [
|
||||
{ in: "path", key: "sessionID" },
|
||||
{ in: "path", key: "requestID" },
|
||||
],
|
||||
},
|
||||
],
|
||||
)
|
||||
return (options?.client ?? this.client).post<
|
||||
V2SessionQuestionRejectResponses,
|
||||
V2SessionQuestionRejectErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/session/{sessionID}/question/{requestID}/reject",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Session3 extends HeyApiClient {
|
||||
/**
|
||||
* List sessions
|
||||
|
|
@ -5739,7 +5606,7 @@ export class Session3 extends HeyApiClient {
|
|||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
workspace?: string | null
|
||||
limit?: string | null
|
||||
limit?: number | null
|
||||
order?: "asc" | "desc" | null
|
||||
search?: string | null
|
||||
parentID?: string | "null" | null
|
||||
|
|
@ -6002,7 +5869,7 @@ export class Session3 extends HeyApiClient {
|
|||
parameters: {
|
||||
sessionID: string
|
||||
id?: string | null
|
||||
prompt?: PromptInput
|
||||
prompt?: PromptInputV2
|
||||
delivery?: "steer" | "queue" | null
|
||||
resume?: boolean | null
|
||||
},
|
||||
|
|
@ -6047,7 +5914,7 @@ export class Session3 extends HeyApiClient {
|
|||
arguments?: string | null
|
||||
agent?: string | null
|
||||
model?: ModelRef2 | null
|
||||
files?: Array<PromptInputFileAttachment>
|
||||
files?: Array<PromptInputFileAttachment2>
|
||||
agents?: Array<PromptAgentAttachment2>
|
||||
delivery?: "steer" | "queue" | null
|
||||
resume?: boolean | null
|
||||
|
|
@ -6225,7 +6092,7 @@ export class Session3 extends HeyApiClient {
|
|||
/**
|
||||
* Read the session log
|
||||
*
|
||||
* Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a caught-up marker once the replay reaches the end of the log, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.
|
||||
* Durable, ordered, gap-free read of public session events after an exclusive aggregate sequence. Emits a synced marker once replay reaches the captured watermark, then completes; with follow=true it continues with live events instead. The only event API that promises reliability: attach after a snapshot watermark to compose fetch and stream without a race window.
|
||||
*/
|
||||
public log<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
|
|
@ -6332,7 +6199,7 @@ export class Session3 extends HeyApiClient {
|
|||
public messages<ThrowOnError extends boolean = false>(
|
||||
parameters: {
|
||||
sessionID: string
|
||||
limit?: string | null
|
||||
limit?: number | null
|
||||
order?: "asc" | "desc" | null
|
||||
cursor?: string | null
|
||||
},
|
||||
|
|
@ -6368,15 +6235,15 @@ export class Session3 extends HeyApiClient {
|
|||
return (this._context ??= new Context({ client: this.client }))
|
||||
}
|
||||
|
||||
private _form?: Form
|
||||
get form(): Form {
|
||||
return (this._form ??= new Form({ client: this.client }))
|
||||
}
|
||||
|
||||
private _permission?: Permission2
|
||||
get permission(): Permission2 {
|
||||
return (this._permission ??= new Permission2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _question?: Question2
|
||||
get question(): Question2 {
|
||||
return (this._question ??= new Question2({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Model extends HeyApiClient {
|
||||
|
|
@ -6972,37 +6839,6 @@ export class Project2 extends HeyApiClient {
|
|||
}
|
||||
|
||||
export class Request extends HeyApiClient {
|
||||
/**
|
||||
* List pending form requests
|
||||
*
|
||||
* Retrieve pending forms for a location.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<V2FormRequestListResponses, V2FormRequestListErrors, ThrowOnError>({
|
||||
url: "/api/form/request",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Form2 extends HeyApiClient {
|
||||
private _request?: Request
|
||||
get request(): Request {
|
||||
return (this._request ??= new Request({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Request2 extends HeyApiClient {
|
||||
/**
|
||||
* List pending permission requests
|
||||
*
|
||||
|
|
@ -7079,9 +6915,9 @@ export class Saved extends HeyApiClient {
|
|||
}
|
||||
|
||||
export class Permission3 extends HeyApiClient {
|
||||
private _request?: Request2
|
||||
get request(): Request2 {
|
||||
return (this._request ??= new Request2({ client: this.client }))
|
||||
private _request?: Request
|
||||
get request(): Request {
|
||||
return (this._request ??= new Request({ client: this.client }))
|
||||
}
|
||||
|
||||
private _saved?: Saved
|
||||
|
|
@ -7683,6 +7519,41 @@ export class Shell extends HeyApiClient {
|
|||
}
|
||||
}
|
||||
|
||||
export class Request2 extends HeyApiClient {
|
||||
/**
|
||||
* List pending question requests
|
||||
*
|
||||
* Retrieve pending question requests for a location.
|
||||
*/
|
||||
public list<ThrowOnError extends boolean = false>(
|
||||
parameters?: {
|
||||
location?: {
|
||||
directory?: string | null
|
||||
workspace?: string | null
|
||||
} | null
|
||||
},
|
||||
options?: Options<never, ThrowOnError>,
|
||||
) {
|
||||
const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "location" }] }])
|
||||
return (options?.client ?? this.client).get<
|
||||
V2QuestionRequestListResponses,
|
||||
V2QuestionRequestListErrors,
|
||||
ThrowOnError
|
||||
>({
|
||||
url: "/api/question/request",
|
||||
...options,
|
||||
...params,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
export class Question3 extends HeyApiClient {
|
||||
private _request?: Request2
|
||||
get request(): Request2 {
|
||||
return (this._request ??= new Request2({ client: this.client }))
|
||||
}
|
||||
}
|
||||
|
||||
export class Reference extends HeyApiClient {
|
||||
/**
|
||||
* List references
|
||||
|
|
@ -7884,11 +7755,6 @@ export class V2 extends HeyApiClient {
|
|||
return (this._project ??= new Project2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _form?: Form2
|
||||
get form(): Form2 {
|
||||
return (this._form ??= new Form2({ client: this.client }))
|
||||
}
|
||||
|
||||
private _permission?: Permission3
|
||||
get permission(): Permission3 {
|
||||
return (this._permission ??= new Permission3({ client: this.client }))
|
||||
|
|
@ -7924,6 +7790,11 @@ export class V2 extends HeyApiClient {
|
|||
return (this._shell ??= new Shell({ client: this.client }))
|
||||
}
|
||||
|
||||
private _question?: Question3
|
||||
get question(): Question3 {
|
||||
return (this._question ??= new Question3({ client: this.client }))
|
||||
}
|
||||
|
||||
private _reference?: Reference
|
||||
get reference(): Reference {
|
||||
return (this._reference ??= new Reference({ client: this.client }))
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,12 +1,12 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { V2SessionLogData } from "../src/v2/gen/types.gen"
|
||||
import type { V2SessionHistoryData } from "../src/v2/gen/types.gen"
|
||||
|
||||
test("uses numeric Session log positions", () => {
|
||||
test("uses numeric Session history positions", () => {
|
||||
const input = {
|
||||
path: { sessionID: "ses_test" },
|
||||
query: { after: 1, follow: "false" },
|
||||
url: "/api/session/{sessionID}/log",
|
||||
} satisfies V2SessionLogData
|
||||
query: { after: 1, limit: 50 },
|
||||
url: "/api/session/{sessionID}/history",
|
||||
} satisfies V2SessionHistoryData
|
||||
|
||||
expect(input.query.after).toBe(1)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,8 @@
|
|||
import { makeDefaultApi } from "@opencode-ai/protocol/api"
|
||||
import { LocationMiddleware } from "./location"
|
||||
import { FormLocationMiddleware } from "./middleware/form-location"
|
||||
import { SessionLocationMiddleware } from "./middleware/session-location"
|
||||
|
||||
export const Api = makeDefaultApi({
|
||||
locationMiddleware: LocationMiddleware,
|
||||
// FormLocationMiddleware contains the temporary `sessionID === "global"` MCP elicitation hack.
|
||||
// Do not use that sentinel with general session APIs.
|
||||
formLocationMiddleware: FormLocationMiddleware,
|
||||
sessionLocationMiddleware: SessionLocationMiddleware,
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import { ProviderHandler } from "./handlers/provider"
|
|||
import { SessionHandler } from "./handlers/session"
|
||||
import { PermissionHandler } from "./handlers/permission"
|
||||
import { FileSystemHandler } from "./handlers/fs"
|
||||
import { FormHandler } from "./handlers/form"
|
||||
import { CommandHandler } from "./handlers/command"
|
||||
import { SkillHandler } from "./handlers/skill"
|
||||
import { EventHandler } from "./handlers/event"
|
||||
|
|
@ -15,6 +14,7 @@ import { PluginHandler } from "./handlers/plugin"
|
|||
import { HealthHandler } from "./handlers/health"
|
||||
import { PtyHandler } from "./handlers/pty"
|
||||
import { ShellHandler } from "./handlers/shell"
|
||||
import { QuestionHandler } from "./handlers/question"
|
||||
import { ReferenceHandler } from "./handlers/reference"
|
||||
import { LocationHandler } from "./handlers/location"
|
||||
import { IntegrationHandler } from "./handlers/integration"
|
||||
|
|
@ -37,7 +37,6 @@ export const handlers = Layer.mergeAll(
|
|||
McpHandler,
|
||||
CredentialHandler,
|
||||
ProjectHandler,
|
||||
FormHandler,
|
||||
PermissionHandler,
|
||||
FileSystemHandler,
|
||||
CommandHandler,
|
||||
|
|
@ -45,6 +44,7 @@ export const handlers = Layer.mergeAll(
|
|||
EventHandler,
|
||||
PtyHandler,
|
||||
ShellHandler,
|
||||
QuestionHandler,
|
||||
ReferenceHandler,
|
||||
ProjectCopyHandler,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,136 +0,0 @@
|
|||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import {
|
||||
ConflictError,
|
||||
FormAlreadySettledError,
|
||||
FormInvalidAnswerError,
|
||||
FormNotFoundError,
|
||||
InvalidRequestError,
|
||||
} from "@opencode-ai/protocol/errors"
|
||||
import { Api } from "../api"
|
||||
import { response } from "../location"
|
||||
|
||||
function missingForm(id: Form.ID) {
|
||||
return new FormNotFoundError({ id, message: `Form not found: ${id}` })
|
||||
}
|
||||
|
||||
function alreadySettled(error: Form.AlreadySettledError) {
|
||||
return new FormAlreadySettledError({ id: error.id, message: error.message })
|
||||
}
|
||||
|
||||
function alreadyExists(error: Form.AlreadyExistsError) {
|
||||
return new ConflictError({ resource: error.id, message: error.message })
|
||||
}
|
||||
|
||||
function invalidAnswer(error: Form.InvalidAnswerError) {
|
||||
return new FormInvalidAnswerError({ id: error.id, message: error.message })
|
||||
}
|
||||
|
||||
export const FormHandler = HttpApiBuilder.group(Api, "server.form", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const withOwnedForm = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: string,
|
||||
formID: Form.ID,
|
||||
use: (service: Form.Interface, info: Form.Info) => Effect.Effect<A, E>,
|
||||
) {
|
||||
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 yield* use(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 form = yield* Form.Service
|
||||
return yield* response(form.list({ sessionID: ctx.params.sessionID }))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.create",
|
||||
Effect.fn(function* (ctx) {
|
||||
const form = yield* Form.Service
|
||||
if (ctx.payload.mode === "form") {
|
||||
if (!ctx.payload.fields) {
|
||||
return yield* new InvalidRequestError({ message: "Form fields are required", field: "fields" })
|
||||
}
|
||||
return yield* response(
|
||||
form.create({
|
||||
id: ctx.payload.id,
|
||||
sessionID: ctx.params.sessionID,
|
||||
title: ctx.payload.title,
|
||||
metadata: ctx.payload.metadata,
|
||||
mode: "form",
|
||||
fields: ctx.payload.fields,
|
||||
}).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
|
||||
)
|
||||
}
|
||||
if (!ctx.payload.url) return yield* new InvalidRequestError({ message: "Form URL is required", field: "url" })
|
||||
return yield* response(
|
||||
form.create({
|
||||
id: ctx.payload.id,
|
||||
sessionID: ctx.params.sessionID,
|
||||
title: ctx.payload.title,
|
||||
metadata: ctx.payload.metadata,
|
||||
mode: "url",
|
||||
url: ctx.payload.url,
|
||||
}).pipe(Effect.catchTag("Form.AlreadyExistsError", alreadyExists)),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.get",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* response(withOwnedForm(ctx.params.sessionID, ctx.params.formID, (_, info) => Effect.succeed(info)))
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.state",
|
||||
Effect.fn(function* (ctx) {
|
||||
return yield* response(
|
||||
withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
|
||||
form.state(ctx.params.formID).pipe(Effect.catchTag("Form.NotFoundError", () => missingForm(ctx.params.formID))),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.reply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
|
||||
form.reply({ id: ctx.params.formID, answer: ctx.payload.answer }).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadySettledError": alreadySettled,
|
||||
"Form.InvalidAnswerError": invalidAnswer,
|
||||
"Form.NotFoundError": () => missingForm(ctx.params.formID),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.form.cancel",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedForm(ctx.params.sessionID, ctx.params.formID, (form) =>
|
||||
form.cancel(ctx.params.formID).pipe(
|
||||
Effect.catchTags({
|
||||
"Form.AlreadySettledError": alreadySettled,
|
||||
"Form.NotFoundError": () => missingForm(ctx.params.formID),
|
||||
}),
|
||||
),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
62
packages/server/src/handlers/question.ts
Normal file
62
packages/server/src/handlers/question.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { Effect } from "effect"
|
||||
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
|
||||
import { Api } from "../api"
|
||||
import { QuestionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { response } from "../location"
|
||||
|
||||
function missingRequest(id: QuestionV2.ID) {
|
||||
return new QuestionNotFoundError({ requestID: id, message: `Question request not found: ${id}` })
|
||||
}
|
||||
|
||||
export const QuestionHandler = HttpApiBuilder.group(Api, "server.question", (handlers) =>
|
||||
Effect.gen(function* () {
|
||||
const withOwnedQuestion = Effect.fnUntraced(function* <A, E>(
|
||||
sessionID: QuestionV2.Request["sessionID"],
|
||||
requestID: QuestionV2.ID,
|
||||
use: (question: QuestionV2.Interface) => Effect.Effect<A, E>,
|
||||
) {
|
||||
const question = yield* QuestionV2.Service
|
||||
const request = (yield* question.list()).find((request) => request.id === requestID)
|
||||
if (!request || request.sessionID !== sessionID) return yield* missingRequest(requestID)
|
||||
return yield* use(question)
|
||||
})
|
||||
|
||||
return handlers
|
||||
.handle(
|
||||
"question.request.list",
|
||||
Effect.fn(function* () {
|
||||
return yield* response((yield* QuestionV2.Service).list())
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.list",
|
||||
Effect.fn(function* (ctx) {
|
||||
const requests = yield* (yield* QuestionV2.Service).list()
|
||||
return { data: requests.filter((request) => request.sessionID === ctx.params.sessionID) }
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.reply",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reply({ requestID: ctx.params.requestID, answers: ctx.payload.answers })
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
.handle(
|
||||
"session.question.reject",
|
||||
Effect.fn(function* (ctx) {
|
||||
yield* withOwnedQuestion(ctx.params.sessionID, ctx.params.requestID, (question) =>
|
||||
question
|
||||
.reject(ctx.params.requestID)
|
||||
.pipe(Effect.catchTag("QuestionV2.NotFoundError", () => missingRequest(ctx.params.requestID))),
|
||||
)
|
||||
return HttpApiSchema.NoContent.make()
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -26,7 +26,7 @@ export function response<A, E, R>(data: Effect.Effect<A, E, R>) {
|
|||
})
|
||||
}
|
||||
|
||||
export function requestRef(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
function ref(request: HttpServerRequest.HttpServerRequest): Location.Ref {
|
||||
const query = new URL(request.url, "http://localhost").searchParams
|
||||
const workspaceID = query.get("location[workspace]") || request.headers["x-opencode-workspace"]
|
||||
const directory =
|
||||
|
|
@ -53,7 +53,7 @@ export const layer = Layer.effect(
|
|||
return LocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
|
||||
return yield* effect.pipe(Effect.provide(locations.get(ref(request))))
|
||||
}),
|
||||
)
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -1,76 +0,0 @@
|
|||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { Location } from "@opencode-ai/core/location"
|
||||
import { LocationServiceMap } from "@opencode-ai/core/location-services"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { WorkspaceV2 } from "@opencode-ai/core/workspace"
|
||||
import { InvalidRequestError, SessionNotFoundError } from "@opencode-ai/protocol/errors"
|
||||
import { eq } from "drizzle-orm"
|
||||
import { Effect, Layer, Schema } from "effect"
|
||||
import { HttpRouter, HttpServerRequest } from "effect/unstable/http"
|
||||
import { HttpApiMiddleware } from "effect/unstable/httpapi"
|
||||
import { requestRef, type LocationServices } from "../location"
|
||||
|
||||
export class FormLocationMiddleware extends HttpApiMiddleware.Service<
|
||||
FormLocationMiddleware,
|
||||
{ provides: LocationServices }
|
||||
>()("@opencode/HttpApiFormLocation", {
|
||||
error: [InvalidRequestError, SessionNotFoundError],
|
||||
}) {}
|
||||
|
||||
const decodeSessionID = Schema.decodeUnknownEffect(SessionV2.ID)
|
||||
|
||||
export const formLocationLayer = Layer.effect(
|
||||
FormLocationMiddleware,
|
||||
Effect.gen(function* () {
|
||||
const { db } = yield* Database.Service
|
||||
const locations = yield* LocationServiceMap.Service
|
||||
|
||||
return FormLocationMiddleware.of((effect) =>
|
||||
Effect.gen(function* () {
|
||||
const route = yield* HttpRouter.RouteContext
|
||||
if (route.params.sessionID === "global") {
|
||||
// Temporary MCP elicitation escape hatch. This is still Location-scoped; it only bypasses
|
||||
// the session row lookup because some MCP elicitations cannot currently be attributed to
|
||||
// a real session. Keep this undocumented and remove once elicitations carry session ownership.
|
||||
const request = yield* HttpServerRequest.HttpServerRequest
|
||||
return yield* effect.pipe(Effect.provide(locations.get(requestRef(request))))
|
||||
}
|
||||
|
||||
const sessionID = yield* decodeSessionID(route.params.sessionID).pipe(
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new InvalidRequestError({
|
||||
message: "Invalid session ID",
|
||||
field: "sessionID",
|
||||
}),
|
||||
),
|
||||
)
|
||||
const row = yield* db
|
||||
.select({ directory: SessionTable.directory, workspaceID: SessionTable.workspace_id })
|
||||
.from(SessionTable)
|
||||
.where(eq(SessionTable.id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
if (!row) {
|
||||
return yield* new SessionNotFoundError({
|
||||
sessionID,
|
||||
message: `Session not found: ${sessionID}`,
|
||||
})
|
||||
}
|
||||
|
||||
return yield* effect.pipe(
|
||||
Effect.provide(
|
||||
locations.get(
|
||||
Location.Ref.make({
|
||||
directory: AbsolutePath.make(row.directory),
|
||||
workspaceID: row.workspaceID ? WorkspaceV2.ID.make(row.workspaceID) : undefined,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
|
@ -24,7 +24,6 @@ import { authorizationLayer } from "./middleware/authorization"
|
|||
import { schemaErrorLayer } from "./middleware/schema-error"
|
||||
import { PtyEnvironment } from "./pty-environment"
|
||||
import { layer as locationLayer } from "./location"
|
||||
import { formLocationLayer } from "./middleware/form-location"
|
||||
import { sessionLocationLayer } from "./middleware/session-location"
|
||||
|
||||
const applicationServices = LayerNode.group([
|
||||
|
|
@ -80,7 +79,6 @@ function makeRoutes<AuthError, AuthServices>(
|
|||
|
||||
return HttpApiBuilder.layer(Api, { openapiPath: "/openapi.json" }).pipe(
|
||||
Layer.provide(handlers),
|
||||
Layer.provide(formLocationLayer),
|
||||
Layer.provide(sessionLocationLayer),
|
||||
Layer.provide(locationLayer),
|
||||
Layer.provide(authorizationLayer),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import type {
|
|||
PermissionSavedInfo,
|
||||
PermissionV2Request,
|
||||
ProviderV2Info,
|
||||
QuestionV2Request,
|
||||
ReferenceInfo,
|
||||
SessionMessage,
|
||||
SessionMessageAssistant,
|
||||
|
|
@ -18,16 +19,12 @@ import type {
|
|||
Shell,
|
||||
SkillV2Info,
|
||||
V2Event,
|
||||
FormFormInfo,
|
||||
FormUrlInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
|
||||
type FormInfo = FormFormInfo | FormUrlInfo
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
type LocationData = {
|
||||
|
|
@ -38,7 +35,6 @@ type LocationData = {
|
|||
model?: ModelV2Info[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
form?: FormInfo[]
|
||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||
shell?: Record<string, Shell>
|
||||
|
|
@ -55,7 +51,7 @@ type Data = {
|
|||
status: Record<string, DataSessionStatus>
|
||||
message: Record<string, SessionMessage[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
form: Record<string, FormInfo[]>
|
||||
question: Record<string, QuestionV2Request[]>
|
||||
}
|
||||
project: {
|
||||
permission: Record<string, PermissionSavedInfo[]>
|
||||
|
|
@ -89,7 +85,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
status: {},
|
||||
message: {},
|
||||
permission: {},
|
||||
form: {},
|
||||
question: {},
|
||||
},
|
||||
project: {
|
||||
permission: {},
|
||||
|
|
@ -185,21 +181,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
const info = store.session.info[sessionID]
|
||||
if (!info) return
|
||||
const rootID = resolveRoot(sessionID)
|
||||
setStore(
|
||||
"session",
|
||||
"family",
|
||||
produce((draft) => {
|
||||
if (sessionID !== rootID && draft[sessionID]) {
|
||||
const members = (draft[rootID] ??= [])
|
||||
for (const id of draft[sessionID]) {
|
||||
if (!members.includes(id)) members.push(id)
|
||||
}
|
||||
delete draft[sessionID]
|
||||
setStore("session", "family", produce((draft) => {
|
||||
if (sessionID !== rootID && draft[sessionID]) {
|
||||
const members = draft[rootID] ??= []
|
||||
for (const id of draft[sessionID]) {
|
||||
if (!members.includes(id)) members.push(id)
|
||||
}
|
||||
const family = (draft[rootID] ??= [])
|
||||
if (!family.includes(sessionID)) family.push(sessionID)
|
||||
}),
|
||||
)
|
||||
delete draft[sessionID]
|
||||
}
|
||||
const family = draft[rootID] ??= []
|
||||
if (!family.includes(sessionID)) family.push(sessionID)
|
||||
}))
|
||||
}
|
||||
|
||||
function handleEvent(event: V2Event) {
|
||||
|
|
@ -575,40 +567,22 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
),
|
||||
)
|
||||
break
|
||||
case "form.created":
|
||||
if (event.data.form.sessionID === "global") {
|
||||
const key = locationKey(event.location ?? defaultLocation())
|
||||
if (store.location[key]?.form?.some((request) => request.id === event.data.form.id)) break
|
||||
setStore("location", key, (data) => ({ ...data, form: [...(data?.form ?? []), event.data.form] }))
|
||||
break
|
||||
}
|
||||
if (store.session.form[event.data.form.sessionID]?.some((request) => request.id === event.data.form.id)) break
|
||||
setStore("session", "form", event.data.form.sessionID, [
|
||||
...(store.session.form[event.data.form.sessionID] ?? []),
|
||||
event.data.form,
|
||||
case "question.v2.asked":
|
||||
if (store.session.question[event.data.sessionID]?.some((request) => request.id === event.data.id)) break
|
||||
setStore("session", "question", event.data.sessionID, [
|
||||
...(store.session.question[event.data.sessionID] ?? []),
|
||||
event.data,
|
||||
])
|
||||
break
|
||||
case "form.replied":
|
||||
case "form.cancelled":
|
||||
case "question.v2.replied":
|
||||
case "question.v2.rejected":
|
||||
setStore(
|
||||
"session",
|
||||
"form",
|
||||
"question",
|
||||
event.data.sessionID,
|
||||
(store.session.form[event.data.sessionID] ?? []).filter((request) => request.id !== event.data.id),
|
||||
)
|
||||
if (event.location) {
|
||||
setStore("location", locationKey(event.location), (data) => ({
|
||||
...data,
|
||||
form: (data?.form ?? []).filter((request) => request.id !== event.data.id),
|
||||
}))
|
||||
break
|
||||
}
|
||||
setStore(
|
||||
"location",
|
||||
produce((draft) => {
|
||||
for (const data of Object.values(draft))
|
||||
data.form = data.form?.filter((request) => request.id !== event.data.id)
|
||||
}),
|
||||
(store.session.question[event.data.sessionID] ?? []).filter(
|
||||
(request) => request.id !== event.data.requestID,
|
||||
),
|
||||
)
|
||||
break
|
||||
case "shell.created":
|
||||
|
|
@ -716,7 +690,8 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return liveByID.get(message.id) ?? message
|
||||
}),
|
||||
...live.filter((message) => !loadedIDs.has(message.id)),
|
||||
].toSorted((a, b) => a.time.created - b.time.created)
|
||||
]
|
||||
.toSorted((a, b) => a.time.created - b.time.created)
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, messages)
|
||||
},
|
||||
|
|
@ -729,20 +704,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setStore("session", "permission", sessionID, mutable(await sdk.api.permission.list({ sessionID })))
|
||||
},
|
||||
},
|
||||
form: {
|
||||
list(sessionID: string) {
|
||||
return store.session.form[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
|
||||
},
|
||||
},
|
||||
question: {
|
||||
list(sessionID: string) {
|
||||
return store.session.form[sessionID]
|
||||
return store.session.question[sessionID]
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
setStore("session", "form", sessionID, mutable((await sdk.api.form.list({ sessionID })).data))
|
||||
setStore("session", "question", sessionID, mutable(await sdk.api.question.list({ sessionID })))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -833,22 +800,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setStore("location", key, { ...store.location[key], mcp: result.data.data })
|
||||
},
|
||||
},
|
||||
form: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.form
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.client.v2.form.request.list(
|
||||
{ location: locationQuery(ref) },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const key = locationKey(result.data.location)
|
||||
setStore("location", key, {
|
||||
...store.location[key],
|
||||
form: result.data.data.filter((form) => form.sessionID === "global"),
|
||||
})
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list(location?: LocationRef) {
|
||||
return store.location[locationKey(location ?? defaultLocation())]?.model
|
||||
|
|
@ -925,7 +876,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
result.location.mcp.refresh(),
|
||||
result.location.form.refresh(),
|
||||
result.location.model.refresh(),
|
||||
result.location.provider.refresh(),
|
||||
result.location.reference.refresh(),
|
||||
|
|
|
|||
|
|
@ -46,25 +46,6 @@ const tui: TuiPlugin = async (api) => {
|
|||
questions.delete(event.data.requestID)
|
||||
})
|
||||
|
||||
api.event.on("form.created", (event) => {
|
||||
if (questions.has(event.data.form.id)) return
|
||||
questions.add(event.data.form.id)
|
||||
notify(
|
||||
api,
|
||||
event.data.form.sessionID === "global" ? undefined : event.data.form.sessionID,
|
||||
"Form needs input",
|
||||
"question",
|
||||
)
|
||||
})
|
||||
|
||||
api.event.on("form.replied", (event) => {
|
||||
questions.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("form.cancelled", (event) => {
|
||||
questions.delete(event.data.id)
|
||||
})
|
||||
|
||||
api.event.on("permission.asked", (event) => {
|
||||
if (permissions.has(event.data.id)) return
|
||||
permissions.add(event.data.id)
|
||||
|
|
|
|||
|
|
@ -1,710 +0,0 @@
|
|||
import { createMemo, createSignal, For, Match, onCleanup, onMount, Show, Switch } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { FormAnswer, FormFormInfo, FormUrlInfo, LocationRef } from "@opencode-ai/sdk/v2"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { Locale } from "../../util/locale"
|
||||
|
||||
const FORM_MODE = "form"
|
||||
|
||||
type FormField = FormFormInfo["fields"][number]
|
||||
type PromptForm = FormFormInfo | FormUrlInfo
|
||||
type FieldOption = { value: string; label: string; description?: string; custom?: boolean }
|
||||
type FieldTab = { type: "field"; index: number } | { type: "ellipsis"; key: string }
|
||||
|
||||
export function FormPrompt(props: { request: PromptForm; location?: LocationRef }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [settling, setSettling] = createSignal<"reply" | "cancel">()
|
||||
const [settleError, setSettleError] = createSignal<string>()
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
selected: 0,
|
||||
editing: false,
|
||||
text: {} as Record<string, string>,
|
||||
boolean: {} as Record<string, boolean>,
|
||||
multiselect: {} as Record<string, string[]>,
|
||||
custom: {} as Record<string, string>,
|
||||
})
|
||||
let textarea: TextareaRenderable | undefined
|
||||
|
||||
const fields = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
|
||||
const single = createMemo(() => fields().length === 1 && fields()[0]?.type !== "multiselect")
|
||||
const compactTabs = createMemo(() => {
|
||||
const items = fields()
|
||||
return items.length > 5 || items.some((item) => fieldLabel(item).length > 18)
|
||||
})
|
||||
const fieldTabItems = createMemo(() => fieldTabs(fields(), store.tab, compactTabs()))
|
||||
const answeredCount = createMemo(() => fields().filter((item) => fieldText(item).length > 0).length)
|
||||
const tabs = createMemo(() => (single() ? 1 : fields().length + 1))
|
||||
const confirm = createMemo(() => !single() && store.tab === fields().length)
|
||||
const field = createMemo(() => fields()[store.tab])
|
||||
const options = createMemo(() => fieldOptions(field()))
|
||||
const currentText = createMemo(() => fieldText(field()))
|
||||
const footerAction = createMemo(() => {
|
||||
if (confirm() || props.request.mode === "url") return "submit"
|
||||
if (field()?.type === "multiselect") return "toggle"
|
||||
if (options().length > 0) return single() ? "submit" : "confirm"
|
||||
return "type"
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const result = props.request.mode === "url" ? { answer: {} } : buildAnswer(fields())
|
||||
if ("error" in result) {
|
||||
setSettleError(result.error)
|
||||
return
|
||||
}
|
||||
settle("reply", () =>
|
||||
sdk.client.v2.session.form.reply(
|
||||
{
|
||||
sessionID: props.request.sessionID,
|
||||
formID: props.request.id,
|
||||
location: locationQuery(props.location),
|
||||
formReply: { answer: result.answer },
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
settle("cancel", () =>
|
||||
sdk.client.v2.session.form.cancel(
|
||||
{
|
||||
sessionID: props.request.sessionID,
|
||||
formID: props.request.id,
|
||||
location: locationQuery(props.location),
|
||||
},
|
||||
{ throwOnError: true },
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
function settle(kind: "reply" | "cancel", run: () => Promise<unknown>) {
|
||||
if (settling()) return
|
||||
setSettling(kind)
|
||||
setSettleError(undefined)
|
||||
void run()
|
||||
.catch((error) => setSettleError(errorMessage(error)))
|
||||
.finally(() => setSettling(undefined))
|
||||
}
|
||||
|
||||
function selectTab(index: number) {
|
||||
setStore("tab", index)
|
||||
setStore("selected", selectedOptionIndex(fields()[index]))
|
||||
setStore("editing", false)
|
||||
}
|
||||
|
||||
function moveTo(index: number) {
|
||||
setStore("selected", index)
|
||||
}
|
||||
|
||||
function moveOption(count: number, index: number) {
|
||||
if (count === 0) return
|
||||
moveTo((index + count) % count)
|
||||
}
|
||||
|
||||
function choose() {
|
||||
const item = field()
|
||||
if (!item) {
|
||||
submit()
|
||||
return
|
||||
}
|
||||
if (item.type === "boolean") {
|
||||
setStore("boolean", item.key, store.selected === 1)
|
||||
if (single()) {
|
||||
submit()
|
||||
return
|
||||
}
|
||||
nextTab()
|
||||
return
|
||||
}
|
||||
if (item.type === "string" && fieldOptions(item).length) {
|
||||
const option = fieldOptions(item)[store.selected]
|
||||
if (!option) return
|
||||
if (option.custom) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
setStore("text", item.key, option.value)
|
||||
if (single()) {
|
||||
submit()
|
||||
return
|
||||
}
|
||||
nextTab()
|
||||
return
|
||||
}
|
||||
if (item.type === "multiselect") {
|
||||
const option = fieldOptions(item)[store.selected]
|
||||
if (!option) return
|
||||
if (option.custom) {
|
||||
const value = store.custom[item.key]
|
||||
if (value && (store.multiselect[item.key] ?? item.default ?? []).includes(value)) {
|
||||
setStore("multiselect", item.key, toggle(store.multiselect[item.key] ?? item.default ?? [], value))
|
||||
return
|
||||
}
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const existing = store.multiselect[item.key] ?? item.default ?? []
|
||||
setStore("multiselect", item.key, toggle(existing, option.value))
|
||||
return
|
||||
}
|
||||
setStore("editing", true)
|
||||
}
|
||||
|
||||
function nextTab() {
|
||||
selectTab(Math.min(store.tab + 1, fields().length))
|
||||
}
|
||||
|
||||
function fieldText(item: FormField | undefined) {
|
||||
if (!item) return ""
|
||||
if (item.type === "boolean") return String(store.boolean[item.key] ?? item.default ?? false)
|
||||
if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).join(", ")
|
||||
return store.text[item.key] ?? (item.default === undefined ? "" : String(item.default))
|
||||
}
|
||||
|
||||
function selectedOptionIndex(item: FormField | undefined) {
|
||||
const choices = fieldOptions(item)
|
||||
if (!item || choices.length === 0 || item.type === "multiselect") return 0
|
||||
const index = choices.findIndex((option) => option.value === fieldText(item))
|
||||
if (index >= 0) return index
|
||||
const customIndex = choices.findIndex((option) => option.custom === true)
|
||||
return customIndex >= 0 && (store.custom[item.key] || fieldText(item)) ? customIndex : 0
|
||||
}
|
||||
|
||||
function buildAnswer(items: FormField[]): { answer: FormAnswer } | { error: string } {
|
||||
const result = items.reduce<{ answer: FormAnswer; error?: string }>(
|
||||
(state, item) => {
|
||||
if (state.error) return state
|
||||
if (!isActive(item, state.answer)) return state
|
||||
if (item.type === "boolean") {
|
||||
if (store.boolean[item.key] !== undefined || item.default !== undefined || item.required) {
|
||||
return {
|
||||
...state,
|
||||
answer: { ...state.answer, [item.key]: store.boolean[item.key] ?? item.default ?? false },
|
||||
}
|
||||
}
|
||||
return state
|
||||
}
|
||||
if (item.type === "multiselect") {
|
||||
const value = store.multiselect[item.key] ?? item.default ?? []
|
||||
if (value.length > 0 || item.required) {
|
||||
return { ...state, answer: { ...state.answer, [item.key]: value } }
|
||||
}
|
||||
return state
|
||||
}
|
||||
const text = fieldText(item).trim()
|
||||
if (!text) {
|
||||
if (item.required) return { ...state, answer: { ...state.answer, [item.key]: "" } }
|
||||
return state
|
||||
}
|
||||
if (item.type === "number" || item.type === "integer") {
|
||||
const value = Number(text)
|
||||
if (!Number.isFinite(value)) return { ...state, error: `Expected number for ${fieldLabel(item)}` }
|
||||
if (item.type === "integer" && !Number.isInteger(value))
|
||||
return { ...state, error: `Expected integer for ${fieldLabel(item)}` }
|
||||
return { ...state, answer: { ...state.answer, [item.key]: value } }
|
||||
}
|
||||
return { ...state, answer: { ...state.answer, [item.key]: text } }
|
||||
},
|
||||
{ answer: {} },
|
||||
)
|
||||
if (result.error) return { error: result.error }
|
||||
return { answer: result.answer }
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
setStore("selected", selectedOptionIndex(field()))
|
||||
const popMode = modeStack.push(FORM_MODE)
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
mode: FORM_MODE,
|
||||
enabled: store.editing,
|
||||
commands: [
|
||||
{
|
||||
name: "form.clear",
|
||||
title: "Clear form input",
|
||||
category: "Form",
|
||||
run() {
|
||||
const text = textarea?.plainText ?? ""
|
||||
if (!text) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
textarea?.setText("")
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Cancel edit",
|
||||
group: "Form",
|
||||
cmd: () => setStore("editing", false),
|
||||
},
|
||||
...tuiConfig.keybinds.get("prompt.clear"),
|
||||
{
|
||||
key: "return",
|
||||
desc: "Save input",
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
const item = field()
|
||||
if (!item) return
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
if (item.type === "multiselect") {
|
||||
const previous = store.custom[item.key]
|
||||
const existing = store.multiselect[item.key] ?? item.default ?? []
|
||||
const withoutPrevious = previous ? existing.filter((value) => value !== previous) : existing
|
||||
setStore(
|
||||
"multiselect",
|
||||
item.key,
|
||||
text && !withoutPrevious.includes(text) ? [...withoutPrevious, text] : withoutPrevious,
|
||||
)
|
||||
setStore("custom", item.key, text)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
setStore("text", item.key, text)
|
||||
setStore("custom", item.key, text)
|
||||
setStore("editing", false)
|
||||
if (single()) {
|
||||
submit()
|
||||
return
|
||||
}
|
||||
nextTab()
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => {
|
||||
const item = field()
|
||||
const count = optionCount(item)
|
||||
return {
|
||||
mode: FORM_MODE,
|
||||
enabled: !store.editing,
|
||||
commands: [
|
||||
{
|
||||
name: "app.exit",
|
||||
title: "Cancel form",
|
||||
category: "Form",
|
||||
run: cancel,
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{ key: "left", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
|
||||
{ key: "h", desc: "Previous field", group: "Form", cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()) },
|
||||
{ key: "right", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||
{ key: "l", desc: "Next field", group: "Form", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next field",
|
||||
group: "Form",
|
||||
cmd: ({ event }: { event: { shift: boolean } }) => {
|
||||
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
|
||||
},
|
||||
},
|
||||
...Array.from({ length: Math.min(count, 9) }, (_, index) => ({
|
||||
key: String(index + 1),
|
||||
desc: `Select option ${index + 1}`,
|
||||
group: "Form",
|
||||
cmd: () => {
|
||||
moveTo(index)
|
||||
choose()
|
||||
},
|
||||
})),
|
||||
...(count > 0
|
||||
? [
|
||||
{ key: "up", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
|
||||
{ key: "k", desc: "Previous option", group: "Form", cmd: () => moveOption(count, store.selected - 1) },
|
||||
{ key: "down", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
|
||||
{ key: "j", desc: "Next option", group: "Form", cmd: () => moveOption(count, store.selected + 1) },
|
||||
]
|
||||
: []),
|
||||
{
|
||||
key: "return",
|
||||
desc: confirm() || props.request.mode === "url" ? "Submit form" : count > 0 ? "Select" : "Edit",
|
||||
group: "Form",
|
||||
cmd: choose,
|
||||
},
|
||||
{ key: "escape", desc: "Cancel form", group: "Form", cmd: cancel },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<Switch>
|
||||
<Match when={props.request.mode === "url"}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.text}>{props.request.title ?? "Open URL request"}</text>
|
||||
<Show when={formMessage(props.request)}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{formMessage(props.request)}
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.textMuted}>Open this URL, complete the request, then press enter:</text>
|
||||
<text fg={theme.secondary}>{props.request.mode === "url" ? props.request.url : ""}</text>
|
||||
</box>
|
||||
</Match>
|
||||
<Match when={props.request.mode === "form"}>
|
||||
<Show when={props.request.mode === "form" && props.request.title}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text} wrapMode="word">
|
||||
{props.request.mode === "form" ? props.request.title : ""}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={formMessage(props.request)}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted} wrapMode="word">
|
||||
{formMessage(props.request)}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!single()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<For each={fieldTabItems()}>
|
||||
{(tab) => {
|
||||
if (tab.type === "ellipsis")
|
||||
return (
|
||||
<box paddingLeft={1} paddingRight={1}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
...
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
const item = () => fields()[tab.index]
|
||||
const active = () => tab.index === store.tab
|
||||
const answered = () => fieldText(item()).length > 0
|
||||
const title = () => (compactTabs() ? String(tab.index + 1) : Locale.truncate(fieldLabel(item()), 18))
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
active()
|
||||
? theme.accent
|
||||
: tabHover() === tab.index
|
||||
? theme.backgroundElement
|
||||
: theme.backgroundPanel
|
||||
}
|
||||
onMouseOver={() => setTabHover(tab.index)}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(tab.index)
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
active() ? selectedForeground(theme, theme.accent) : answered() ? theme.text : theme.textMuted
|
||||
}
|
||||
wrapMode="none"
|
||||
>
|
||||
{title()}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(fields().length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted} wrapMode="none">
|
||||
{compactTabs() ? "Review" : "Confirm"}
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={compactTabs() && !confirm()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.textMuted} wrapMode="none">
|
||||
{`Field ${store.tab + 1} of ${fields().length} - ${answeredCount()} answered`}
|
||||
</text>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<FieldEditor
|
||||
field={field()}
|
||||
selected={store.selected}
|
||||
value={currentText()}
|
||||
customValue={field() ? store.custom[field()!.key] : ""}
|
||||
editing={store.editing}
|
||||
options={options()}
|
||||
picked={(value) => picked(field(), value)}
|
||||
moveTo={moveTo}
|
||||
choose={choose}
|
||||
textarea={(value) => {
|
||||
textarea = value
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<Show when={confirm()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>Review</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={fields()}>
|
||||
{(item) => {
|
||||
const value = () => fieldText(item)
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{fieldLabel(item)}:</span>{" "}
|
||||
<span style={{ fg: value() ? theme.text : theme.error }}>{value() || "(not answered)"}</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={props.request.mode === "form" && !single()}>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={props.request.mode === "form" && !confirm() && optionCount(field()) > 0}>
|
||||
<text fg={theme.text}>
|
||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>{footerAction()}</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>cancel</span>
|
||||
</text>
|
||||
<Show when={settling()}>
|
||||
<text fg={theme.textMuted}>{settling() === "cancel" ? "Cancelling..." : "Submitting..."}</text>
|
||||
</Show>
|
||||
<Show when={settleError()}>
|
||||
<text fg={theme.error}>{settleError()}</text>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
|
||||
function picked(item: FormField | undefined, value: string) {
|
||||
if (!item) return false
|
||||
if (value === CUSTOM_OPTION_VALUE) return Boolean(store.custom[item.key])
|
||||
if (item.type === "multiselect") return (store.multiselect[item.key] ?? item.default ?? []).includes(value)
|
||||
return fieldText(item) === value
|
||||
}
|
||||
}
|
||||
|
||||
function FieldEditor(props: {
|
||||
field?: FormField
|
||||
selected: number
|
||||
value: string
|
||||
customValue: string
|
||||
editing: boolean
|
||||
options: FieldOption[]
|
||||
picked: (value: string) => boolean
|
||||
moveTo: (index: number) => void
|
||||
choose: () => void
|
||||
textarea: (value: TextareaRenderable) => void
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<text fg={theme.text}>{fieldLabel(props.field)}</text>
|
||||
<Show when={fieldDescription(props.field)}>
|
||||
<text fg={theme.textMuted}>{props.field?.description}</text>
|
||||
</Show>
|
||||
<Switch>
|
||||
<Match when={props.options.length > 0}>
|
||||
<For each={props.options}>
|
||||
{(option, index) => {
|
||||
const active = () => index() === props.selected
|
||||
const picked = () => props.picked(option.value)
|
||||
const multi = () => props.field?.type === "multiselect"
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => props.moveTo(index())}
|
||||
onMouseDown={() => props.moveTo(index())}
|
||||
onMouseUp={() => props.choose()}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text
|
||||
fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}
|
||||
>{`${index() + 1}.`}</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${option.label}` : option.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={option.description}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{option.description}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={props.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<FormTextarea value={props.customValue} placeholder="Type custom value" textarea={props.textarea} />
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!props.editing && props.customValue}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{props.customValue}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.editing}>
|
||||
<FormTextarea value={props.value} placeholder="Type value" textarea={props.textarea} />
|
||||
</Match>
|
||||
<Match when={true}>
|
||||
<text fg={props.value ? theme.text : theme.textMuted}>{props.value || "Press enter to type"}</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
function FormTextarea(props: { value: string; placeholder: string; textarea: (value: TextareaRenderable) => void }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<textarea
|
||||
ref={(value: TextareaRenderable) => {
|
||||
props.textarea(value)
|
||||
value.traits = { status: "FORM" }
|
||||
queueMicrotask(() => {
|
||||
value.focus()
|
||||
value.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={props.value}
|
||||
placeholder={props.placeholder}
|
||||
placeholderColor={theme.textMuted}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function fieldTabs(fields: FormField[], active: number, compact: boolean): FieldTab[] {
|
||||
if (!compact) return fields.map((_, index) => ({ type: "field", index }))
|
||||
if (fields.length <= 7) return fields.map((_, index) => ({ type: "field", index }))
|
||||
|
||||
const last = fields.length - 1
|
||||
const start = Math.max(0, Math.min(active - 2, last - 4))
|
||||
const window = Array.from({ length: 5 }, (_, offset) => start + offset).filter((index) => index <= last)
|
||||
const indexes = [...new Set([0, ...window, last])].toSorted((a, b) => a - b)
|
||||
|
||||
return indexes.flatMap((index, position): FieldTab[] => {
|
||||
const previous = indexes[position - 1]
|
||||
const tab: FieldTab = { type: "field", index }
|
||||
if (previous !== undefined && index - previous > 1) return [{ type: "ellipsis", key: `${previous}-${index}` }, tab]
|
||||
return [tab]
|
||||
})
|
||||
}
|
||||
|
||||
function fieldOptions(field: FormField | undefined) {
|
||||
if (!field) return []
|
||||
if (field.type === "boolean")
|
||||
return [
|
||||
{ value: "false", label: "No" },
|
||||
{ value: "true", label: "Yes" },
|
||||
]
|
||||
if (field.type === "string") return withCustomOption(field.options ?? [], field.custom)
|
||||
if (field.type === "multiselect") return withCustomOption(field.options, field.custom)
|
||||
return []
|
||||
}
|
||||
|
||||
const CUSTOM_OPTION_VALUE = "__custom__"
|
||||
|
||||
function withCustomOption(options: FieldOption[], custom: boolean | undefined): FieldOption[] {
|
||||
if (options.length > 0 && custom !== true) return options
|
||||
return [...options, { value: CUSTOM_OPTION_VALUE, label: "Type custom value", custom: true }]
|
||||
}
|
||||
|
||||
function formMessage(form: PromptForm) {
|
||||
const message = form.metadata?.message
|
||||
return typeof message === "string" && message !== form.title ? message : undefined
|
||||
}
|
||||
|
||||
function optionCount(field: FormField | undefined) {
|
||||
return fieldOptions(field).length
|
||||
}
|
||||
|
||||
function fieldLabel(field: FormField | undefined) {
|
||||
return field?.title ?? field?.description ?? field?.key ?? "Form"
|
||||
}
|
||||
|
||||
function fieldDescription(field: FormField | undefined) {
|
||||
if (!field?.description || field.description === fieldLabel(field)) return undefined
|
||||
return field.description
|
||||
}
|
||||
|
||||
function isActive(field: FormField, answer: FormAnswer) {
|
||||
if (!field.when) return true
|
||||
const value = answer[field.when.key]
|
||||
if (field.when.op === "eq") return value === field.when.value
|
||||
return value !== field.when.value
|
||||
}
|
||||
|
||||
function toggle(values: readonly string[], value: string) {
|
||||
return values.includes(value) ? values.filter((item) => item !== value) : [...values, value]
|
||||
}
|
||||
|
||||
function locationQuery(ref?: LocationRef) {
|
||||
return ref ? { directory: ref.directory, workspace: ref.workspaceID } : undefined
|
||||
}
|
||||
|
|
@ -58,7 +58,7 @@ import { usePromptRef } from "../../context/prompt"
|
|||
import { useEpilogue } from "../../context/epilogue"
|
||||
import { normalizePath } from "../../util/path"
|
||||
import { PermissionPrompt } from "./permission"
|
||||
import { FormPrompt } from "./form"
|
||||
import { QuestionPrompt } from "./question"
|
||||
import { DialogExportOptions } from "../../ui/dialog-export-options"
|
||||
import { sessionEpilogue } from "../../util/presentation"
|
||||
import { useTuiConfig } from "../../config"
|
||||
|
|
@ -181,20 +181,15 @@ export function Session() {
|
|||
(sessionID) => data.session.permission.list(sessionID) ?? [],
|
||||
)
|
||||
})
|
||||
const sessionForms = createMemo(() => {
|
||||
const questions = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return data.session.form.list(route.sessionID) ?? []
|
||||
return data.session.question.list(route.sessionID) ?? []
|
||||
})
|
||||
const locationForms = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return data.location.form.list(location()) ?? []
|
||||
})
|
||||
const forms = createMemo(() => [...sessionForms(), ...locationForms()])
|
||||
const [composer, setComposer] = createStore({
|
||||
open: false,
|
||||
tab: undefined as string | undefined,
|
||||
})
|
||||
const disabled = createMemo(() => permissions().length > 0 || forms().length > 0)
|
||||
const disabled = createMemo(() => permissions().length > 0 || questions().length > 0)
|
||||
|
||||
const pending = createMemo(() => {
|
||||
const completed = messages().findLast((x) => x.type === "assistant" && x.time.completed)?.id
|
||||
|
|
@ -251,7 +246,7 @@ export function Session() {
|
|||
await Promise.all([
|
||||
data.session.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
data.session.question.refresh(sessionID),
|
||||
])
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
|
|
@ -263,7 +258,6 @@ export function Session() {
|
|||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
await data.location.form.refresh(info.location)
|
||||
|
||||
project.workspace.set(info.location.workspaceID)
|
||||
editor.reconnect(info.location.directory)
|
||||
|
|
@ -945,8 +939,8 @@ export function Session() {
|
|||
<Match when={permissions().length > 0}>
|
||||
<PermissionPrompt request={permissions()[0]} directory={session()?.location.directory} />
|
||||
</Match>
|
||||
<Match when={forms().length > 0}>
|
||||
<FormPrompt request={forms()[0]} location={location()} />
|
||||
<Match when={questions().length > 0}>
|
||||
<QuestionPrompt request={questions()[0]} directory={session()?.location.directory} />
|
||||
</Match>
|
||||
<Match when={!disabled()}>
|
||||
<pluginRuntime.Slot
|
||||
|
|
@ -1393,7 +1387,13 @@ function UserMessage(props: { message: SessionMessageUser }) {
|
|||
>
|
||||
<text fg={theme.text}>{props.message.text}</text>
|
||||
<Show when={files().length}>
|
||||
<box flexDirection="row" paddingBottom={metadataVisible() ? 1 : 0} paddingTop={1} gap={1} flexWrap="wrap">
|
||||
<box
|
||||
flexDirection="row"
|
||||
paddingBottom={metadataVisible() ? 1 : 0}
|
||||
paddingTop={1}
|
||||
gap={1}
|
||||
flexWrap="wrap"
|
||||
>
|
||||
<For each={files()}>
|
||||
{(file) => {
|
||||
const directory = file.mime === "application/x-directory"
|
||||
|
|
@ -1723,8 +1723,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
return Boolean(shellID && data.shell.get(shellID))
|
||||
}
|
||||
if (display() === "subagent") {
|
||||
const sessionID =
|
||||
stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
|
||||
const sessionID = stringValue(props.part.state.structured.sessionID) ?? stringValue(props.part.state.structured.sessionId)
|
||||
return Boolean(sessionID && data.session.status(sessionID) === "running")
|
||||
}
|
||||
return false
|
||||
|
|
|
|||
514
packages/tui/src/routes/session/question.tsx
Normal file
514
packages/tui/src/routes/session/question.tsx
Normal file
|
|
@ -0,0 +1,514 @@
|
|||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
|
||||
import { useRenderer } from "@opentui/solid"
|
||||
import type { TextareaRenderable } from "@opentui/core"
|
||||
import { selectedForeground, tint, useTheme } from "../../context/theme"
|
||||
import type { QuestionV2Answer, QuestionV2Request } from "@opencode-ai/sdk/v2"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { SplitBorder } from "../../ui/border"
|
||||
import { useTuiConfig } from "../../config"
|
||||
import { useBindings, useOpencodeModeStack } from "../../keymap"
|
||||
|
||||
const QUESTION_MODE = "question"
|
||||
|
||||
export function QuestionPrompt(props: { request: QuestionV2Request; directory?: string }) {
|
||||
const sdk = useSDK()
|
||||
const { theme } = useTheme()
|
||||
const renderer = useRenderer()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const modeStack = useOpencodeModeStack()
|
||||
|
||||
const questions = createMemo(() => props.request.questions)
|
||||
const single = createMemo(() => questions().length === 1 && questions()[0]?.multiple !== true)
|
||||
const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single select)
|
||||
const [tabHover, setTabHover] = createSignal<number | "confirm" | null>(null)
|
||||
const [store, setStore] = createStore({
|
||||
tab: 0,
|
||||
answers: [] as QuestionV2Answer[],
|
||||
custom: [] as string[],
|
||||
selected: 0,
|
||||
editing: false,
|
||||
})
|
||||
|
||||
let textarea: TextareaRenderable | undefined
|
||||
|
||||
const question = createMemo(() => questions()[store.tab])
|
||||
const confirm = createMemo(() => !single() && store.tab === questions().length)
|
||||
const options = createMemo(() => question()?.options ?? [])
|
||||
const custom = createMemo(() => question()?.custom !== false)
|
||||
const other = createMemo(() => custom() && store.selected === options().length)
|
||||
const input = createMemo(() => store.custom[store.tab] ?? "")
|
||||
const multi = createMemo(() => question()?.multiple === true)
|
||||
const customPicked = createMemo(() => {
|
||||
const value = input()
|
||||
if (!value) return false
|
||||
return store.answers[store.tab]?.includes(value) ?? false
|
||||
})
|
||||
|
||||
function submit() {
|
||||
const answers = questions().map((_, i) => store.answers[i] ?? [])
|
||||
void sdk.api.question.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
answers,
|
||||
})
|
||||
}
|
||||
|
||||
function reject() {
|
||||
void sdk.api.question.reject({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
})
|
||||
}
|
||||
|
||||
function pick(answer: string, custom: boolean = false) {
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = [answer]
|
||||
setStore("answers", answers)
|
||||
if (custom) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = answer
|
||||
setStore("custom", inputs)
|
||||
}
|
||||
if (single()) {
|
||||
void sdk.api.question.reply({
|
||||
sessionID: props.request.sessionID,
|
||||
requestID: props.request.id,
|
||||
answers: [[answer]],
|
||||
})
|
||||
return
|
||||
}
|
||||
setStore("tab", store.tab + 1)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
function toggle(answer: string) {
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
const index = next.indexOf(answer)
|
||||
if (index === -1) next.push(answer)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
}
|
||||
|
||||
function moveTo(index: number) {
|
||||
setStore("selected", index)
|
||||
}
|
||||
|
||||
function selectTab(index: number) {
|
||||
setStore("tab", index)
|
||||
setStore("selected", 0)
|
||||
}
|
||||
|
||||
function selectOption() {
|
||||
if (other()) {
|
||||
if (!multi()) {
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const value = input()
|
||||
if (value && customPicked()) {
|
||||
toggle(value)
|
||||
return
|
||||
}
|
||||
setStore("editing", true)
|
||||
return
|
||||
}
|
||||
const opt = options()[store.selected]
|
||||
if (!opt) return
|
||||
if (multi()) {
|
||||
toggle(opt.label)
|
||||
return
|
||||
}
|
||||
pick(opt.label)
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
const popMode = modeStack.push(QUESTION_MODE)
|
||||
onCleanup(popMode)
|
||||
})
|
||||
|
||||
useBindings(() => ({
|
||||
mode: QUESTION_MODE,
|
||||
enabled: store.editing && !confirm(),
|
||||
commands: [
|
||||
{
|
||||
name: "prompt.clear",
|
||||
title: "Clear answer edit",
|
||||
category: "Question",
|
||||
run() {
|
||||
const text = textarea?.plainText ?? ""
|
||||
if (!text) {
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
textarea?.setText("")
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{
|
||||
key: "escape",
|
||||
desc: "Cancel answer edit",
|
||||
group: "Question",
|
||||
cmd: () => {
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
...tuiConfig.keybinds.get("prompt.clear"),
|
||||
{
|
||||
key: "return",
|
||||
desc: "Submit answer edit",
|
||||
group: "Question",
|
||||
cmd: () => {
|
||||
const text = textarea?.plainText?.trim() ?? ""
|
||||
const prev = store.custom[store.tab]
|
||||
|
||||
if (!text) {
|
||||
if (prev) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = ""
|
||||
setStore("custom", inputs)
|
||||
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = (answers[store.tab] ?? []).filter((x) => x !== prev)
|
||||
setStore("answers", answers)
|
||||
}
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
if (multi()) {
|
||||
const inputs = [...store.custom]
|
||||
inputs[store.tab] = text
|
||||
setStore("custom", inputs)
|
||||
|
||||
const existing = store.answers[store.tab] ?? []
|
||||
const next = [...existing]
|
||||
if (prev) {
|
||||
const index = next.indexOf(prev)
|
||||
if (index !== -1) next.splice(index, 1)
|
||||
}
|
||||
if (!next.includes(text)) next.push(text)
|
||||
const answers = [...store.answers]
|
||||
answers[store.tab] = next
|
||||
setStore("answers", answers)
|
||||
setStore("editing", false)
|
||||
return
|
||||
}
|
||||
|
||||
pick(text, true)
|
||||
setStore("editing", false)
|
||||
},
|
||||
},
|
||||
],
|
||||
}))
|
||||
|
||||
useBindings(() => {
|
||||
const opts = options()
|
||||
const total = opts.length + (custom() ? 1 : 0)
|
||||
const max = Math.min(total, 9)
|
||||
|
||||
return {
|
||||
mode: QUESTION_MODE,
|
||||
enabled: !store.editing,
|
||||
commands: [
|
||||
{
|
||||
name: "app.exit",
|
||||
title: "Reject question",
|
||||
category: "Question",
|
||||
run() {
|
||||
reject()
|
||||
},
|
||||
},
|
||||
],
|
||||
bindings: [
|
||||
{
|
||||
key: "left",
|
||||
desc: "Previous question",
|
||||
group: "Question",
|
||||
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
||||
},
|
||||
{
|
||||
key: "h",
|
||||
desc: "Previous question",
|
||||
group: "Question",
|
||||
cmd: () => selectTab((store.tab - 1 + tabs()) % tabs()),
|
||||
},
|
||||
{ key: "right", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||
{ key: "l", desc: "Next question", group: "Question", cmd: () => selectTab((store.tab + 1) % tabs()) },
|
||||
{
|
||||
key: "tab",
|
||||
desc: "Next question",
|
||||
group: "Question",
|
||||
cmd: ({ event }: { event: { shift: boolean } }) => {
|
||||
selectTab((store.tab + (event.shift ? -1 : 1) + tabs()) % tabs())
|
||||
},
|
||||
},
|
||||
...(confirm()
|
||||
? [
|
||||
{ key: "return", desc: "Submit answer", group: "Question", cmd: () => submit() },
|
||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
]
|
||||
: [
|
||||
...Array.from({ length: max }, (_, index) => ({
|
||||
key: String(index + 1),
|
||||
desc: `Select answer ${index + 1}`,
|
||||
group: "Question",
|
||||
cmd: () => {
|
||||
moveTo(index)
|
||||
selectOption()
|
||||
},
|
||||
})),
|
||||
{
|
||||
key: "up",
|
||||
desc: "Previous answer",
|
||||
group: "Question",
|
||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||
},
|
||||
{
|
||||
key: "k",
|
||||
desc: "Previous answer",
|
||||
group: "Question",
|
||||
cmd: () => moveTo((store.selected - 1 + total) % total),
|
||||
},
|
||||
{ key: "down", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
||||
{ key: "j", desc: "Next answer", group: "Question", cmd: () => moveTo((store.selected + 1) % total) },
|
||||
{ key: "return", desc: "Select answer", group: "Question", cmd: () => selectOption() },
|
||||
{ key: "escape", desc: "Reject question", group: "Question", cmd: () => reject() },
|
||||
...tuiConfig.keybinds.get("app.exit"),
|
||||
]),
|
||||
],
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<box
|
||||
backgroundColor={theme.backgroundPanel}
|
||||
border={["left"]}
|
||||
borderColor={theme.accent}
|
||||
customBorderChars={SplitBorder.customBorderChars}
|
||||
>
|
||||
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
|
||||
<Show when={!single()}>
|
||||
<box flexDirection="row" gap={1} paddingLeft={1}>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const isActive = () => index() === store.tab
|
||||
const isAnswered = () => {
|
||||
return (store.answers[index()]?.length ?? 0) > 0
|
||||
}
|
||||
return (
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
isActive()
|
||||
? theme.accent
|
||||
: tabHover() === index()
|
||||
? theme.backgroundElement
|
||||
: theme.backgroundPanel
|
||||
}
|
||||
onMouseOver={() => setTabHover(index())}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(index())
|
||||
}}
|
||||
>
|
||||
<text
|
||||
fg={
|
||||
isActive()
|
||||
? selectedForeground(theme, theme.accent)
|
||||
: isAnswered()
|
||||
? theme.text
|
||||
: theme.textMuted
|
||||
}
|
||||
>
|
||||
{q.header}
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<box
|
||||
paddingLeft={1}
|
||||
paddingRight={1}
|
||||
backgroundColor={
|
||||
confirm() ? theme.accent : tabHover() === "confirm" ? theme.backgroundElement : theme.backgroundPanel
|
||||
}
|
||||
onMouseOver={() => setTabHover("confirm")}
|
||||
onMouseOut={() => setTabHover(null)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectTab(questions().length)
|
||||
}}
|
||||
>
|
||||
<text fg={confirm() ? selectedForeground(theme, theme.accent) : theme.textMuted}>Confirm</text>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={!confirm()}>
|
||||
<box paddingLeft={1} gap={1}>
|
||||
<box>
|
||||
<text fg={theme.text}>
|
||||
{question()?.question}
|
||||
{multi() ? " (select all that apply)" : ""}
|
||||
</text>
|
||||
</box>
|
||||
<box>
|
||||
<For each={options()}>
|
||||
{(opt, i) => {
|
||||
const active = () => i() === store.selected
|
||||
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
|
||||
return (
|
||||
<box
|
||||
onMouseOver={() => moveTo(i())}
|
||||
onMouseDown={() => moveTo(i())}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectOption()
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={active() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
{`${i() + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
|
||||
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
|
||||
{multi() ? `[${picked() ? "✓" : " "}] ${opt.label}` : opt.label}
|
||||
</text>
|
||||
</box>
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{picked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{opt.description}</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
<Show when={custom()}>
|
||||
<box
|
||||
onMouseOver={() => moveTo(options().length)}
|
||||
onMouseDown={() => moveTo(options().length)}
|
||||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
selectOption()
|
||||
}}
|
||||
>
|
||||
<box flexDirection="row">
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined} paddingRight={1}>
|
||||
<text fg={other() ? tint(theme.textMuted, theme.secondary, 0.6) : theme.textMuted}>
|
||||
{`${options().length + 1}.`}
|
||||
</text>
|
||||
</box>
|
||||
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
|
||||
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
|
||||
{multi() ? `[${customPicked() ? "✓" : " "}] Type your own answer` : "Type your own answer"}
|
||||
</text>
|
||||
</box>
|
||||
|
||||
<Show when={!multi()}>
|
||||
<text fg={theme.success}>{customPicked() ? " ✓" : ""}</text>
|
||||
</Show>
|
||||
</box>
|
||||
<Show when={store.editing}>
|
||||
<box paddingLeft={3}>
|
||||
<textarea
|
||||
ref={(val: TextareaRenderable) => {
|
||||
textarea = val
|
||||
val.traits = { status: "ANSWER" }
|
||||
queueMicrotask(() => {
|
||||
val.focus()
|
||||
val.gotoLineEnd()
|
||||
})
|
||||
}}
|
||||
initialValue={input()}
|
||||
placeholder="Type your own answer"
|
||||
placeholderColor={theme.textMuted}
|
||||
minHeight={1}
|
||||
maxHeight={6}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.primary}
|
||||
/>
|
||||
</box>
|
||||
</Show>
|
||||
<Show when={!store.editing && input()}>
|
||||
<box paddingLeft={3}>
|
||||
<text fg={theme.textMuted}>{input()}</text>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</Show>
|
||||
</box>
|
||||
</box>
|
||||
</Show>
|
||||
|
||||
<Show when={confirm() && !single()}>
|
||||
<box paddingLeft={1}>
|
||||
<text fg={theme.text}>Review</text>
|
||||
</box>
|
||||
<For each={questions()}>
|
||||
{(q, index) => {
|
||||
const value = () => store.answers[index()]?.join(", ") ?? ""
|
||||
const answered = () => Boolean(value())
|
||||
return (
|
||||
<box paddingLeft={1}>
|
||||
<text>
|
||||
<span style={{ fg: theme.textMuted }}>{q.header}:</span>{" "}
|
||||
<span style={{ fg: answered() ? theme.text : theme.error }}>
|
||||
{answered() ? value() : "(not answered)"}
|
||||
</span>
|
||||
</text>
|
||||
</box>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</Show>
|
||||
</box>
|
||||
<box
|
||||
flexDirection="row"
|
||||
flexShrink={0}
|
||||
gap={1}
|
||||
paddingLeft={2}
|
||||
paddingRight={3}
|
||||
paddingBottom={1}
|
||||
justifyContent="space-between"
|
||||
>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<Show when={!single()}>
|
||||
<text fg={theme.text}>
|
||||
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
|
||||
</text>
|
||||
</Show>
|
||||
<Show when={!confirm()}>
|
||||
<text fg={theme.text}>
|
||||
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
|
||||
</text>
|
||||
</Show>
|
||||
<text fg={theme.text}>
|
||||
enter{" "}
|
||||
<span style={{ fg: theme.textMuted }}>
|
||||
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
|
||||
</span>
|
||||
</text>
|
||||
|
||||
<text fg={theme.text}>
|
||||
esc <span style={{ fg: theme.textMuted }}>dismiss</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
|
@ -672,45 +672,37 @@ test("adds and dismisses question requests from live events", async () => {
|
|||
try {
|
||||
await wait(() => data.connection.status() === "connected")
|
||||
emitEvent(events, {
|
||||
id: "evt_form_created_1",
|
||||
type: "form.created",
|
||||
id: "evt_question_asked_1",
|
||||
type: "question.v2.asked",
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_1",
|
||||
sessionID: "ses_1",
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "question_0", title: "Which option?", description: "Option", type: "string", options: [] }],
|
||||
},
|
||||
id: "que_1",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which option?", header: "Option", options: [], multiple: false }],
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_form_created_2",
|
||||
type: "form.created",
|
||||
id: "evt_question_asked_2",
|
||||
type: "question.v2.asked",
|
||||
data: {
|
||||
form: {
|
||||
id: "frm_2",
|
||||
sessionID: "ses_1",
|
||||
mode: "form",
|
||||
metadata: { kind: "question" },
|
||||
fields: [{ key: "question_0", title: "Which environment?", description: "Environment", type: "string", options: [] }],
|
||||
},
|
||||
id: "que_2",
|
||||
sessionID: "ses_1",
|
||||
questions: [{ question: "Which environment?", header: "Environment", options: [], multiple: false }],
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 2)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_form_replied_1",
|
||||
type: "form.replied",
|
||||
data: { id: "frm_1", sessionID: "ses_1", answer: { question_0: "First" } },
|
||||
id: "evt_question_replied_1",
|
||||
type: "question.v2.replied",
|
||||
data: { sessionID: "ses_1", requestID: "que_1", answers: [["First"]] },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 1)
|
||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("frm_2")
|
||||
expect(data.session.question.list("ses_1")?.[0]?.id).toBe("que_2")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_form_cancelled_2",
|
||||
type: "form.cancelled",
|
||||
data: { id: "frm_2", sessionID: "ses_1" },
|
||||
id: "evt_question_rejected_2",
|
||||
type: "question.v2.rejected",
|
||||
data: { sessionID: "ses_1", requestID: "que_2" },
|
||||
})
|
||||
await wait(() => data.session.question.list("ses_1")?.length === 0)
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue