Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/core/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,12 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
message: Schema.String,
}) {}

export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Form.CancelledError", {}) {
override get message() {
return "The form was cancelled"
}
}

export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/location-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ const pluginSupervisorNode = makeLocationNode({
Npm.node,
PermissionV2.node,
PluginRuntime.node,
QuestionV2.node,
Form.node,
ReadToolFileSystem.node,
Reference.node,
Ripgrep.node,
Expand Down
6 changes: 3 additions & 3 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ConfigReferencePlugin } from "../config/plugin/reference"
import { ConfigSkillPlugin } from "../config/plugin/skill"
import { EventV2 } from "../event"
import { FileMutation } from "../file-mutation"
import { Form } from "../form"
import { FileSystem } from "../filesystem"
import { FSUtil } from "../fs-util"
import { Global } from "../global"
Expand All @@ -24,7 +25,6 @@ import { LocationMutation } from "../location-mutation"
import { ModelsDev } from "../models-dev"
import { Npm } from "../npm"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Reference } from "../reference"
import { Ripgrep } from "../ripgrep"
import { SessionInstructions } from "../session/instructions"
Expand Down Expand Up @@ -73,7 +73,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
const npm = yield* Npm.Service
const permission = yield* PermissionV2.Service
const runtime = yield* PluginRuntime.Service
const question = yield* QuestionV2.Service
const form = yield* Form.Service
const read = yield* ReadToolFileSystem.Service
const reference = yield* Reference.Service
const ripgrep = yield* Ripgrep.Service
Expand Down Expand Up @@ -102,7 +102,7 @@ const services = Effect.fn("PluginInternal.services")(function* () {
Context.make(Npm.Service, npm),
Context.make(PermissionV2.Service, permission),
Context.make(PluginRuntime.Service, runtime),
Context.make(QuestionV2.Service, question),
Context.make(Form.Service, form),
Context.make(ReadToolFileSystem.Service, read),
Context.make(Reference.Service, reference),
Context.make(Ripgrep.Service, ripgrep),
Expand Down
14 changes: 6 additions & 8 deletions packages/core/src/session/runner/llm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import { Config } from "../../config"
import { Database } from "../../database/database"
import { EventV2 } from "../../event"
import { Location } from "../../location"
import { QuestionV2 } from "../../question"
import { Form } from "../../form"
import { SystemContext } from "../../system-context/index"
import { SystemContextBuiltIns } from "../../system-context/builtins"
import { InstructionContext } from "../../instruction-context"
Expand Down Expand Up @@ -149,9 +149,8 @@ const layer = Layer.effect(
const awaitToolFibers = (fibers: FiberSet.FiberSet<void, ToolOutputStore.Error>) =>
Effect.raceFirst(FiberSet.join(fibers), FiberSet.awaitEmpty(fibers))

// Match V1: dismissing a question halts the loop instead of becoming model-facing tool output.
const isQuestionRejected = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof QuestionV2.RejectedError)
const isFormCancelled = (cause: Cause.Cause<unknown>) =>
cause.reasons.some((reason) => Cause.isDieReason(reason) && reason.defect instanceof Form.CancelledError)

const loadSystemContext = (agent: AgentV2.Selection, sessionID: SessionSchema.ID) =>
Effect.all(
Expand Down Expand Up @@ -341,14 +340,13 @@ const layer = Layer.effect(
if (streamInterrupted) yield* FiberSet.clear(toolFibers)
const settled = yield* restore(awaitToolFibers(toolFibers)).pipe(Effect.exit)
const toolsInterrupted = settled._tag === "Failure" && Cause.hasInterrupts(settled.cause)
const questionDismissed = settled._tag === "Failure" && isQuestionRejected(settled.cause)
const formCancelled = settled._tag === "Failure" && isFormCancelled(settled.cause)

if (questionDismissed || streamInterrupted || toolsInterrupted) {
if (formCancelled || streamInterrupted || toolsInterrupted) {
yield* FiberSet.clear(toolFibers)
yield* serialized(publisher.failUnsettledTools("Tool execution interrupted"))
yield* serialized(publisher.failAssistant("Step interrupted"))
// Match V1: dismissing a question halts the loop like an interruption.
if (questionDismissed) return yield* Effect.interrupt
if (formCancelled) return yield* Effect.interrupt
}
// A settled tool fiber failure is one of two things. A defect from a tool
// implementation becomes a failed tool call the model can read, and the step still
Expand Down
38 changes: 33 additions & 5 deletions packages/core/src/tool/question.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ export * as QuestionTool from "./question"
import type { PluginContext } from "@opencode-ai/plugin/v2/effect"
import { ToolFailure } from "@opencode-ai/llm"
import { Effect, Schema } from "effect"
import { Form } from "../form"
import { PermissionV2 } from "../permission"
import { QuestionV2 } from "../question"
import { Tool } from "./tool"
Expand Down Expand Up @@ -45,7 +46,7 @@ export const toModelOutput = (
export const Plugin = {
id: "opencode.tool.question",
effect: Effect.fn("QuestionTool.Plugin")(function* (ctx: PluginContext) {
const question = yield* QuestionV2.Service
const forms = yield* Form.Service
const permission = yield* PermissionV2.Service

yield* ctx.tool
Expand All @@ -69,15 +70,42 @@ export const Plugin = {
.pipe(
Effect.mapError(() => new ToolFailure({ message: "Permission denied: question" })),
Effect.andThen(
question
forms
.ask({
sessionID: context.sessionID,
questions: input.questions,
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
metadata: {
kind: "question",
tool: { messageID: context.assistantMessageID, callID: context.toolCallID },
},
mode: "form",
fields: input.questions.map(
(question, index): Form.Field => ({
key: `q${index}`,
title: question.header,
description: question.question,
type: question.multiple === true ? "multiselect" : "string",
options: question.options.map((option) => ({
value: option.label,
label: option.label,
description: option.description,
})),
custom: true,
}),
),
})
.pipe(Effect.orDie),
),
Effect.map((answers) => ({ answers })),
Effect.flatMap((state) => {
if (state.status !== "answered") return Effect.die(new Form.CancelledError())
return Effect.succeed({
answers: input.questions.map((_, index): QuestionV2.Answer => {
const value = state.answer[`q${index}`]
if (value === undefined) return []
if (typeof value === "object") return Array.from(value)
return [String(value)]
}),
})
}),
),
}),
})
Expand Down
16 changes: 4 additions & 12 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ 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 { Form } from "@opencode-ai/core/form"
import { AbsolutePath } from "@opencode-ai/core/schema"
import { SessionV2 } from "@opencode-ai/core/session"
import { Snapshot } from "@opencode-ai/core/snapshot"
Expand Down Expand Up @@ -276,7 +276,7 @@ const it = testEffect(
LayerNode.group([
Database.node,
EventV2.node,
QuestionV2.node,
Form.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Expand Down Expand Up @@ -2822,19 +2822,17 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("interrupts runner continuation when a question is dismissed", () =>
it.effect("interrupts runner continuation when a form is cancelled", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
const registry = yield* ToolRegistry.Service
const questions = yield* QuestionV2.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),
execute: () => Effect.die(new Form.CancelledError()),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })
Expand All @@ -2851,12 +2849,6 @@ describe("SessionRunnerLLM", () => {
]

const run = yield* session.resume(sessionID).pipe(Effect.exit, Effect.forkChild)
let pending = yield* questions.list()
while (pending.length === 0) {
yield* Effect.yieldNow
pending = yield* questions.list()
}
yield* questions.reject(pending[0]!.id)
const exit = yield* Fiber.join(run)

expect(exit._tag).toBe("Failure")
Expand Down
79 changes: 61 additions & 18 deletions packages/core/test/tool-question.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -14,7 +14,7 @@ import { toolIdentity, executeTool, registerToolPlugin, settleTool, toolDefiniti

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
Expand All @@ -32,28 +32,38 @@ const permission = Layer.succeed(
list: () => Effect.die("unused"),
}),
)
const question = Layer.succeed(
QuestionV2.Service,
QuestionV2.Service.of({
ask: (input: QuestionV2.AskInput) =>
const form = Layer.succeed(
Form.Service,
Form.Service.of({
ask: (input: Form.CreateInput) =>
Effect.sync(() => {
captured = input
}).pipe(Effect.andThen(reject ? Effect.fail(new QuestionV2.RejectedError()) : Effect.succeed([["Build"], []]))),
reply: () => Effect.die("unused"),
reject: () => Effect.die("unused"),
}).pipe(
Effect.andThen(
Effect.sync(
(): Form.State =>
reject ? { status: "cancelled" } : { status: "answered", answer: { q0: "Build", q1: ["Dev"] } },
),
),
),
create: () => Effect.die("unused"),
get: () => Effect.die("unused"),
list: () => Effect.die("unused"),
state: () => Effect.die("unused"),
reply: () => Effect.die("unused"),
cancel: () => Effect.die("unused"),
}),
)
const questionToolNode = makeLocationNode({
name: "test/question-tool-plugin",
layer: Layer.effectDiscard(registerToolPlugin(QuestionTool.Plugin)),
deps: [ToolRegistry.toolsNode, PermissionV2.node, QuestionV2.node],
deps: [ToolRegistry.toolsNode, PermissionV2.node, Form.node],
})

const it = testEffect(
AppNodeBuilder.build(LayerNode.group([ToolRegistry.node, ToolRegistry.toolsNode, questionToolNode]), [
[PermissionV2.node, permission],
[QuestionV2.node, question],
[Form.node, form],
[ToolOutputStore.node, ToolOutputStore.nodeWithoutConfig],
]),
)
Expand Down Expand Up @@ -95,6 +105,12 @@ describe("QuestionTool", () => {
question: "Which environment?",
header: "Environment",
options: [{ label: "Dev", description: "Development" }],
multiple: true,
},
{
question: "Anything else?",
header: "Optional",
options: [],
},
]

Expand All @@ -109,23 +125,49 @@ describe("QuestionTool", () => {
result: {
type: "text",
value:
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
output: {
structured: { answers: [["Build"], []] },
structured: { answers: [["Build"], ["Dev"], []] },
content: [
{
type: "text",
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Unanswered". You can now continue with the user\'s answers in mind.',
text: 'User has answered your questions: "What should happen?"="Build", "Which environment?"="Dev", "Anything else?"="Unanswered". You can now continue with the user\'s answers in mind.',
},
],
},
})
expect(assertions).toMatchObject([{ sessionID, action: "question", resources: ["*"] }])
expect(capturedInput()).toEqual({
sessionID,
questions,
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [
{
key: "q0",
title: "Action",
description: "What should happen?",
options: [{ value: "Build", label: "Build", description: "Build it" }],
custom: true,
type: "string",
},
{
key: "q1",
title: "Environment",
description: "Which environment?",
options: [{ value: "Dev", label: "Dev", description: "Development" }],
custom: true,
type: "multiselect",
},
{
key: "q2",
title: "Optional",
description: "Anything else?",
options: [],
custom: true,
type: "string",
},
],
})
}),
)
Expand All @@ -144,8 +186,9 @@ describe("QuestionTool", () => {
})
expect(capturedInput()).toEqual({
sessionID,
questions: [],
tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" },
metadata: { kind: "question", tool: { messageID: toolIdentity.assistantMessageID, callID: "call-question" } },
mode: "form",
fields: [],
})
}),
)
Expand Down
Loading
Loading