feat(core): MCP elicitation support (#35064)

This commit is contained in:
Aiden Cline 2026-07-02 23:25:54 -05:00 committed by GitHub
commit efcf2c3f5d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 1170 additions and 668 deletions

View file

@ -30,11 +30,13 @@ 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)
}

View file

@ -321,8 +321,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 ids = forms.map((question) => question.sessionID)
const grouped = groupBySession(forms)
const global = forms.filter((question) => question.sessionID === "global")
const grouped = groupBySession(forms.filter((question) => question.sessionID !== "global"))
const ids = Object.keys(grouped)
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 })
@ -330,11 +331,22 @@ 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)),

View file

@ -527,7 +527,8 @@ describe("applyDirectoryEvent", () => {
directory: "/tmp",
loadLsp() {},
})
expect(store.question[sessionID]?.find((x) => x.id === "q_2")?.fields[0]?.description).toBe("updated")
const form = store.question[sessionID]?.find((x) => x.id === "q_2")
expect(form?.mode === "form" ? form.fields[0]?.description : undefined).toBe("updated")
applyDirectoryEvent({
event: { type: "form.cancelled", properties: { sessionID, id: "q_2" } },
@ -540,6 +541,34 @@ 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({

View file

@ -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)) return
if (input.sessionContent === false && SESSION_CONTENT_EVENTS.has(event.type) && !isGlobalQuestionEvent(event)) return
const limit = Math.max(input.store.limit, input.retainedLimit ?? 0)
switch (event.type) {
case "server.instance.disposed": {
@ -413,3 +413,13 @@ 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"
}

View file

@ -25,7 +25,7 @@ import {
loadReferencesQuery,
} from "./global-sync/bootstrap"
import { createChildStoreManager } from "./global-sync/child-store"
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
import { applyDirectoryEvent, applyGlobalEvent, isGlobalQuestionEvent } 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
session.apply(event)
if (!isGlobalQuestionEvent(event)) session.apply(event)
if (directory === "global") {
applyGlobalEvent({

View file

@ -12,7 +12,15 @@ 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"
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[] }>()
@ -67,8 +75,8 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
const questions = createMemo(() => props.request.fields)
const total = createMemo(() => questions().length)
const questions = createMemo(() => (props.request.mode === "form" ? props.request.fields : []))
const total = createMemo(() => (props.request.mode === "url" ? 1 : questions().length))
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
@ -90,17 +98,23 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
let focusFrame: number | undefined
const question = createMemo(() => questions()[store.tab])
const options = createMemo(() => question()?.options ?? [])
const custom = createMemo(() => question()?.custom !== false)
const options = createMemo(() => questionOptions(question()))
const custom = createMemo(() => questionAllowsCustom(question()))
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 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")
@ -154,11 +168,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 = questions()[tab]?.options ?? []
if (questions()[tab]?.custom !== false && store.customOn[tab] === true) return list.length
const list = questionOptions(questions()[tab])
if (questionAllowsCustom(questions()[tab]) && store.customOn[tab] === true) return list.length
return Math.max(
0,
list.findIndex((item) => store.answers[tab]?.includes(item.label) ?? false),
list.findIndex((item) => store.answers[tab]?.includes(item.value) ?? false),
)
}
@ -228,7 +242,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
sdk().client.v2.session.form.reply({
sessionID: props.request.sessionID,
formID: props.request.id,
formReply: { answer: questionAnswer(props.request.fields, answers) },
formReply: { answer: props.request.mode === "url" ? {} : questionAnswer(props.request.fields, answers) },
}),
onMutate: () => {
props.onSubmit()
@ -268,7 +282,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
const answered = (i: number) => {
if ((store.answers[i]?.length ?? 0) > 0) return true
return questions()[i]?.custom !== false && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
return questionAllowsCustom(questions()[i]) && store.customOn[i] === true && (store.custom[i] ?? "").trim().length > 0
}
const picked = (answer: string) => store.answers[store.tab]?.includes(answer) ?? false
@ -386,10 +400,10 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
if (!opt) return
if (multi()) {
setStore("editing", false)
toggle(opt.label)
toggle(opt.value)
return
}
pick(opt.label)
pick(opt.value)
}
const commitCustom = () => {
@ -535,11 +549,24 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
overflow: store.minimized ? "hidden" : undefined,
}}
>
{question()?.title}
{body()}
</div>
<Show when={message()}>
<div data-slot="question-hint">{message()}</div>
</Show>
<Show when={!store.minimized}>
<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
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>
</Show>
<div
@ -557,7 +584,7 @@ export const SessionQuestionDock: Component<{ request: QuestionForm; onSubmit: (
{(opt, i) => (
<Option
multi={multi()}
picked={picked(opt.label)}
picked={picked(opt.value)}
label={opt.label}
description={opt.description}
disabled={sending()}

View file

@ -49,5 +49,5 @@ export function sessionQuestionRequest(
sessionID?: string,
include?: (item: QuestionForm) => boolean,
) {
return sessionTreeRequest(session, request, sessionID, include)
return sessionTreeRequest(session, request, sessionID, include) ?? request.global?.find(include ?? (() => true))
}

View 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"],
})
})
})

View file

@ -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
}