feat(core): MCP elicitation support (#35064)
This commit is contained in:
parent
e65477ab1d
commit
efcf2c3f5d
18 changed files with 1170 additions and 668 deletions
36
packages/app/src/utils/question-form.test.ts
Normal file
36
packages/app/src/utils/question-form.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
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,49 +1,72 @@
|
|||
import type { FormAnswer, FormFormInfo, FormUrlInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
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 QuestionField = FormFormInfo["fields"][number]
|
||||
|
||||
export type QuestionForm = {
|
||||
id: string
|
||||
sessionID: string
|
||||
mode: "form"
|
||||
metadata?: { [key: string]: unknown }
|
||||
fields: QuestionField[]
|
||||
}
|
||||
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 { mode?: unknown; metadata?: unknown; fields?: unknown }
|
||||
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
|
||||
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"
|
||||
return ["string", "number", "integer", "boolean", "multiselect"].includes(String(field.type))
|
||||
}
|
||||
|
||||
export function questionAnswer(fields: ReadonlyArray<QuestionField>, answers: ReadonlyArray<QuestionAnswer>) {
|
||||
const entries = fields.flatMap((field, index): ReadonlyArray<readonly [string, string | string[]]> => {
|
||||
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) return []
|
||||
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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue