Przeglądaj źródła

feat(core): add form service (#35094)

Aiden Cline 1 miesiąc temu
rodzic
commit
3ce5e9800d

+ 1 - 0
packages/client/package.json

@@ -27,6 +27,7 @@
     }
   },
   "devDependencies": {
+    "@effect/platform-node": "catalog:",
     "@opencode-ai/core": "workspace:*",
     "@opencode-ai/httpapi-codegen": "workspace:*",
     "@opencode-ai/server": "workspace:*",

+ 115 - 0
packages/client/src/promise/generated/types.ts

@@ -4019,6 +4019,121 @@ export type EventSubscribeOutput =
       readonly location?: { readonly directory: string; readonly workspaceID?: string }
       readonly data: { readonly sessionID: string; readonly requestID: string }
     }
+  | {
+      readonly id: string
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "form.created"
+      readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: {
+        readonly form:
+          | {
+              readonly id: string
+              readonly sessionID: string
+              readonly title?: string
+              readonly metadata?: { readonly [x: string]: unknown }
+              readonly mode: "form"
+              readonly fields: ReadonlyArray<
+                | {
+                    readonly key: string
+                    readonly title?: string
+                    readonly description?: string
+                    readonly required?: boolean
+                    readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
+                    readonly type: "string"
+                    readonly format?: "email" | "uri" | "date" | "date-time"
+                    readonly minLength?: number
+                    readonly maxLength?: number
+                    readonly pattern?: string
+                    readonly placeholder?: string
+                    readonly default?: string
+                    readonly options?: ReadonlyArray<{
+                      readonly value: string
+                      readonly label: string
+                      readonly description?: string
+                    }>
+                    readonly custom?: boolean
+                  }
+                | {
+                    readonly key: string
+                    readonly title?: string
+                    readonly description?: string
+                    readonly required?: boolean
+                    readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
+                    readonly type: "number"
+                    readonly minimum?: number
+                    readonly maximum?: number
+                    readonly default?: number
+                  }
+                | {
+                    readonly key: string
+                    readonly title?: string
+                    readonly description?: string
+                    readonly required?: boolean
+                    readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
+                    readonly type: "integer"
+                    readonly minimum?: number
+                    readonly maximum?: number
+                    readonly default?: number
+                  }
+                | {
+                    readonly key: string
+                    readonly title?: string
+                    readonly description?: string
+                    readonly required?: boolean
+                    readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
+                    readonly type: "boolean"
+                    readonly default?: boolean
+                  }
+                | {
+                    readonly key: string
+                    readonly title?: string
+                    readonly description?: string
+                    readonly required?: boolean
+                    readonly when?: { readonly key: string; readonly op: "eq" | "neq"; readonly value: string }
+                    readonly type: "multiselect"
+                    readonly options: ReadonlyArray<{
+                      readonly value: string
+                      readonly label: string
+                      readonly description?: string
+                    }>
+                    readonly minItems?: number
+                    readonly maxItems?: number
+                    readonly custom?: boolean
+                    readonly default?: ReadonlyArray<string>
+                  }
+              >
+            }
+          | {
+              readonly id: string
+              readonly sessionID: string
+              readonly title?: string
+              readonly metadata?: { readonly [x: string]: unknown }
+              readonly mode: "url"
+              readonly url: string
+            }
+      }
+    }
+  | {
+      readonly id: string
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "form.replied"
+      readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: {
+        readonly id: string
+        readonly sessionID: string
+        readonly answer: { readonly [x: string]: string | number | boolean | ReadonlyArray<string> }
+      }
+    }
+  | {
+      readonly id: string
+      readonly metadata?: { readonly [x: string]: unknown }
+      readonly type: "form.cancelled"
+      readonly durable?: { readonly aggregateID: string; readonly seq: number; readonly version: number }
+      readonly location?: { readonly directory: string; readonly workspaceID?: string }
+      readonly data: { readonly id: string; readonly sessionID: string }
+    }
   | {
       readonly id: string
       readonly metadata?: { readonly [x: string]: unknown }

+ 302 - 0
packages/core/src/form.ts

@@ -0,0 +1,302 @@
+export * as Form from "./form"
+
+import { Form } from "@opencode-ai/schema/form"
+import { Cache, Context, Deferred, Duration, Effect, Exit, Layer, Option, Schema } from "effect"
+import { makeLocationNode } from "./effect/app-node"
+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?: Form.FormInfo["sessionID"]
+}
+
+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())
+}

