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

View file

@ -0,0 +1,58 @@
import { describe, expect } from "bun:test"
import { Effect, Exit } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { testEffect } from "./lib/effect"
const forms = AppNodeBuilder.build(LayerNode.group([EventV2.node, Form.node]))
const it = testEffect(forms)
const formID = Form.ID.create("frm_test")
const input = {
id: formID,
sessionID: "ses_test",
mode: "form",
fields: [{ key: "name", type: "string", required: true }],
} satisfies Form.CreateInput
describe("Form", () => {
it.effect("cleans up created forms when event publication fails", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const events = yield* EventV2.Service
const unsubscribe = yield* events.listen((event) =>
event.type === Form.Event.Created.type ? Effect.die("create listener failed") : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
expect(Exit.isFailure(yield* Effect.exit(service.create(input)))).toBe(true)
expect(yield* service.get(formID).pipe(Effect.flip)).toEqual(new Form.NotFoundError({ id: formID }))
yield* unsubscribe
expect(yield* service.create(input)).toMatchObject({ id: formID })
}),
)
it.effect("keeps forms pending when reply event publication fails", () =>
Effect.gen(function* () {
const service = yield* Form.Service
const events = yield* EventV2.Service
yield* service.create(input)
const unsubscribe = yield* events.listen((event) =>
event.type === Form.Event.Replied.type ? Effect.die("reply listener failed") : Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
expect(
Exit.isFailure(yield* Effect.exit(service.reply({ id: formID, answer: { name: "Ava" } }))),
).toBe(true)
expect(yield* service.state(formID)).toEqual({ status: "pending" })
yield* unsubscribe
yield* service.reply({ id: formID, answer: { name: "Ava" } })
expect(yield* service.state(formID)).toEqual({ status: "answered", answer: { name: "Ava" } })
}),
)
})

View file

@ -1,114 +0,0 @@
import { describe, expect } from "bun:test"
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { EventV2 } from "@opencode-ai/core/event"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { testEffect } from "./lib/effect"
const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
const it = testEffect(questions)
const sessionID = SessionV2.ID.make("ses_question_test")
const question: QuestionV2.Info = {
question: "Which option?",
header: "Option",
options: [{ label: "One", description: "First option" }],
}
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
service: QuestionV2.Interface,
input: QuestionV2.AskInput,
) {
const events = yield* EventV2.Service
const asked = yield* Deferred.make<QuestionV2.Request>()
const unsubscribe = yield* events.listen((event) =>
event.type === QuestionV2.Event.Asked.type
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
: Effect.void,
)
yield* Effect.addFinalizer(() => unsubscribe)
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
return { fiber, request: yield* Deferred.await(asked) }
})
describe("QuestionV2", () => {
it.effect("publishes lifecycle events and settles a pending reply", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type.startsWith("question.v2.")) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
expect(request.id).toMatch(/^que_/)
expect(yield* service.list()).toEqual([request])
yield* service.reply({ requestID: request.id, answers: [["One"]] })
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
expect(yield* service.list()).toEqual([])
expect(published.map((event) => [event.type, event.data])).toEqual([
[QuestionV2.Event.Asked.type, request],
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
])
}),
)
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
Effect.gen(function* () {
const service = yield* QuestionV2.Service
const events = yield* EventV2.Service
const published: EventV2.Payload[] = []
const unsubscribe = yield* events.listen((event) =>
Effect.sync(() => {
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
}),
)
yield* Effect.addFinalizer(() => unsubscribe)
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
yield* service.reject(request.id)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
const unknown = QuestionV2.ID.ascending("que_unknown")
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
)
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: unknown }),
)
}),
)
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
Effect.gen(function* () {
const firstScope = yield* Scope.make()
const secondScope = yield* Scope.make()
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
yield* Effect.yieldNow
const request = (yield* first.list())[0]!
expect(yield* second.list()).toEqual([])
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
new QuestionV2.NotFoundError({ requestID: request.id }),
)
yield* Scope.close(firstScope, Exit.void)
const exit = yield* Fiber.await(fiber)
expect(Exit.isFailure(exit)).toBe(true)
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
yield* Scope.close(secondScope, Exit.void)
}),
)
})

