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

View file

@ -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"
@ -19,7 +20,6 @@ import { Plugin } from "./plugin.js"
import { Project } from "./project.js"
import { ProjectDirectories } from "./project-directories.js"
import { Pty } from "./pty.js"
import { Question } from "./question.js"
import { QuestionV1 } from "./question-v1.js"
import { Reference } from "./reference.js"
import { ServerEvent } from "./server-event.js"
@ -59,7 +59,7 @@ const featureDefinitions = Event.inventory(
...FileSystemWatcher.Event.Definitions,
...Pty.Event.Definitions,
...Shell.Event.Definitions,
...Question.Event.Definitions,
...Form.Event.Definitions,
)
export const ServerDefinitions = Event.inventory(

149
packages/schema/src/form.ts Normal file
View file

@ -0,0 +1,149 @@
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"
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,
// Public form flows are session-owned. This is intentionally `string` because the server
// currently accepts an undocumented `global` sentinel for MCP elicitation forms that cannot
// be attributed to a concrete session yet. Do not document or rely on `global` outside our
// own clients; remove the sentinel path once MCP elicitation can carry session ownership.
sessionID: Schema.String,
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: Schema.String, answer: Answer } })
const Cancelled = define({ type: "form.cancelled", schema: { id: ID, sessionID: Schema.String } })
export const Event = { Created, Replied, Cancelled, Definitions: inventory(Created, Replied, Cancelled) }

View file

@ -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"
@ -22,7 +23,6 @@ export { Shell } from "./shell.js"
export { Skill } from "./skill.js"
export { Pty } from "./pty.js"
export { PtyTicket } from "./pty-ticket.js"
export { Question } from "./question.js"
export { Workspace } from "./workspace.js"
export { Prompt, Source, FileAttachment, AgentAttachment } from "./prompt.js"
export { PromptInput } from "./prompt-input.js"

View file

@ -1,86 +0,0 @@
export * as Question from "./question.js"
import { Schema } from "effect"
import { optional } from "./schema.js"
import { define, inventory } from "./event.js"
import { ascending } from "./identifier.js"
import { SessionID } from "./session-id.js"
import { statics } from "./schema.js"
export const ID = Schema.String.check(Schema.isStartsWith("que")).pipe(
Schema.brand("QuestionV2.ID"),
statics((schema) => {
const create = () => schema.make("que_" + ascending())
return {
create,
ascending: (id?: string) => (id === undefined ? create() : schema.make(id)),
}
}),
)
export type ID = typeof ID.Type
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: "QuestionV2.Option" })
export interface Option extends Schema.Schema.Type<typeof Option> {}
const base = {
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" }),
}
export const Info = Schema.Struct({
...base,
custom: Schema.Boolean.pipe(optional).annotate({
description: "Allow typing a custom answer (default: true)",
}),
}).annotate({ identifier: "QuestionV2.Info" })
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Prompt = Schema.Struct(base).annotate({ identifier: "QuestionV2.Prompt" })
export interface Prompt extends Schema.Schema.Type<typeof Prompt> {}
export const Tool = Schema.Struct({
messageID: Schema.String,
callID: Schema.String,
}).annotate({ identifier: "QuestionV2.Tool" })
export interface Tool extends Schema.Schema.Type<typeof Tool> {}
export const Request = Schema.Struct({
id: ID,
sessionID: SessionID,
questions: Schema.Array(Info).annotate({ description: "Questions to ask" }),
tool: Tool.pipe(optional),
}).annotate({ identifier: "QuestionV2.Request" })
export interface Request extends Schema.Schema.Type<typeof Request> {}
export const Answer = Schema.Array(Schema.String).annotate({ identifier: "QuestionV2.Answer" })
export type Answer = typeof Answer.Type
export const Reply = Schema.Struct({
answers: Schema.Array(Answer).annotate({
description: "User answers in order of questions (each answer is an array of selected labels)",
}),
}).annotate({ identifier: "QuestionV2.Reply" })
export interface Reply extends Schema.Schema.Type<typeof Reply> {}
const Asked = define({ type: "question.v2.asked", schema: Request.fields })
const Replied = define({
type: "question.v2.replied",
schema: {
sessionID: SessionID,
requestID: ID,
answers: Schema.Array(Answer),
},
})
const Rejected = define({
type: "question.v2.rejected",
schema: {
sessionID: SessionID,
requestID: ID,
},
})
export const Event = { Asked, Replied, Rejected, Definitions: inventory(Asked, Replied, Rejected) }

View file

@ -2,10 +2,10 @@ import { describe, expect, test } from "bun:test"
import { Schema } from "effect"
import { Agent } from "../src/agent.js"
import { FileSystem } from "../src/filesystem.js"
import { Form } from "../src/form.js"
import { Model } from "../src/model.js"
import { Project } from "../src/project.js"
import { Pty } from "../src/pty.js"
import { Question } from "../src/question.js"
import { Session } from "../src/session.js"
import { SessionTodo } from "../src/session-todo.js"
import { optional } from "../src/schema.js"
@ -28,7 +28,7 @@ describe("contract hygiene", () => {
})
test("current ID constructors expose create", () => {
expect(Question.ID.create()).toStartWith("que_")
expect(Form.ID.create()).toStartWith("frm_")
expect(Pty.ID.create()).toStartWith("pty_")
})

View file

@ -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(65)
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(96)
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(96)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(Agent.Event.Updated.durable).toBeUndefined()
expect(EventManifest.Durable.has("agent.updated")).toBe(false)
@ -43,12 +43,16 @@ describe("public event manifest", () => {
expect(EventManifest.Latest.get("session.next.step.ended")).toBe(SessionEvent.Step.Ended)
expect(EventManifest.Latest.get("todo.updated")).toBe(SessionTodo.Event.Updated)
expect(EventManifest.Latest.get("agent.updated")).toBe(Agent.Event.Updated)
expect(EventManifest.Latest.get("form.created")).toBe(Form.Event.Created)
expect(EventManifest.Latest.get("form.replied")).toBe(Form.Event.Replied)
expect(EventManifest.Latest.get("form.cancelled")).toBe(Form.Event.Cancelled)
expect(EventManifest.Latest.get("project.updated")).toBe(Project.Event.Updated)
expect(Agent.Event.Definitions).toEqual([Agent.Event.Updated])
expect(Project.Event.Definitions).toEqual([Project.Event.Updated])
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])