+ 57 - 0
packages/core/test/form.test.ts

@@ -0,0 +1,57 @@
+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 { 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"),
+  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" } })
+    }),
+  )
+})

+ 2 - 1
packages/opencode/test/event-manifest.test.ts

@@ -10,13 +10,14 @@ describe("public event manifest", () => {
     expect(EventManifest.Definitions).toBe(SchemaEventManifest.Definitions)
     expect(EventManifest.Latest).toBe(SchemaEventManifest.Latest)
     expect(EventManifest.Durable).toBe(SchemaEventManifest.Durable)
-    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
+    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101)
     expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
     expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
     expect(EventManifest.Latest.get("todo.updated")).toBe(Todo.Event.Updated)
     expect(EventManifest.Latest.has("ide.installed")).toBe(false)
     expect(EventManifest.Latest.has("server.connected")).toBe(true)
     expect(EventManifest.Latest.has("global.disposed")).toBe(true)
+    expect(EventManifest.Latest.has("form.created")).toBe(true)
   })
 
   test("contains only the current step settlement versions", () => {

+ 2 - 0
packages/schema/src/event-manifest.ts

@@ -7,6 +7,7 @@ import { Durable } from "./durable-event-manifest.js"
 import { Event } from "./event.js"
 import { FileSystem } from "./filesystem.js"
 import { FileSystemWatcher } from "./filesystem-watcher.js"
+import { Form } from "./form.js"
 import { InstallationEvent } from "./installation-event.js"
 import { Integration } from "./integration.js"
 import { LegacyEvent } from "./legacy-event.js"
@@ -60,6 +61,7 @@ const featureDefinitions = Event.inventory(
   ...Pty.Event.Definitions,
   ...Shell.Event.Definitions,
   ...Question.Event.Definitions,
+  ...Form.Event.Definitions,
 )
 
 export const ServerDefinitions = Event.inventory(

+ 146 - 0
packages/schema/src/form.ts

@@ -0,0 +1,146 @@
+export * as Form from "./form.js"
+
+import { Schema } from "effect"
+import { define, inventory } from "./event.js"
+import { ascending } from "./identifier.js"
+import { NonNegativeInt, optional, statics } from "./schema.js"
+import { SessionID } from "./session-id.js"
+
+const IDSchema = Schema.String.check(Schema.isStartsWith("frm_")).pipe(Schema.brand("Form.ID"))
+
+export const ID = IDSchema.pipe(
+  statics((schema: typeof IDSchema) => ({ create: (id?: string) => schema.make(id ?? "frm_" + ascending()) })),
+)
+export type ID = typeof ID.Type
+
+export const Metadata = Schema.Record(Schema.String, Schema.Unknown).annotate({ identifier: "Form.Metadata" })
+export type Metadata = typeof Metadata.Type
+
+export const Option = Schema.Struct({
+  value: Schema.String,
+  label: Schema.String,
+  description: Schema.String.pipe(optional),
+}).annotate({ identifier: "Form.Option" })
+export interface Option extends Schema.Schema.Type<typeof Option> {}
+
+export const When = Schema.Struct({
+  key: Schema.String,
+  op: Schema.Literals(["eq", "neq"]),
+  value: Schema.String,
+}).annotate({ identifier: "Form.When" })
+export interface When extends Schema.Schema.Type<typeof When> {}
+
+const FieldBase = {
+  key: Schema.String,
+  title: Schema.String.pipe(optional),
+  description: Schema.String.pipe(optional),
+  required: Schema.Boolean.pipe(optional),
+  when: When.pipe(optional),
+}
+
+export const StringField = Schema.Struct({
+  ...FieldBase,
+  type: Schema.Literal("string"),
+  format: Schema.Literals(["email", "uri", "date", "date-time"]).pipe(optional),
+  minLength: NonNegativeInt.pipe(optional),
+  maxLength: NonNegativeInt.pipe(optional),
+  pattern: Schema.String.pipe(optional),
+  placeholder: Schema.String.pipe(optional),
+  default: Schema.String.pipe(optional),
+  options: Schema.Array(Option).pipe(optional),
+  custom: Schema.Boolean.pipe(optional),
+}).annotate({ identifier: "Form.StringField" })
+export interface StringField extends Schema.Schema.Type<typeof StringField> {}
+
+export const NumberField = Schema.Struct({
+  ...FieldBase,
+  type: Schema.Literal("number"),
+  minimum: Schema.Number.pipe(optional),
+  maximum: Schema.Number.pipe(optional),
+  default: Schema.Number.pipe(optional),
+}).annotate({ identifier: "Form.NumberField" })
+export interface NumberField extends Schema.Schema.Type<typeof NumberField> {}
+
+export const IntegerField = Schema.Struct({
+  ...FieldBase,
+  type: Schema.Literal("integer"),
+  minimum: Schema.Number.pipe(optional),
+  maximum: Schema.Number.pipe(optional),
+  default: Schema.Number.pipe(optional),
+}).annotate({ identifier: "Form.IntegerField" })
+export interface IntegerField extends Schema.Schema.Type<typeof IntegerField> {}
+
+export const BooleanField = Schema.Struct({
+  ...FieldBase,
+  type: Schema.Literal("boolean"),
+  default: Schema.Boolean.pipe(optional),
+}).annotate({ identifier: "Form.BooleanField" })
+export interface BooleanField extends Schema.Schema.Type<typeof BooleanField> {}
+
+export const MultiselectField = Schema.Struct({
+  ...FieldBase,
+  type: Schema.Literal("multiselect"),
+  options: Schema.Array(Option),
+  minItems: NonNegativeInt.pipe(optional),
+  maxItems: NonNegativeInt.pipe(optional),
+  custom: Schema.Boolean.pipe(optional),
+  default: Schema.Array(Schema.String).pipe(optional),
+}).annotate({ identifier: "Form.MultiselectField" })
+export interface MultiselectField extends Schema.Schema.Type<typeof MultiselectField> {}
+
+export const Field = Schema.Union([StringField, NumberField, IntegerField, BooleanField, MultiselectField]).pipe(
+  Schema.toTaggedUnion("type"),
+)
+export type Field = StringField | NumberField | IntegerField | BooleanField | MultiselectField
+
+const InfoBase = {
+  id: ID,
+  sessionID: SessionID,
+  title: Schema.String.pipe(optional),
+  metadata: Metadata.pipe(optional),
+}
+
+export const FormInfo = Schema.Struct({
+  ...InfoBase,
+  mode: Schema.Literal("form"),
+  fields: Schema.Array(Field),
+}).annotate({ identifier: "Form.FormInfo" })
+export interface FormInfo extends Schema.Schema.Type<typeof FormInfo> {}
+
+export const UrlInfo = Schema.Struct({
+  ...InfoBase,
+  mode: Schema.Literal("url"),
+  url: Schema.String,
+}).annotate({ identifier: "Form.UrlInfo" })
+export interface UrlInfo extends Schema.Schema.Type<typeof UrlInfo> {}
+
+export const Info = Schema.Union([FormInfo, UrlInfo]).pipe(Schema.toTaggedUnion("mode"))
+export type Info = FormInfo | UrlInfo
+
+export const Value = Schema.Union([Schema.String, Schema.Number, Schema.Boolean, Schema.Array(Schema.String)]).annotate({
+  identifier: "Form.Value",
+})
+export type Value = typeof Value.Type
+
+export const Answer = Schema.Record(Schema.String, Value).annotate({ identifier: "Form.Answer" })
+export type Answer = typeof Answer.Type
+
+export const State = Schema.Union([
+  Schema.Struct({ status: Schema.Literal("pending") }),
+  Schema.Struct({ status: Schema.Literal("answered"), answer: Answer }),
+  Schema.Struct({ status: Schema.Literal("cancelled") }),
+])
+  .pipe(Schema.toTaggedUnion("status"))
+  .annotate({ identifier: "Form.State" })
+export type State = typeof State.Type
+
+export const Reply = Schema.Struct({
+  answer: Answer,
+}).annotate({ identifier: "Form.Reply" })
+export interface Reply extends Schema.Schema.Type<typeof Reply> {}
+
+const Created = define({ type: "form.created", schema: { form: Info } })
+const Replied = define({ type: "form.replied", schema: { id: ID, sessionID: SessionID, answer: Answer } })
+const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: SessionID } })
+
+export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }

