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
58
packages/core/test/form.test.ts
Normal file
58
packages/core/test/form.test.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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 { 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: "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" } })
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -1,114 +0,0 @@
|
|||
import { describe, expect } from "bun:test"
|
||||
import { Context, Deferred, Effect, Exit, Fiber, Layer, Scope } from "effect"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const questions = AppNodeBuilder.build(LayerNode.group([EventV2.node, QuestionV2.node]))
|
||||
const it = testEffect(questions)
|
||||
|
||||
const sessionID = SessionV2.ID.make("ses_question_test")
|
||||
const question: QuestionV2.Info = {
|
||||
question: "Which option?",
|
||||
header: "Option",
|
||||
options: [{ label: "One", description: "First option" }],
|
||||
}
|
||||
|
||||
const waitForAsk = Effect.fn("QuestionV2Test.waitForAsk")(function* (
|
||||
service: QuestionV2.Interface,
|
||||
input: QuestionV2.AskInput,
|
||||
) {
|
||||
const events = yield* EventV2.Service
|
||||
const asked = yield* Deferred.make<QuestionV2.Request>()
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
event.type === QuestionV2.Event.Asked.type
|
||||
? Deferred.succeed(asked, event.data as QuestionV2.Request).pipe(Effect.asVoid)
|
||||
: Effect.void,
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const fiber = yield* service.ask(input).pipe(Effect.forkScoped)
|
||||
return { fiber, request: yield* Deferred.await(asked) }
|
||||
})
|
||||
|
||||
describe("QuestionV2", () => {
|
||||
it.effect("publishes lifecycle events and settles a pending reply", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type.startsWith("question.v2.")) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
expect(request.id).toMatch(/^que_/)
|
||||
expect(yield* service.list()).toEqual([request])
|
||||
yield* service.reply({ requestID: request.id, answers: [["One"]] })
|
||||
|
||||
expect(yield* Fiber.join(fiber)).toEqual([["One"]])
|
||||
expect(yield* service.list()).toEqual([])
|
||||
expect(published.map((event) => [event.type, event.data])).toEqual([
|
||||
[QuestionV2.Event.Asked.type, request],
|
||||
[QuestionV2.Event.Replied.type, { sessionID, requestID: request.id, answers: [["One"]] }],
|
||||
])
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("publishes rejection, fails the ask, and rejects unknown IDs", () =>
|
||||
Effect.gen(function* () {
|
||||
const service = yield* QuestionV2.Service
|
||||
const events = yield* EventV2.Service
|
||||
const published: EventV2.Payload[] = []
|
||||
const unsubscribe = yield* events.listen((event) =>
|
||||
Effect.sync(() => {
|
||||
if (event.type === QuestionV2.Event.Rejected.type) published.push(event)
|
||||
}),
|
||||
)
|
||||
yield* Effect.addFinalizer(() => unsubscribe)
|
||||
const { fiber, request } = yield* waitForAsk(service, { sessionID, questions: [question] })
|
||||
|
||||
yield* service.reject(request.id)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
expect(published.map((event) => event.data)).toEqual([{ sessionID, requestID: request.id }])
|
||||
|
||||
const unknown = QuestionV2.ID.ascending("que_unknown")
|
||||
expect(yield* service.reply({ requestID: unknown, answers: [] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
expect(yield* service.reject(unknown).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: unknown }),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("isolates pending requests by location-layer instance and rejects them on finalization", () =>
|
||||
Effect.gen(function* () {
|
||||
const firstScope = yield* Scope.make()
|
||||
const secondScope = yield* Scope.make()
|
||||
const first = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), firstScope), QuestionV2.Service)
|
||||
const second = Context.get(yield* Layer.buildWithScope(Layer.fresh(questions), secondScope), QuestionV2.Service)
|
||||
const fiber = yield* first.ask({ sessionID, questions: [question] }).pipe(Effect.forkScoped)
|
||||
yield* Effect.yieldNow
|
||||
const request = (yield* first.list())[0]!
|
||||
|
||||
expect(yield* second.list()).toEqual([])
|
||||
expect(yield* second.reply({ requestID: request.id, answers: [["One"]] }).pipe(Effect.flip)).toEqual(
|
||||
new QuestionV2.NotFoundError({ requestID: request.id }),
|
||||
)
|
||||
|
||||
yield* Scope.close(firstScope, Exit.void)
|
||||
const exit = yield* Fiber.await(fiber)
|
||||
expect(Exit.isFailure(exit)).toBe(true)
|
||||
if (Exit.isFailure(exit)) expect(exit.cause.toString()).toContain("QuestionV2.RejectedError")
|
||||
yield* Scope.close(secondScope, Exit.void)
|
||||
}),
|
||||
)
|
||||
})
|
||||
|
|
@ -16,12 +16,12 @@ import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
|||
import { LayerNodePlatform } from "@opencode-ai/core/effect/app-node-platform"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { EventV2 } from "@opencode-ai/core/event"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { Job } from "@opencode-ai/core/job"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { Snapshot } from "@opencode-ai/core/snapshot"
|
||||
|
|
@ -39,6 +39,7 @@ import * as SessionRunnerLLM from "@opencode-ai/core/session/runner/llm"
|
|||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { SessionRunnerSystemPrompt } from "@opencode-ai/core/session/runner/system-prompt"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
import { ToolOutputStore } from "@opencode-ai/core/tool-output-store"
|
||||
import { AgentV2 } from "@opencode-ai/core/agent"
|
||||
import { Config } from "@opencode-ai/core/config"
|
||||
|
|
@ -268,7 +269,7 @@ const it = testEffect(
|
|||
LayerNode.group([
|
||||
Database.node,
|
||||
EventV2.node,
|
||||
QuestionV2.node,
|
||||
Form.node,
|
||||
SessionProjector.node,
|
||||
SessionStore.node,
|
||||
AgentV2.node,
|
||||
|
|
@ -2801,14 +2802,21 @@ describe("SessionRunnerLLM", () => {
|
|||
yield* setup
|
||||
const session = yield* SessionV2.Service
|
||||
const registry = yield* ToolRegistry.Service
|
||||
const questions = yield* QuestionV2.Service
|
||||
const forms = yield* Form.Service
|
||||
yield* registry.register({
|
||||
question: Tool.make({
|
||||
description: "Ask the user",
|
||||
input: Schema.Struct({}),
|
||||
output: Schema.Struct({}),
|
||||
execute: (_, context) =>
|
||||
questions.ask({ sessionID: context.sessionID, questions: [] }).pipe(Effect.as({}), Effect.orDie),
|
||||
forms
|
||||
.ask({ sessionID: context.sessionID, mode: "form", fields: [] })
|
||||
.pipe(
|
||||
Effect.orDie,
|
||||
Effect.flatMap((state) =>
|
||||
state.status === "answered" ? Effect.succeed({}) : Effect.die(new QuestionTool.RejectedError()),
|
||||
),
|
||||
),
|
||||
}),
|
||||
})
|
||||
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
|
||||
|
|
@ -2825,12 +2833,12 @@ describe("SessionRunnerLLM", () => {
|
|||
]
|
||||
|
||||
const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
|
||||
let pending = yield* questions.list()
|
||||
let pending = yield* forms.list({ sessionID })
|
||||
while (pending.length === 0) {
|
||||
yield* Effect.yieldNow
|
||||
pending = yield* questions.list()
|
||||
pending = yield* forms.list({ sessionID })
|
||||
}
|
||||
yield* questions.reject(pending[0]!.id)
|
||||
yield* forms.cancel(pending[0]!.id)
|
||||
const exit = yield* Fiber.join(run)
|
||||
|
||||
expect(exit._tag).toBe("Failure")
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ import { describe, expect } from "bun:test"
|
|||
import { Effect, Exit, Fiber, Layer } from "effect"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
|
||||
import { Form } from "@opencode-ai/core/form"
|
||||
import { PermissionV2 } from "@opencode-ai/core/permission"
|
||||
import { QuestionV2 } from "@opencode-ai/core/question"
|
||||
import { SessionV2 } from "@opencode-ai/core/session"
|
||||
import { ToolRegistry } from "@opencode-ai/core/tool/registry"
|
||||
import { QuestionTool } from "@opencode-ai/core/tool/question"
|
||||
|
|
@ -13,7 +13,7 @@ import { toolIdentity, executeTool, settleTool, toolDefinitions } from "./lib/to
|
|||
|
||||
const sessionID = SessionV2.ID.make("ses_question_tool_test")
|
||||
const assertions: PermissionV2.AssertInput[] = []
|
||||
let captured: QuestionV2.AskInput | undefined
|
||||
let captured: Form.CreateInput | undefined
|
||||
let reject = false
|
||||
let deny = false
|
||||
const capturedInput = () => captured
|
||||
|
|
@ -31,22 +31,27 @@ const permission = Layer.succeed(
|
|||
list: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const question = Layer.succeed(
|
||||
QuestionV2.Service,
|
||||
QuestionV2.Service.of({
|
||||
ask: (input: QuestionV2.AskInput) =>
|
||||
Effect.sync(() => {
|
||||
const form = Layer.succeed(
|
||||
Form.Service,
|
||||
Form.Service.of({
|
||||
create: () => Effect.die("unused"),
|
||||
ask: (input: Form.CreateInput) =>
|
||||
Effect.gen(function* () {
|
||||
captured = input
|
||||
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
|
||||
reply: () => Effect.die("unused"),
|
||||
reject: () => Effect.die("unused"),
|
||||
if (reject) return { status: "cancelled" } as const
|
||||
return { status: "answered", answer: { question_0: "Build" } } as const
|
||||
}),
|
||||
get: () => Effect.die("unused"),
|
||||
list: () => Effect.die("unused"),
|
||||
state: () => Effect.die("unused"),
|
||||
reply: () => Effect.die("unused"),
|
||||
cancel: () => Effect.die("unused"),
|
||||
}),
|
||||
)
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, QuestionTool.node]), [
|
||||
[PermissionV2.node, permission],
|
||||
[QuestionV2.node, question],
|
||||
[Form.node, form],
|
||||
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
|
||||
]),
|
||||
)
|
||||
|
|
@ -117,8 +122,27 @@ describe("QuestionTool", () => {
|
|||
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions,
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [
|
||||
{
|
||||
key: "question_0",
|
||||
title: "What should happen?",
|
||||
description: "Action",
|
||||
type: "string",
|
||||
options: [{ value: "Build", label: "Build", description: "Build it" }],
|
||||
custom: true,
|
||||
},
|
||||
{
|
||||
key: "question_1",
|
||||
title: "Which environment?",
|
||||
description: "Environment",
|
||||
type: "string",
|
||||
options: [{ value: "Dev", label: "Dev", description: "Development" }],
|
||||
custom: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
@ -137,8 +161,10 @@ describe("QuestionTool", () => {
|
|||
})
|
||||
expect(capturedInput()).toEqual({
|
||||
sessionID,
|
||||
questions: [],
|
||||
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
|
||||
title: "Questions",
|
||||
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
|
||||
mode: "form",
|
||||
fields: [],
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue