fix(core): revert form service and mcp elicitation (#35080)

This commit is contained in:
Aiden Cline 2026-07-03 00:32:10 -05:00 committed by GitHub
commit ef2140d121
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 6587 additions and 6492 deletions

View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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