+ 1 - 0
packages/schema/src/index.ts

@@ -4,6 +4,7 @@ export { Connection } from "./connection.js"
 export { Credential } from "./credential.js"
 export { Event } from "./event.js"
 export { FileSystem } from "./filesystem.js"
+export { Form } from "./form.js"
 export { Integration } from "./integration.js"
 export { LLM } from "./llm.js"
 export { Location } from "./location.js"

+ 5 - 4
packages/schema/test/event-manifest.test.ts

@@ -1,5 +1,5 @@
 import { describe, expect, test } from "bun:test"
-import { Agent, FileSystem, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
+import { Agent, FileSystem, Form, Integration, Permission, Project, Reference, Session, Workspace } from "../src/index.js"
 import { EventManifest } from "../src/event-manifest.js"
 import { IdeEvent } from "../src/ide-event.js"
 import { SessionEvent } from "../src/session-event.js"
@@ -9,11 +9,11 @@ import { WorkspaceEvent } from "../src/workspace-event.js"
 
 describe("public event manifest", () => {
   test("owns the complete public event surface", () => {
-    expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(63)
+    expect(EventManifest.ServerDefinitions.filter((definition) => definition.type !== "agent.updated").length).toBe(86)
     expect(EventManifest.ServerDefinitions.filter((definition) => definition.type === "agent.updated")).toEqual([
       Agent.Event.Updated,
     ])
-    expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(93)
+    expect(EventManifest.Definitions.filter((definition) => definition.type !== "agent.updated").length).toBe(101)
     expect(EventManifest.Definitions.filter((definition) => definition.type === "agent.updated")).toEqual([
       Agent.Event.Updated,
     ])
@@ -29,7 +29,7 @@ describe("public event manifest", () => {
       SessionV1.Event.Diff,
       SessionV1.Event.Error,
     ])
-    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(93)
+    expect(Array.from(EventManifest.Latest.keys()).filter((type) => type !== "agent.updated").length).toBe(101)
     expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
     expect(Agent.Event.Updated.durable).toBeUndefined()
     expect(EventManifest.Durable.has("agent.updated")).toBe(false)
@@ -49,6 +49,7 @@ describe("public event manifest", () => {
     expect(FileSystem.Event.Definitions).toEqual([FileSystem.Event.Edited])
     expect(Integration.Event.Definitions).toEqual([Integration.Event.Updated, Integration.Event.ConnectionUpdated])
     expect(Permission.Event.Definitions).toEqual([Permission.Event.Asked, Permission.Event.Replied])
+    expect(Form.Event.Definitions).toEqual([Form.Event.Created, Form.Event.Replied, Form.Event.Cancelled])
     expect(Reference.Event.Definitions).toEqual([Reference.Event.Updated])
     expect(EventManifest.Latest.has("ide.installed")).toBe(false)
     expect(IdeEvent.Definitions).toEqual([IdeEvent.Installed])