refactor(core): route questions through forms (#35422)

This commit is contained in:
Aiden Cline 2026-07-05 22:28:05 -05:00 committed by GitHub
commit 780c99bc2e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 150 additions and 674 deletions

View file

@ -3,6 +3,7 @@ export * as QuestionTool from "./question"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Tool } from "./tool"
@ -29,6 +30,12 @@ export const Output = Schema.Struct({
})
export type Output = typeof Output.Type
export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("QuestionTool.CancelledError", {}) {
override get message() {
return "The user dismissed this question"
}
}
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
answers: ReadonlyArray<QuestionV2.Answer>,
@ -45,7 +52,7 @@ export const toModelOutput = (
export const Plugin = {
id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const question = yield* QuestionV2.Service
const forms = yield* Form.Service
const permission = yield* PermissionV2.Service
yield* ctx.tool
@ -69,15 +76,42 @@ export const Plugin = {
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.andThen(
question
forms
.ask({
sessionID: context.sessionID,
questions: input.questions,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
metadata: {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
},
mode: "form",
fields: input.questions.map(
(question, index): Form.Field => ({
key: `q${index}`,
title: question.header,
description: question.question,
type: question.multiple === true ? "multiselect" : "string",
options: question.options.map((option) => ({
value: option.label,
label: option.label,
description: option.description,
})),
custom: true,
}),
),
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
Effect.flatMap((state) => {
if (state.status === "cancelled") return Effect.die(new CancelledError())
return Effect.succeed({
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
})
}),
),
}),
})