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

@ -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" } })
}),
)
})

View file

@ -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)
}),
)
})

View file

@ -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")

View file

@ -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: [],
})
}),
)