feat(core): add session form service (#34855)
This commit is contained in:
parent
460cdc5aec
commit
7ebd344fa2
67 changed files with 7862 additions and 5223 deletions
|
|
@ -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
149
packages/schema/src/form.ts
Normal 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) }
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
@ -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_")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue