mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-15 22:08:31 +00:00
296 lines
12 KiB
TypeScript
296 lines
12 KiB
TypeScript
import { describe, expect } from "bun:test"
|
|
import { Deferred, Effect, Exit, Fiber } 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 { SessionSchema } from "@opencode-ai/core/session/schema"
|
|
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: SessionSchema.ID.make("ses_test"),
|
|
title: "Test form",
|
|
mode: "form",
|
|
fields: [{ key: "name", type: "string", required: true }],
|
|
} satisfies Form.CreateInput
|
|
|
|
describe("Form", () => {
|
|
it.effect("returns a terminal cancelled state from ask", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const events = yield* EventV2.Service
|
|
const created = yield* Deferred.make<Form.Info>()
|
|
const unsubscribe = yield* events.listen((event) =>
|
|
event.type === Form.Event.Created.type
|
|
? Deferred.succeed(created, (event.data as { readonly form: Form.Info }).form).pipe(Effect.asVoid)
|
|
: Effect.void,
|
|
)
|
|
yield* Effect.addFinalizer(() => unsubscribe)
|
|
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
|
const form = yield* Deferred.await(created)
|
|
|
|
yield* service.cancel(form.id)
|
|
|
|
expect(yield* Fiber.join(fiber)).toEqual({ status: "cancelled" })
|
|
expect(yield* service.state(form.id)).toEqual({ status: "cancelled" })
|
|
}),
|
|
)
|
|
|
|
it.effect("supports the temporary global mcp elicitation owner", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "MCP input",
|
|
mode: "form",
|
|
fields: [{ key: "name", type: "string", required: true }],
|
|
})
|
|
expect(created.sessionID).toBe("global")
|
|
expect(created.title).toBe("MCP input")
|
|
|
|
const owned = yield* service.list({ sessionID: "global" })
|
|
expect(owned.map((form) => form.id)).toEqual([created.id])
|
|
expect(yield* service.list({ sessionID: "other" })).toEqual([])
|
|
|
|
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
|
}),
|
|
)
|
|
|
|
it.effect("gates required fields and rejects inactive answers via when", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "Conditional form",
|
|
mode: "form",
|
|
fields: [
|
|
{ key: "confirm", type: "boolean", required: true },
|
|
{ key: "reason", type: "string", required: true, when: [{ key: "confirm", op: "eq", value: false }] },
|
|
],
|
|
})
|
|
|
|
const inactive = yield* service.reply({ id: created.id, answer: { confirm: true, reason: "x" } }).pipe(Effect.flip)
|
|
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: reason" }))
|
|
|
|
const missing = yield* service.reply({ id: created.id, answer: { confirm: false } }).pipe(Effect.flip)
|
|
expect(missing).toEqual(
|
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: reason" }),
|
|
)
|
|
|
|
yield* service.reply({ id: created.id, answer: { confirm: false, reason: "not ready" } })
|
|
expect(yield* service.state(created.id)).toEqual({
|
|
status: "answered",
|
|
answer: { confirm: false, reason: "not ready" },
|
|
})
|
|
}),
|
|
)
|
|
|
|
it.effect("evaluates when against multiselect answers as inclusion", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const options = [
|
|
{ value: "go", label: "Go" },
|
|
{ value: "ts", label: "TypeScript" },
|
|
]
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "Multiselect form",
|
|
mode: "form",
|
|
fields: [
|
|
{ key: "langs", type: "multiselect", options },
|
|
{ key: "goVersion", type: "string", required: true, when: [{ key: "langs", op: "eq", value: "go" }] },
|
|
],
|
|
})
|
|
|
|
const missing = yield* service.reply({ id: created.id, answer: { langs: ["go", "ts"] } }).pipe(Effect.flip)
|
|
expect(missing).toEqual(
|
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: goVersion" }),
|
|
)
|
|
|
|
yield* service.reply({ id: created.id, answer: { langs: ["ts"] } })
|
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["ts"] } })
|
|
}),
|
|
)
|
|
|
|
it.effect("requires every when condition to match and treats empty when as active", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "Dependent form",
|
|
mode: "form",
|
|
fields: [
|
|
{ key: "a", type: "boolean" },
|
|
{ key: "b", type: "boolean" },
|
|
{
|
|
key: "x",
|
|
type: "string",
|
|
required: true,
|
|
when: [
|
|
{ key: "a", op: "eq", value: true },
|
|
{ key: "b", op: "eq", value: true },
|
|
],
|
|
},
|
|
{ key: "z", type: "string", required: true, when: [] },
|
|
],
|
|
})
|
|
|
|
const missingX = yield* service.reply({ id: created.id, answer: { a: true, b: true, z: "ok" } }).pipe(Effect.flip)
|
|
expect(missingX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: x" }))
|
|
|
|
const inactiveX = yield* service
|
|
.reply({ id: created.id, answer: { a: true, b: false, x: "nope", z: "ok" } })
|
|
.pipe(Effect.flip)
|
|
expect(inactiveX).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: x" }))
|
|
|
|
const missingZ = yield* service.reply({ id: created.id, answer: { a: true, b: false } }).pipe(Effect.flip)
|
|
expect(missingZ).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: z" }))
|
|
|
|
yield* service.reply({ id: created.id, answer: { a: true, b: false, z: "ok" } })
|
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: true, b: false, z: "ok" } })
|
|
}),
|
|
)
|
|
|
|
it.effect("evaluates neq against multiselect answers as non-inclusion", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const options = [
|
|
{ value: "go", label: "Go" },
|
|
{ value: "ts", label: "TypeScript" },
|
|
]
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "Selection form",
|
|
mode: "form",
|
|
fields: [
|
|
{ key: "langs", type: "multiselect", options },
|
|
{ key: "note", type: "string", required: true, when: [{ key: "langs", op: "neq", value: "go" }] },
|
|
],
|
|
})
|
|
|
|
const missing = yield* service.reply({ id: created.id, answer: { langs: ["ts"] } }).pipe(Effect.flip)
|
|
expect(missing).toEqual(
|
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
|
|
)
|
|
|
|
// an answered-but-empty multiselect also satisfies neq
|
|
const missingEmpty = yield* service.reply({ id: created.id, answer: { langs: [] } }).pipe(Effect.flip)
|
|
expect(missingEmpty).toEqual(
|
|
new Form.InvalidAnswerError({ id: created.id, message: "Missing required form field: note" }),
|
|
)
|
|
|
|
const inactive = yield* service.reply({ id: created.id, answer: { langs: ["go"], note: "x" } }).pipe(Effect.flip)
|
|
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: note" }))
|
|
|
|
yield* service.reply({ id: created.id, answer: { langs: ["go"] } })
|
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { langs: ["go"] } })
|
|
}),
|
|
)
|
|
|
|
it.effect("treats unanswered when references as false and cascades inactivity", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const created = yield* service.create({
|
|
sessionID: "global",
|
|
title: "Cascading form",
|
|
mode: "form",
|
|
fields: [
|
|
{ key: "a", type: "boolean" },
|
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: true }] },
|
|
// neq also fails against an unanswered reference, and hiding b cascades here through
|
|
// the reject-inactive-answers rule: b can never be answered while a is false.
|
|
{ key: "c", type: "string", required: true, when: [{ key: "b", op: "neq", value: "x" }] },
|
|
],
|
|
})
|
|
|
|
const inactive = yield* service.reply({ id: created.id, answer: { a: false, b: "yes" } }).pipe(Effect.flip)
|
|
expect(inactive).toEqual(new Form.InvalidAnswerError({ id: created.id, message: "Form field is not active: b" }))
|
|
|
|
yield* service.reply({ id: created.id, answer: { a: false } })
|
|
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { a: false } })
|
|
}),
|
|
)
|
|
|
|
it.effect("rejects invalid when definitions at creation", () =>
|
|
Effect.gen(function* () {
|
|
const service = yield* Form.Service
|
|
const flipCreate = (fields: ReadonlyArray<Form.Field>) =>
|
|
service.create({ sessionID: "global", title: "Invalid form", mode: "form", fields }).pipe(Effect.flip)
|
|
|
|
expect(
|
|
yield* flipCreate([
|
|
{ key: "b", type: "string", when: [{ key: "missing", op: "eq", value: "x" }] },
|
|
]),
|
|
).toEqual(new Form.InvalidFormError({ message: "Form field condition must reference an earlier field: b -> missing" }))
|
|
|
|
expect(
|
|
yield* flipCreate([
|
|
{ key: "a", type: "string" },
|
|
{ key: "a", type: "string" },
|
|
]),
|
|
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
|
|
|
expect(
|
|
yield* flipCreate([
|
|
{ key: "a", type: "boolean" },
|
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "yes" }] },
|
|
]),
|
|
).toEqual(
|
|
new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }),
|
|
)
|
|
|
|
expect(
|
|
yield* flipCreate([
|
|
{ key: "a", type: "string", options: [{ value: "x", label: "X" }] },
|
|
{ key: "b", type: "string", when: [{ key: "a", op: "eq", value: "y" }] },
|
|
]),
|
|
).toEqual(
|
|
new Form.InvalidFormError({
|
|
message: "Form field condition value must be one of the field's options: b -> a",
|
|
}),
|
|
)
|
|
}),
|
|
)
|
|
|
|
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" } })
|
|
}),
|
|
)
|
|
})
|