refactor(form): model links as fields (#36129)
This commit is contained in:
parent
6a85f0d3db
commit
a6449cb45c
24 changed files with 2409 additions and 1430 deletions
|
|
@ -15,7 +15,6 @@ 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
|
||||
|
||||
|
|
@ -47,7 +46,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "MCP input",
|
||||
mode: "form",
|
||||
fields: [{ key: "name", type: "string", required: true }],
|
||||
})
|
||||
expect(created.sessionID).toBe("global")
|
||||
|
|
@ -59,6 +57,14 @@ describe("Form", () => {
|
|||
|
||||
yield* service.reply({ id: created.id, answer: { name: "Ava" } })
|
||||
expect(yield* service.state(created.id)).toEqual({ status: "answered", answer: { name: "Ava" } })
|
||||
|
||||
const externalOnly = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "External setup",
|
||||
fields: [{ key: "setup", type: "external", url: "https://example.com/setup" }],
|
||||
})
|
||||
yield* service.reply({ id: externalOnly.id, answer: { setup: true } })
|
||||
expect(yield* service.state(externalOnly.id)).toEqual({ status: "answered", answer: { setup: true } })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -68,15 +74,18 @@ describe("Form", () => {
|
|||
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 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(
|
||||
|
|
@ -101,7 +110,6 @@ describe("Form", () => {
|
|||
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" }] },
|
||||
|
|
@ -124,7 +132,6 @@ describe("Form", () => {
|
|||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "Dependent form",
|
||||
mode: "form",
|
||||
fields: [
|
||||
{ key: "a", type: "boolean" },
|
||||
{ key: "b", type: "boolean" },
|
||||
|
|
@ -142,7 +149,9 @@ describe("Form", () => {
|
|||
})
|
||||
|
||||
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" }))
|
||||
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" } })
|
||||
|
|
@ -150,7 +159,9 @@ describe("Form", () => {
|
|||
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" }))
|
||||
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" } })
|
||||
|
|
@ -167,7 +178,6 @@ describe("Form", () => {
|
|||
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" }] },
|
||||
|
|
@ -186,7 +196,9 @@ describe("Form", () => {
|
|||
)
|
||||
|
||||
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" }))
|
||||
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"] } })
|
||||
|
|
@ -199,7 +211,6 @@ describe("Form", () => {
|
|||
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 }] },
|
||||
|
|
@ -220,14 +231,14 @@ describe("Form", () => {
|
|||
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)
|
||||
const flipCreate = (fields: Form.CreateInput["fields"]) =>
|
||||
service.create({ sessionID: "global", title: "Invalid 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" }))
|
||||
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([
|
||||
|
|
@ -236,14 +247,19 @@ describe("Form", () => {
|
|||
]),
|
||||
).toEqual(new Form.InvalidFormError({ message: "Duplicate form field key: a" }))
|
||||
|
||||
expect(
|
||||
yield* flipCreate([
|
||||
{ key: "a", type: "external", url: "https://example.com" },
|
||||
{ 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" }),
|
||||
)
|
||||
).toEqual(new Form.InvalidFormError({ message: "Form field condition value must be a boolean: b -> a" }))
|
||||
|
||||
expect(
|
||||
yield* flipCreate([
|
||||
|
|
@ -258,6 +274,40 @@ describe("Form", () => {
|
|||
}),
|
||||
)
|
||||
|
||||
it.effect("requires external field acknowledgements", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
const created = yield* service.create({
|
||||
sessionID: "global",
|
||||
title: "External setup",
|
||||
fields: [
|
||||
{ key: "authorization", type: "external", url: "https://example.com/setup", title: "Open setup" },
|
||||
{ key: "name", type: "string", required: true },
|
||||
],
|
||||
})
|
||||
|
||||
const invalidAnswers: ReadonlyArray<Form.Answer> = [
|
||||
{ name: "Ava" },
|
||||
{ authorization: false, name: "Ava" },
|
||||
{ authorization: "yes", name: "Ava" },
|
||||
]
|
||||
for (const answer of invalidAnswers) {
|
||||
expect(yield* service.reply({ id: created.id, answer }).pipe(Effect.flip)).toEqual(
|
||||
new Form.InvalidAnswerError({
|
||||
id: created.id,
|
||||
message: "External form field must be acknowledged: authorization",
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
yield* service.reply({ id: created.id, answer: { authorization: true, name: "Ava" } })
|
||||
expect(yield* service.state(created.id)).toEqual({
|
||||
status: "answered",
|
||||
answer: { authorization: true, name: "Ava" },
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("cleans up created forms when event publication fails", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* Form.Service
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import { SessionV2 } from "@opencode-ai/core/session"
|
|||
import { McpTool } from "@opencode-ai/core/tool/mcp"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"
|
||||
import { Deferred, Effect, Exit, Fiber, Layer, Schema, Stream } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
import { location } from "./fixture/location"
|
||||
import { settleTool, toolDefinitions, toolIdentity, waitForTool } from "./lib/tool"
|
||||
|
|
@ -47,7 +47,9 @@ type ResourceTemplatePage = {
|
|||
nextCursor?: string
|
||||
}
|
||||
|
||||
function resourceServer(input: { resources?: boolean; listChanged?: boolean } = {}) {
|
||||
function resourceServer(
|
||||
input: { resources?: boolean; listChanged?: boolean; emptyElicitation?: boolean; urlElicitation?: boolean } = {},
|
||||
) {
|
||||
return Effect.acquireRelease(
|
||||
Effect.promise(async () => {
|
||||
const state = {
|
||||
|
|
@ -71,7 +73,42 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||
},
|
||||
},
|
||||
)
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] }))
|
||||
protocol.setRequestHandler(ListToolsRequestSchema, () =>
|
||||
Promise.resolve({
|
||||
tools: input.emptyElicitation
|
||||
? [{ name: "empty-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||
: input.urlElicitation
|
||||
? [{ name: "url-elicitation", inputSchema: { type: "object" as const, properties: {} } }]
|
||||
: [],
|
||||
}),
|
||||
)
|
||||
if (input.emptyElicitation) {
|
||||
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||
const result = await protocol.elicitInput({
|
||||
mode: "form",
|
||||
message: "Confirm",
|
||||
requestedSchema: { type: "object", properties: {} },
|
||||
})
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
structuredContent: result,
|
||||
}
|
||||
})
|
||||
}
|
||||
if (input.urlElicitation) {
|
||||
protocol.setRequestHandler(CallToolRequestSchema, async () => {
|
||||
const result = await protocol.elicitInput({
|
||||
mode: "url",
|
||||
message: "Authorize access",
|
||||
url: "https://example.com/authorize",
|
||||
elicitationId: "elicitation-test",
|
||||
})
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(result) }],
|
||||
structuredContent: result,
|
||||
}
|
||||
})
|
||||
}
|
||||
if (input.resources !== false) {
|
||||
protocol.setRequestHandler(ListResourcesRequestSchema, (request) => {
|
||||
state.resourceLists += 1
|
||||
|
|
@ -98,6 +135,7 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||
state,
|
||||
url: http.url.toString(),
|
||||
sendResourceListChanged: () => protocol.sendResourceListChanged(),
|
||||
completeElicitation: () => protocol.createElicitationCompletionNotifier("elicitation-test")(),
|
||||
close: async () => {
|
||||
await protocol.close().catch(() => {})
|
||||
await http.stop(true)
|
||||
|
|
@ -108,10 +146,11 @@ function resourceServer(input: { resources?: boolean; listChanged?: boolean } =
|
|||
)
|
||||
}
|
||||
|
||||
function resourceMcpLayer(url: string) {
|
||||
function resourceMcpLayer(url: string, onFormCreated?: (form: Form.Info) => Effect.Effect<void>) {
|
||||
const directory = AbsolutePath.make(import.meta.dir)
|
||||
const unusedIntegration = () => Effect.die("unused integration service")
|
||||
return MCP.layer.pipe(
|
||||
Layer.provideMerge(Form.layer),
|
||||
Layer.provide(
|
||||
Layer.mergeAll(
|
||||
Layer.succeed(
|
||||
|
|
@ -133,14 +172,16 @@ function resourceMcpLayer(url: string) {
|
|||
Layer.succeed(Location.Service, Location.Service.of(location({ directory }))),
|
||||
Layer.mock(EventV2.Service, {
|
||||
subscribe: () => Stream.never,
|
||||
publish: (definition, data) =>
|
||||
Effect.succeed({
|
||||
publish: (definition, data) => {
|
||||
const event = {
|
||||
id: EventV2.ID.create(),
|
||||
type: definition.type,
|
||||
data,
|
||||
} as EventV2.Payload<typeof definition>),
|
||||
} as EventV2.Payload<typeof definition>
|
||||
if (event.type !== Form.Event.Created.type || !onFormCreated) return Effect.succeed(event)
|
||||
return onFormCreated(Schema.decodeUnknownSync(Form.Event.Created.data)(data).form).pipe(Effect.as(event))
|
||||
},
|
||||
}),
|
||||
Layer.mock(Form.Service, {}),
|
||||
Layer.mock(Integration.Service, {
|
||||
connection: {
|
||||
active: unusedIntegration,
|
||||
|
|
@ -490,6 +531,53 @@ test("skips MCP resource requests when the capability is absent", async () => {
|
|||
)
|
||||
})
|
||||
|
||||
test("accepts empty MCP elicitations without creating forms", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ resources: false, emptyElicitation: true })
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const forms = yield* Form.Service
|
||||
const result = yield* service.callTool({ server: "resources", name: "empty-elicitation" })
|
||||
expect(yield* forms.list()).toEqual([])
|
||||
return result
|
||||
}).pipe(Effect.provide(resourceMcpLayer(server.url)))
|
||||
|
||||
expect(result.structured).toEqual({ action: "accept", content: {} })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("acknowledges completed MCP URL elicitations without returning internal content", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
Effect.gen(function* () {
|
||||
const server = yield* resourceServer({ resources: false, urlElicitation: true })
|
||||
const created = yield* Deferred.make<Form.Info>()
|
||||
const result = yield* Effect.gen(function* () {
|
||||
const service = yield* MCP.Service
|
||||
const forms = yield* Form.Service
|
||||
const call = yield* service.callTool({ server: "resources", name: "url-elicitation" }).pipe(Effect.forkScoped)
|
||||
|
||||
const form = yield* Deferred.await(created)
|
||||
expect(form.fields).toEqual([{ key: "elicitation", type: "external", url: "https://example.com/authorize" }])
|
||||
|
||||
yield* Effect.promise(server.completeElicitation)
|
||||
const result = yield* Fiber.join(call)
|
||||
expect(yield* forms.state(form.id)).toEqual({ status: "answered", answer: { elicitation: true } })
|
||||
return result
|
||||
}).pipe(
|
||||
Effect.provide(resourceMcpLayer(server.url, (form) => Deferred.succeed(created, form).pipe(Effect.asVoid))),
|
||||
)
|
||||
|
||||
expect(result.structured).toEqual({ action: "accept" })
|
||||
}),
|
||||
),
|
||||
)
|
||||
})
|
||||
|
||||
test("loads and reads MCP resources", async () => {
|
||||
await Effect.runPromise(
|
||||
Effect.scoped(
|
||||
|
|
|
|||
|
|
@ -18,6 +18,15 @@ let captured: Form.CreateInput | undefined
|
|||
let reject = false
|
||||
let deny = false
|
||||
const capturedInput = () => captured
|
||||
const questionInput = {
|
||||
questions: [
|
||||
{
|
||||
question: "Continue?",
|
||||
header: "Continue",
|
||||
options: [{ label: "Yes", description: "Continue" }],
|
||||
},
|
||||
],
|
||||
}
|
||||
const permission = Layer.succeed(
|
||||
PermissionV2.Service,
|
||||
PermissionV2.Service.of({
|
||||
|
|
@ -90,7 +99,7 @@ describe("QuestionTool", () => {
|
|||
yield* settleTool(registry, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: { questions: [] } },
|
||||
call: { type: "tool-call", id: "call-question-denied", name: "question", input: questionInput },
|
||||
}),
|
||||
).toEqual({
|
||||
result: { type: "error", value: "Permission denied: question" },
|
||||
|
|
@ -158,7 +167,6 @@ describe("QuestionTool", () => {
|
|||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
|
|
@ -199,14 +207,22 @@ describe("QuestionTool", () => {
|
|||
yield* executeTool(registryService, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
|
||||
})
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
fields: [
|
||||
{
|
||||
key: "q0",
|
||||
title: "Continue",
|
||||
description: "Continue?",
|
||||
options: [{ value: "Yes", label: "Yes", description: "Continue" }],
|
||||
custom: true,
|
||||
type: "string",
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -220,7 +236,7 @@ describe("QuestionTool", () => {
|
|||
const fiber = yield* executeTool(registryService, {
|
||||
sessionID,
|
||||
...toolIdentity,
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: { questions: [] } },
|
||||
call: { type: "tool-call", id: "call-question", name: "question", input: questionInput },
|
||||
}).pipe(Effect.forkScoped)
|
||||
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue