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

308
packages/core/src/form.ts Normal file
View file

@ -0,0 +1,308 @@
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,6 +9,7 @@ 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"
@ -24,7 +25,6 @@ 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,
QuestionV2.node,
Form.node,
Generate.node,
ReadToolFileSystem.node,
BuiltInTools.node,

View file

@ -1,151 +0,0 @@
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,7 +14,9 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { QuestionV2 } from "../../question"
import { ModelV2 } from "../../model"
import { ProviderV2 } from "../../provider"
import { QuestionTool } from "../../tool/question"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
@ -153,7 +155,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 QuestionV2.RejectedError)
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionTool.RejectedError)
type TurnTransition =
// Automatic compaction completed; rebuild the request from compacted history.

View file

@ -2,9 +2,10 @@ 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"
@ -22,19 +23,40 @@ 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(QuestionV2.Prompt).annotate({ description: "Questions to ask" }),
questions: Schema.Array(Prompt).annotate({ description: "Questions to ask" }),
})
export const Output = Schema.Struct({
answers: Schema.Array(QuestionV2.Answer),
answers: Schema.Array(Answer),
})
export type Output = typeof Output.Type
export const toModelOutput = (
questions: ReadonlyArray<QuestionV2.Prompt>,
answers: ReadonlyArray<QuestionV2.Answer>,
) => {
export class RejectedError extends Error {
constructor() {
super("The user dismissed this question")
}
}
export const toModelOutput = (questions: ReadonlyArray<Prompt>, answers: ReadonlyArray<Answer>) => {
const formatted = questions
.map(
(question, index) =>
@ -47,7 +69,7 @@ export const toModelOutput = (
const layer = Layer.effectDiscard(
Effect.gen(function* () {
const tools = yield* Tools.Service
const question = yield* QuestionV2.Service
const form = yield* Form.Service
const permission = yield* PermissionV2.Service
yield* tools
@ -71,15 +93,22 @@ const layer = Layer.effectDiscard(
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.andThen(
question
form
.ask({
sessionID: context.sessionID,
questions: input.questions,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
title: input.questions.length === 1 ? input.questions[0]?.header : "Questions",
metadata: {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
},
mode: "form",
fields: input.questions.map(questionToField),
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
Effect.flatMap((state) =>
state.status === "answered" ? Effect.succeed({ answers: formToAnswers(input.questions, state.answer) }) : Effect.die(new RejectedError()),
),
),
}),
})
@ -90,5 +119,33 @@ const layer = Layer.effectDiscard(
export const node = makeLocationNode({
name: "tool/question",
layer,
deps: [ToolRegistry.node, PermissionV2.node, QuestionV2.node],
deps: [ToolRegistry.node, PermissionV2.node, Form.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}`
}