feat(core): add session form service (#34855)

This commit is contained in:
Aiden Cline 2026-07-02 17:15:18 -05:00 committed by GitHub
commit 7ebd344fa2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 7862 additions and 5223 deletions

View file

@ -69,7 +69,10 @@ describe("bootstrapDirectory", () => {
},
permission: { list: async () => ({ data: [] }) },
question: { list: async () => ({ data: [] }) },
v2: { reference: { list: async () => ({ data: { data: [] } }) } },
v2: {
form: { request: { list: async () => ({ data: { data: [] } }) } },
reference: { list: async () => ({ data: { data: [] } }) },
},
mcp: {
status: async () => {
mcpReads.push("status")

View file

@ -5,7 +5,6 @@ import type {
PermissionRequest,
Project,
ProviderAuthResponse,
QuestionRequest,
ReferenceInfo,
Session,
} from "@opencode-ai/sdk/v2/client"
@ -22,6 +21,7 @@ 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,9 +319,10 @@ export async function bootstrapDirectory(input: {
),
() =>
retry(() =>
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))
input.sdk.v2.form.request.list().then((x) => {
const forms: QuestionForm[] = (x.data?.data ?? []).flatMap((form) => (isQuestionForm(form) ? [form] : []))
const ids = forms.map((question) => question.sessionID)
const grouped = groupBySession(forms)
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 })

View file

@ -1,8 +1,9 @@
import { describe, expect, test } from "bun:test"
import type { Message, Part, PermissionRequest, Project, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { Message, Part, PermissionRequest, Project, 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 }) =>
({
@ -48,14 +49,18 @@ const questionRequest = (id: string, sessionID: string, title = id) =>
({
id,
sessionID,
questions: [
mode: "form",
metadata: { kind: "question" },
fields: [
{
question: title,
header: title,
options: [{ label: title, description: title }],
key: "question_0",
title,
description: title,
type: "string",
options: [{ value: title, label: title, description: title }],
},
],
}) as QuestionRequest
}) as QuestionForm
const baseState = (input: Partial<State> = {}) =>
({
@ -505,7 +510,7 @@ describe("applyDirectoryEvent", () => {
expect(store.permission[sessionID]?.map((x) => x.id)).toEqual(["perm_1", "perm_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID) },
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID) } },
store,
setStore,
push() {},
@ -515,17 +520,17 @@ describe("applyDirectoryEvent", () => {
expect(store.question[sessionID]?.map((x) => x.id)).toEqual(["q_1", "q_2", "q_3"])
applyDirectoryEvent({
event: { type: "question.asked", properties: questionRequest("q_2", sessionID, "updated") },
event: { type: "form.created", properties: { form: questionRequest("q_2", sessionID, "updated") } },
store,
setStore,
push() {},
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.questions[0]?.header).toBe("updated")
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated")
applyDirectoryEvent({
event: { type: "question.rejected", properties: { sessionID, requestID: "q_2" } },
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
store,
setStore,
push() {},

View file

@ -5,7 +5,6 @@ import type {
Part,
PermissionRequest,
Project,
QuestionRequest,
Session,
SessionStatus,
SnapshotFileDiff,
@ -15,6 +14,7 @@ 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",
"question.asked",
"question.replied",
"question.rejected",
"form.created",
"form.replied",
"form.cancelled",
])
export function applyGlobalEvent(input: {
@ -364,8 +364,10 @@ export function applyDirectoryEvent(input: {
)
break
}
case "question.asked": {
const question = event.properties as QuestionRequest
case "form.created": {
const properties = event.properties as { form?: unknown }
if (!isQuestionForm(properties.form)) break
const question = properties.form
const questions = input.store.question[question.sessionID]
if (!questions) {
input.setStore("question", question.sessionID, [question])
@ -385,12 +387,12 @@ export function applyDirectoryEvent(input: {
)
break
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
case "form.replied":
case "form.cancelled": {
const props = event.properties as { sessionID: string; id: string }
const questions = input.store.question[props.sessionID]
if (!questions) break
const result = Binary.search(questions, props.requestID, (q) => q.id)
const result = Binary.search(questions, props.id, (q) => q.id)
if (!result.found) break
input.setStore(
"question",

View file

@ -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, QuestionRequest[] | undefined>
question: Record<string, QuestionForm[] | 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 QuestionRequest[] },
question: { ses_1: [] as QuestionForm[] },
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, QuestionRequest[] | undefined>
question: Record<string, QuestionForm[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
} = {
session_status: {},

View file

@ -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, QuestionRequest[] | undefined>
question: Record<string, QuestionForm[] | undefined>
part_text_accum_delta: Record<string, string | undefined>
}

View file

@ -9,7 +9,6 @@ import type {
Part,
Path,
PermissionRequest,
QuestionRequest,
ReferenceInfo,
Session,
SessionStatus,
@ -17,6 +16,7 @@ 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]: QuestionRequest[]
[sessionID: string]: QuestionForm[]
}
mcp_ready: boolean
mcp: {

View file

@ -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, QuestionRequest[]>,
question: {} as Record<string, QuestionForm[]>,
message: {} as Record<string, Message[]>,
part: {} as Record<string, Part[]>,
part_text_accum_delta: {} as Record<string, string>,
@ -932,8 +932,10 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
)
return
}
case "question.asked": {
const question = event.properties as QuestionRequest
case "form.created": {
const properties = event.properties as { form?: unknown }
if (!isQuestionForm(properties.form)) return
const question = properties.form
const questions = data.question[question.sessionID]
if (!questions) {
setData("question", question.sessionID, [question])
@ -949,15 +951,15 @@ export function createServerSession(client: OpencodeClient, options?: { retry?:
)
return
}
case "question.replied":
case "question.rejected": {
const props = event.properties as { sessionID: string; requestID: string }
case "form.replied":
case "form.cancelled": {
const props = event.properties as { sessionID: string; id: string }
setData(
"question",
props.sessionID,
produce((draft) => {
if (!draft) return
const result = Binary.search(draft, props.requestID, (item) => item.id)
const result = Binary.search(draft, props.id, (item) => item.id)
if (result.found) draft.splice(result.index, 1)
}),
)

View file

@ -48,6 +48,7 @@ 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"
@ -403,25 +404,22 @@ export default function LegacyLayout(props: ParentProps) {
return
}
if (
e.details?.type === "question.replied" ||
e.details?.type === "question.rejected" ||
e.details?.type === "permission.replied"
) {
if (e.details?.type === "form.replied" || e.details?.type === "form.cancelled" || e.details?.type === "permission.replied") {
const props = e.details.properties as { sessionID: string }
const sessionKey = `${e.name}:${props.sessionID}`
dismissSessionAlert(sessionKey)
return
}
if (e.details?.type !== "permission.asked" && e.details?.type !== "question.asked") 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
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 = e.details.properties
const props = questionForm ?? (e.details.properties as { sessionID: string })
if (e.details.type === "permission.asked" && permission.autoResponds(e.details.properties, directory)) return
const [store] = serverSync().child(directory, { bootstrap: false })
@ -450,7 +448,7 @@ export default function LegacyLayout(props: ParentProps) {
}
}
if (e.details.type === "question.asked") {
if (questionForm) {
if (settings.notifications.agent()) {
void platform.notify(title, description, href)
}

View file

@ -1,5 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { QuestionForm } from "@/utils/question-form"
import { todoDockAtBoundary, todoState } from "./session-composer-state"
import { sessionPermissionRequest, sessionQuestionRequest } from "./session-request-tree"
@ -19,8 +20,10 @@ const question = (id: string, sessionID: string) =>
({
id,
sessionID,
questions: [],
}) as QuestionRequest
mode: "form",
metadata: { kind: "question" },
fields: [],
}) as QuestionForm
describe("sessionPermissionRequest", () => {
test("prefers the current session permission", () => {

View file

@ -1,6 +1,7 @@
import { createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import type { PermissionRequest, QuestionRequest, Todo } from "@opencode-ai/sdk/v2"
import type { PermissionRequest, Todo } from "@opencode-ai/sdk/v2"
import type { QuestionForm } from "@/utils/question-form"
import { useParams } from "@solidjs/router"
import { showToast } from "@/utils/toast"
import { useServerSync } from "@/context/server-sync"
@ -33,7 +34,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const language = useLanguage()
const permission = usePermission()
const questionRequest = createMemo((): QuestionRequest | undefined => {
const questionRequest = createMemo((): QuestionForm | undefined => {
return sessionQuestionRequest(sync().data.session, sync().data.question, params.id)
})

View file

@ -6,13 +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 { questionAnswer, type QuestionAnswer, type QuestionForm } from "@/utils/question-form"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@ -61,13 +61,13 @@ function Option(props: {
)
}
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
export const SessionQuestionDock: Component<{ request: QuestionForm; 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.questions)
const questions = createMemo(() => props.request.fields)
const total = createMemo(() => questions().length)
const cached = cache.get(cacheKey)
@ -91,10 +91,11 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? [])
const custom = createMemo(() => question()?.custom !== false)
const input = createMemo(() => store.custom[store.tab] ?? "")
const on = createMemo(() => store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.multiple === true)
const count = createMemo(() => options().length + 1)
const on = createMemo(() => custom() && store.customOn[store.tab] === true)
const multi = createMemo(() => question()?.type === "multiselect")
const count = createMemo(() => options().length + (custom() ? 1 : 0))
const summary = createMemo(() => {
const n = Math.min(store.tab + 1, total())
@ -154,7 +155,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const pickFocus = (tab: number = store.tab) => {
const list = questions()[tab]?.options ?? []
if (store.customOn[tab] === true) return list.length
if (questions()[tab]?.custom !== false && store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
@ -223,7 +224,12 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}
const replyMutation = useMutation(() => ({
mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }),
mutationFn: (answers: QuestionAnswer[]) =>
sdk().client.v2.session.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
formReply: { answer: questionAnswer(props.request.fields, answers) },
}),
onMutate: () => {
props.onSubmit()
},
@ -235,7 +241,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
}))
const rejectMutation = useMutation(() => ({
mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }),
mutationFn: () => sdk().client.v2.session.form.cancel({ sessionID: props.request.sessionID, formID: props.request.id }),
onMutate: () => {
props.onSubmit()
},
@ -262,7 +268,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
return questions()[i]?.custom !== false && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
@ -283,6 +289,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customToggle = () => {
if (sending()) return
if (!custom()) return
setStore("focus", options().length)
if (!multi()) {
@ -308,6 +315,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const customOpen = () => {
if (sending()) return
if (!custom()) return
setStore("focus", options().length)
if (!on()) setStore("customOn", store.tab, true)
setStore("editing", true)
@ -369,6 +377,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
if (sending()) return
if (optIndex === options().length) {
if (!custom()) return
customOpen()
return
}
@ -526,7 +535,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
overflow: store.minimized ? "hidden" : undefined,
}}
>
{question()?.question}
{question()?.title}
</div>
<Show when={!store.minimized}>
<Show when={multi()} fallback={<div data-slot="question-hint">{language.t("ui.question.singleHint")}</div>}>
@ -559,78 +568,80 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
)}
</For>
<Show
when={store.editing}
fallback={
<button
type="button"
ref={customRef}
<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
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}
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>
<span data-slot="option-description">{input() || customPlaceholder()}</span>
</span>
</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") {
<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()
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>
commitCustom()
}}
onInput={(e) => {
customUpdate(e.currentTarget.value)
resizeInput(e.currentTarget)
}}
/>
</span>
</form>
</Show>
</Show>
</div>
</DockPrompt>

View file

@ -1,4 +1,5 @@
import type { PermissionRequest, QuestionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { PermissionRequest, Session } from "@opencode-ai/sdk/v2/client"
import type { QuestionForm } from "@/utils/question-form"
function sessionTreeRequest<T>(
session: Session[],
@ -44,9 +45,9 @@ export function sessionPermissionRequest(
export function sessionQuestionRequest(
session: Session[],
request: Record<string, QuestionRequest[] | undefined>,
request: Record<string, QuestionForm[] | undefined>,
sessionID?: string,
include?: (item: QuestionRequest) => boolean,
include?: (item: QuestionForm) => boolean,
) {
return sessionTreeRequest(session, request, sessionID, include)
}

View file

@ -0,0 +1,49 @@
export type QuestionOption = {
value: string
label: string
description?: string
}
export type QuestionField = {
key: string
title?: string
description?: string
type: "string" | "multiselect"
options?: QuestionOption[]
custom?: boolean
}
export type QuestionForm = {
id: string
sessionID: string
mode: "form"
metadata?: { [key: string]: unknown }
fields: QuestionField[]
}
export type QuestionAnswer = string[]
export function isQuestionForm(value: unknown): value is QuestionForm {
if (typeof value !== "object" || value === null) return false
const form = value as { mode?: unknown; metadata?: unknown; fields?: unknown }
if (form.mode !== "form") return false
if (typeof form.metadata !== "object" || form.metadata === null) return false
if ((form.metadata as { kind?: unknown }).kind !== "question") 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 field.type === "string" || field.type === "multiselect"
}
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>) {
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, string | string[]]> => {
const answer = answers[index] ?? []
if (answer.length === 0) return []
if (field.type === "multiselect") return [[field.key, answer]]
return [[field.key, answer[0] ?? ""]]
})
return Object.fromEntries(entries)
}