View file

@ -16,12 +16,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { EventV2 } from "@opencode-ai/core/event"
import { Form } from "@opencode-ai/core/form"
import { Job } from "@opencode-ai/core/job"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { EventTable } from "@opencode-ai/core/event/sql"
import { Project } from "@opencode-ai/core/project"
import { ProjectTable } from "@opencode-ai/core/project/sql"
import { QuestionV2 } from "@opencode-ai/core/question"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
import { AgentV2 } from "@opencode-ai/core/agent"
import { Config } from "@opencode-ai/core/config"
@ -268,7 +269,7 @@ const it = testEffect(
LayerNode.group([
Database.node,
EventV2.node,
QuestionV2.node,
Form.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
@ -2801,14 +2802,21 @@ describe("SessionRunnerLLM", () => {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.Service
const forms = yield* Form.Service
yield* registry.register({
question: Tool.make({
description: "Ask the user",
input: Schema.Struct({}),
output: Schema.Struct({}),
execute: (_, context) =>
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
forms
.ask({ sessionID: context.sessionID, mode: "form", fields: [] })
.pipe(
Effect.orDie,
Effect.flatMap((state) =>
state.status === "answered" ? Effect.succeed({}) : Effect.die(new QuestionTool.RejectedError()),
),
),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
@ -2825,12 +2833,12 @@ describe("SessionRunnerLLM", () => {
]
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
let pending = yield* questions.list()
let pending = yield* forms.list({ sessionID })
while (pending.length === 0) {
yield* Effect.yieldNow
pending = yield* questions.list()
pending = yield* forms.list({ sessionID })
}
yield* questions.reject(pending[0]!.id)
yield* forms.cancel(pending[0]!.id)
const exit = yield* Fiber.join(run)
expect(exit._tag).toBe("Failure")

View file

@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
import { Effect, Exit, Fiber, Layer } from "effect"
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Form } from "@opencode-ai/core/form"
import { PermissionV2 } from "@opencode-ai/core/permission"
import { QuestionV2 } from "@opencode-ai/core/question"
import { SessionV2 } from "@opencode-ai/core/session"
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
import { QuestionTool } from "@opencode-ai/core/tool/question"
@ -13,7 +13,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
const sessionID = SessionV2.ID.make("ses_question_tool_test")
const assertions: PermissionV2.AssertInput[] = []
let captured: QuestionV2.AskInput | undefined
let captured: Form.CreateInput | undefined
let reject = false
let deny = false
const capturedInput = () => captured
@ -31,22 +31,27 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const question = Layer.succeed(
QuestionV2.Service,
QuestionV2.Service.of({
ask: (input: QuestionV2.AskInput) =>
Effect.sync(() => {
const form = Layer.succeed(
Form.Service,
Form.Service.of({
create: () => Effect.die("unused"),
ask: (input: Form.CreateInput) =>
Effect.gen(function* () {
captured = input
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
reply: () => Effect.die("unused"),
reject: () => Effect.die("unused"),
if (reject) return { status: "cancelled" } as const
return { status: "answered", answer: { question_0: "Build" } } as const
}),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
[PermissionV2.node, permission],
[QuestionV2.node, question],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
@ -117,8 +122,27 @@ describe("QuestionTool", () => {
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [
{
key: "question_0",
title: "What should happen?",
description: "Action",
type: "string",
options: [{ value: "Build", label: "Build", description: "Build it" }],
custom: true,
},
{
key: "question_1",
title: "Which environment?",
description: "Environment",
type: "string",
options: [{ value: "Dev", label: "Dev", description: "Development" }],
custom: true,
},
],
})
}),
)
@ -137,8 +161,10 @@ describe("QuestionTool", () => {
})
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
title: "Questions",
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [],
})
}),
)