Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b181216
feat(tui): render forms and route question tool through form service
rekram1-node Jul 3, 2026
d6472bf
refactor(tui): simplify form prompts
rekram1-node Jul 3, 2026
ae8a591
fix(tui): align form visibility with active answers
rekram1-node Jul 3, 2026
bb9a34a
fix(tui): surface child session forms
rekram1-node Jul 3, 2026
386b266
fix(tui): surface child session questions
rekram1-node Jul 3, 2026
ba7b009
fix(tui): notify for form requests
rekram1-node Jul 3, 2026
67b9498
refactor(tui): remove question prompt state
rekram1-node Jul 3, 2026
749ad0e
fix(tui): address form review regressions
rekram1-node Jul 3, 2026
a9031a8
fix(tui): expose plugin session messages
rekram1-node Jul 3, 2026
e7ce32c
fix(tui): refresh form blockers on reconnect
rekram1-node Jul 3, 2026
1a38f66
fix(tui): preserve global form location
rekram1-node Jul 3, 2026
d1163cb
fix(tui): retain global forms across locations
rekram1-node Jul 3, 2026
be0cd93
chore(tui): merge v2 into form branch
rekram1-node Jul 3, 2026
0667079
refactor(core): inline question form mapping
rekram1-node Jul 3, 2026
a2136dd
refactor(tui): remove question plugin state
rekram1-node Jul 3, 2026
5deef98
refactor(core): handle form cancellation in runner
rekram1-node Jul 3, 2026
5f277c4
refactor(tui): simplify form aggregation
rekram1-node Jul 3, 2026
bfa9598
fix(plugin): preserve question state compatibility
rekram1-node Jul 3, 2026
3056187
fix(tui): scope global forms by location
rekram1-node Jul 3, 2026
4346bde
fix(tui): key form prompt by id
rekram1-node Jul 3, 2026
9215a2d
test(core): remove legacy question interruption case
rekram1-node Jul 3, 2026
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
2 changes: 2 additions & 0 deletions packages/core/src/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,8 @@ export class InvalidFormError extends Schema.TaggedErrorClass<InvalidFormError>(
message: Schema.String,
}) {}

export class CancelledError extends Schema.TaggedErrorClass<CancelledError>()("Form.CancelledError", {}) {}

export type CreateInput =
| (Omit<Form.FormInfo, "id"> & { readonly id?: ID })
| (Omit<Form.UrlInfo, "id"> & { readonly id?: ID })
Expand Down
8 changes: 4 additions & 4 deletions packages/core/src/plugin/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,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 @@ -28,7 +29,6 @@ import { Npm } from "../npm"
import { PluginV2 } from "../plugin"
import { PluginRuntime } from "../plugin/runtime"
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 @@ -69,6 +69,7 @@ export type Requirements =
| EventV2.Service
| FileMutation.Service
| FileSystem.Service
| Form.Service
| FSUtil.Service
| Global.Service
| HttpClient.HttpClient
Expand All @@ -80,7 +81,6 @@ export type Requirements =
| Npm.Service
| PermissionV2.Service
| PluginRuntime.Service
| QuestionV2.Service
| ReadToolFileSystem.Service
| Reference.Service
| Ripgrep.Service
Expand Down Expand Up @@ -116,13 +116,13 @@ const layer = Layer.effectDiscard(
Context.make(EventV2.Service, yield* EventV2.Service),
Context.make(FSUtil.Service, yield* FSUtil.Service),
Context.make(FileSystem.Service, yield* FileSystem.Service),
Context.make(Form.Service, yield* Form.Service),
Context.make(Global.Service, yield* Global.Service),
Context.make(HttpClient.HttpClient, yield* HttpClient.HttpClient),
Context.make(LocationMutation.Service, yield* LocationMutation.Service),
Context.make(FileMutation.Service, yield* FileMutation.Service),
Context.make(Image.Service, yield* Image.Service),
Context.make(PermissionV2.Service, yield* PermissionV2.Service),
Context.make(QuestionV2.Service, yield* QuestionV2.Service),
Context.make(ReadToolFileSystem.Service, yield* ReadToolFileSystem.Service),
Context.make(SessionInstructions.Service, yield* SessionInstructions.Service),
Context.make(SessionTodo.Service, yield* SessionTodo.Service),
Expand Down Expand Up @@ -192,10 +192,10 @@ export const node = makeLocationNode({
EventV2.node,
FSUtil.node,
FileSystem.node,
Form.node,
Global.node,
httpClient,
PermissionV2.node,
QuestionV2.node,
ReadToolFileSystem.node,
SessionInstructions.node,
SessionTodo.node,
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
36 changes: 31 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: "core-question-tool",
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,40 @@ 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
58 changes: 0 additions & 58 deletions packages/core/test/session-runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ 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"
Expand Down Expand Up @@ -276,7 +275,6 @@ const it = testEffect(
LayerNode.group([
Database.node,
EventV2.node,
QuestionV2.node,
SessionProjector.node,
SessionStore.node,
AgentV2.node,
Expand Down Expand Up @@ -2822,62 +2820,6 @@ describe("SessionRunnerLLM", () => {
}),
)

it.effect("interrupts runner continuation when a question is dismissed", () =>
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),
}),
})
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "Ask then stop" }), resume: false })

requests.length = 0
responses = [
[
LLMEvent.stepStart({ index: 0 }),
LLMEvent.toolCall({ id: "call-question", name: "question", input: {} }),
LLMEvent.stepFinish({ index: 0, reason: "tool-calls" }),
LLMEvent.finish({ reason: "tool-calls" }),
],
[],
]

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")
if (exit._tag === "Failure") expect(Cause.hasInterruptsOnly(exit.cause)).toBe(true)
expect(requests).toHaveLength(1)
expect(yield* session.context(sessionID)).toMatchObject([
{ type: "user", text: "Ask then stop" },
{
type: "assistant",
content: [
{
type: "tool",
id: "call-question",
state: { status: "error", error: { type: "unknown", message: "Tool execution interrupted" } },
},
],
},
])
}),
)

it.effect("awaits started local tools before surfacing provider stream failure", () =>
Effect.gen(function* () {
yield* setup
Expand Down
56 changes: 41 additions & 15 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,35 @@ 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" } })),
),
),
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 @@ -124,8 +131,26 @@ describe("QuestionTool", () => {
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: "string",
},
],
})
}),
)
Expand All @@ -144,8 +169,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