diff --git a/STYLE_GUIDE.md b/STYLE_GUIDE.md
index 47d008fb42..8dd3be5892 100644
--- a/STYLE_GUIDE.md
+++ b/STYLE_GUIDE.md
@@ -1,7 +1,8 @@
## Style Guide
- Try to keep things in one function unless composable or reusable
-- AVOID unnecessary destructuring of variables
+- AVOID unnecessary destructuring of variables. instead of doing `const { a, b }
+= obj` just reference it as obj.a and obj.b. this preserves context
- AVOID `try`/`catch` where possible
- AVOID `else` statements
- AVOID using `any` type
diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
index 8a14d8b2e7..0edc911344 100644
--- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx
+++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx
@@ -8,6 +8,7 @@ import type {
Todo,
Command,
PermissionRequest,
+ QuestionRequest,
LspStatus,
McpStatus,
McpResource,
@@ -42,6 +43,9 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
permission: {
[sessionID: string]: PermissionRequest[]
}
+ question: {
+ [sessionID: string]: QuestionRequest[]
+ }
config: Config
session: Session[]
session_status: {
@@ -80,6 +84,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
status: "loading",
agent: [],
permission: {},
+ question: {},
command: [],
provider: [],
provider_default: {},
@@ -142,6 +147,44 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({
break
}
+ case "question.replied":
+ case "question.rejected": {
+ const requests = store.question[event.properties.sessionID]
+ if (!requests) break
+ const match = Binary.search(requests, event.properties.requestID, (r) => r.id)
+ if (!match.found) break
+ setStore(
+ "question",
+ event.properties.sessionID,
+ produce((draft) => {
+ draft.splice(match.index, 1)
+ }),
+ )
+ break
+ }
+
+ case "question.asked": {
+ const request = event.properties
+ const requests = store.question[request.sessionID]
+ if (!requests) {
+ setStore("question", request.sessionID, [request])
+ break
+ }
+ const match = Binary.search(requests, request.id, (r) => r.id)
+ if (match.found) {
+ setStore("question", request.sessionID, match.index, reconcile(request))
+ break
+ }
+ setStore(
+ "question",
+ request.sessionID,
+ produce((draft) => {
+ draft.splice(match.index, 0, request)
+ }),
+ )
+ break
+ }
+
case "todo.updated":
setStore("todo", event.properties.sessionID, event.properties.todos)
break
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
index e1423e22c2..78f2ff7aa8 100644
--- a/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/index.tsx
@@ -41,6 +41,7 @@ import type { EditTool } from "@/tool/edit"
import type { PatchTool } from "@/tool/patch"
import type { WebFetchTool } from "@/tool/webfetch"
import type { TaskTool } from "@/tool/task"
+import type { QuestionTool } from "@/tool/question"
import { useKeyboard, useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
import { useSDK } from "@tui/context/sdk"
import { useCommandDialog } from "@tui/component/dialog-command"
@@ -69,6 +70,7 @@ import { usePromptRef } from "../../context/prompt"
import { useExit } from "../../context/exit"
import { Filesystem } from "@/util/filesystem"
import { PermissionPrompt } from "./permission"
+import { QuestionPrompt } from "./question"
import { DialogExportOptions } from "../../ui/dialog-export-options"
import { formatTranscript } from "../../util/transcript"
@@ -118,9 +120,13 @@ export function Session() {
})
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
const permissions = createMemo(() => {
- if (session()?.parentID) return sync.data.permission[route.sessionID] ?? []
+ if (session()?.parentID) return []
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
})
+ const questions = createMemo(() => {
+ if (session()?.parentID) return []
+ return children().flatMap((x) => sync.data.question[x.id] ?? [])
+ })
const pending = createMemo(() => {
return messages().findLast((x) => x.role === "assistant" && !x.time.completed)?.id
@@ -1037,8 +1043,11 @@ export function Session() {
0}>
+ 0}>
+
+
{
prompt = r
promptRef.set(r)
@@ -1047,7 +1056,7 @@ export function Session() {
r.set(route.initialPrompt)
}
}}
- disabled={permissions().length > 0}
+ disabled={permissions().length > 0 || questions().length > 0}
onSubmit={() => {
toBottom()
}}
@@ -1381,6 +1390,9 @@ function ToolPart(props: { last: boolean; part: ToolPart; message: AssistantMess
+
+
+
@@ -1442,7 +1454,12 @@ function InlineTool(props: {
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error : undefined))
- const denied = createMemo(() => error()?.includes("rejected permission") || error()?.includes("specified a rule"))
+ const denied = createMemo(
+ () =>
+ error()?.includes("rejected permission") ||
+ error()?.includes("specified a rule") ||
+ error()?.includes("user dismissed"),
+ )
return (
) {
)
}
+function Question(props: ToolProps) {
+ const { theme } = useTheme()
+ const count = createMemo(() => props.input.questions?.length ?? 0)
+ return (
+
+
+
+
+
+ {(q, i) => (
+
+ {q.question}
+ {props.metadata.answers?.[i()] || "(no answer)"}
+
+ )}
+
+
+
+
+
+
+ Asked {count()} question{count() !== 1 ? "s" : ""}
+
+
+
+ )
+}
+
function normalizePath(input?: string) {
if (!input) return ""
if (path.isAbsolute(input)) {
diff --git a/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
new file mode 100644
index 0000000000..96883415bb
--- /dev/null
+++ b/packages/opencode/src/cli/cmd/tui/routes/session/question.tsx
@@ -0,0 +1,287 @@
+import { createStore } from "solid-js/store"
+import { createMemo, For, Show } from "solid-js"
+import { useKeyboard } from "@opentui/solid"
+import type { TextareaRenderable } from "@opentui/core"
+import { useKeybind } from "../../context/keybind"
+import { useTheme } from "../../context/theme"
+import type { QuestionRequest } from "@opencode-ai/sdk/v2"
+import { useSDK } from "../../context/sdk"
+import { SplitBorder } from "../../component/border"
+import { useTextareaKeybindings } from "../../component/textarea-keybindings"
+import { useDialog } from "../../ui/dialog"
+
+export function QuestionPrompt(props: { request: QuestionRequest }) {
+ const sdk = useSDK()
+ const { theme } = useTheme()
+ const keybind = useKeybind()
+ const bindings = useTextareaKeybindings()
+
+ const questions = createMemo(() => props.request.questions)
+ const single = createMemo(() => questions().length === 1)
+ const tabs = createMemo(() => (single() ? 1 : questions().length + 1)) // questions + confirm tab (no confirm for single)
+ const [store, setStore] = createStore({
+ tab: 0,
+ answers: [] as string[],
+ custom: [] as string[],
+ selected: 0,
+ editing: false,
+ })
+
+ let textarea: TextareaRenderable | undefined
+
+ const question = createMemo(() => questions()[store.tab])
+ const confirm = createMemo(() => !single() && store.tab === questions().length)
+ const options = createMemo(() => question()?.options ?? [])
+ const other = createMemo(() => store.selected === options().length)
+ const input = createMemo(() => store.custom[store.tab] ?? "")
+
+ function submit() {
+ // Fill in empty answers with empty strings
+ const answers = questions().map((_, i) => store.answers[i] ?? "")
+ sdk.client.question.reply({
+ requestID: props.request.id,
+ answers,
+ })
+ }
+
+ function reject() {
+ sdk.client.question.reject({
+ requestID: props.request.id,
+ })
+ }
+
+ function pick(answer: string, custom: boolean = false) {
+ const answers = [...store.answers]
+ answers[store.tab] = answer
+ setStore("answers", answers)
+ if (custom) {
+ const inputs = [...store.custom]
+ inputs[store.tab] = answer
+ setStore("custom", inputs)
+ }
+ if (single()) {
+ sdk.client.question.reply({
+ requestID: props.request.id,
+ answers: [answer],
+ })
+ return
+ }
+ setStore("tab", store.tab + 1)
+ setStore("selected", 0)
+ }
+
+ const dialog = useDialog()
+
+ useKeyboard((evt) => {
+ // When editing "Other" textarea
+ if (store.editing && !confirm()) {
+ if (evt.name === "escape") {
+ evt.preventDefault()
+ setStore("editing", false)
+ return
+ }
+ if (evt.name === "return") {
+ evt.preventDefault()
+ const text = textarea?.plainText?.trim()
+ if (text) {
+ pick(text, true)
+ setStore("editing", false)
+ }
+ return
+ }
+ // Let textarea handle all other keys
+ return
+ }
+
+ if (evt.name === "left" || evt.name === "h") {
+ evt.preventDefault()
+ const next = (store.tab - 1 + tabs()) % tabs()
+ setStore("tab", next)
+ setStore("selected", 0)
+ }
+
+ if (evt.name === "right" || evt.name === "l") {
+ evt.preventDefault()
+ const next = (store.tab + 1) % tabs()
+ setStore("tab", next)
+ setStore("selected", 0)
+ }
+
+ if (confirm()) {
+ if (evt.name === "return") {
+ evt.preventDefault()
+ submit()
+ }
+ if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+ evt.preventDefault()
+ reject()
+ }
+ } else {
+ const opts = options()
+ const total = opts.length + 1 // options + "Other"
+
+ if (evt.name === "up" || evt.name === "k") {
+ evt.preventDefault()
+ setStore("selected", (store.selected - 1 + total) % total)
+ }
+
+ if (evt.name === "down" || evt.name === "j") {
+ evt.preventDefault()
+ setStore("selected", (store.selected + 1) % total)
+ }
+
+ if (evt.name === "return") {
+ evt.preventDefault()
+ if (other()) {
+ setStore("editing", true)
+ } else {
+ const opt = opts[store.selected]
+ if (opt) {
+ pick(opt.label)
+ }
+ }
+ }
+
+ if (evt.name === "escape" || keybind.match("app_exit", evt)) {
+ evt.preventDefault()
+ reject()
+ }
+ }
+ })
+
+ return (
+
+
+
+
+
+ {(q, index) => {
+ const isActive = () => index() === store.tab
+ const isAnswered = () => store.answers[index()] !== undefined
+ return (
+
+
+ {q.header}
+
+
+ )
+ }}
+
+
+ Confirm
+
+
+
+
+
+
+
+ {question()?.question}
+
+
+
+ {(opt, i) => {
+ const active = () => i() === store.selected
+ const picked = () => store.answers[store.tab] === opt.label
+ return (
+
+
+
+
+ {i() + 1}. {opt.label}
+
+
+ {picked() ? "✓" : ""}
+
+
+ {opt.description}
+
+
+ )
+ }}
+
+
+
+
+
+ {options().length + 1}. Other
+
+
+ {input() ? "✓" : ""}
+
+
+
+
+ {input()}
+
+
+
+
+
+
+
+
+ Review
+
+
+ {(q, index) => {
+ const answer = () => store.answers[index()]
+ return (
+
+ {q.header}:
+ {answer() ?? "(not answered)"}
+
+ )
+ }}
+
+
+
+
+
+
+
+ {"⇆"} tab
+
+
+
+
+ {"↑↓"} select
+
+
+
+ enter {confirm() ? "submit" : single() ? "submit" : "confirm"}
+
+
+ esc dismiss
+
+
+
+
+ )
+}
diff --git a/packages/opencode/src/id/id.ts b/packages/opencode/src/id/id.ts
index 7c81c5ed62..db2920b0a4 100644
--- a/packages/opencode/src/id/id.ts
+++ b/packages/opencode/src/id/id.ts
@@ -6,6 +6,7 @@ export namespace Identifier {
session: "ses",
message: "msg",
permission: "per",
+ question: "que",
user: "usr",
part: "prt",
pty: "pty",
diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts
new file mode 100644
index 0000000000..0fc90b40ce
--- /dev/null
+++ b/packages/opencode/src/question/index.ts
@@ -0,0 +1,162 @@
+import { Bus } from "@/bus"
+import { BusEvent } from "@/bus/bus-event"
+import { Identifier } from "@/id/id"
+import { Instance } from "@/project/instance"
+import { Log } from "@/util/log"
+import z from "zod"
+
+export namespace Question {
+ const log = Log.create({ service: "question" })
+
+ export const Option = z
+ .object({
+ label: z.string().describe("Display text (1-5 words, concise)"),
+ description: z.string().describe("Explanation of choice"),
+ })
+ .meta({
+ ref: "QuestionOption",
+ })
+ export type Option = z.infer
+
+ export const Info = z
+ .object({
+ question: z.string().describe("Complete question"),
+ header: z.string().max(12).describe("Very short label (max 12 chars)"),
+ options: z.array(Option).describe("Available choices"),
+ })
+ .meta({
+ ref: "QuestionInfo",
+ })
+ export type Info = z.infer
+
+ export const Request = z
+ .object({
+ id: Identifier.schema("question"),
+ sessionID: Identifier.schema("session"),
+ questions: z.array(Info).describe("Questions to ask"),
+ tool: z
+ .object({
+ messageID: z.string(),
+ callID: z.string(),
+ })
+ .optional(),
+ })
+ .meta({
+ ref: "QuestionRequest",
+ })
+ export type Request = z.infer
+
+ export const Reply = z.object({
+ answers: z.array(z.string()).describe("User answers in order of questions"),
+ })
+ export type Reply = z.infer
+
+ export const Event = {
+ Asked: BusEvent.define("question.asked", Request),
+ Replied: BusEvent.define(
+ "question.replied",
+ z.object({
+ sessionID: z.string(),
+ requestID: z.string(),
+ answers: z.array(z.string()),
+ }),
+ ),
+ Rejected: BusEvent.define(
+ "question.rejected",
+ z.object({
+ sessionID: z.string(),
+ requestID: z.string(),
+ }),
+ ),
+ }
+
+ const state = Instance.state(async () => {
+ const pending: Record<
+ string,
+ {
+ info: Request
+ resolve: (answers: string[]) => void
+ reject: (e: any) => void
+ }
+ > = {}
+
+ return {
+ pending,
+ }
+ })
+
+ export async function ask(input: {
+ sessionID: string
+ questions: Info[]
+ tool?: { messageID: string; callID: string }
+ }): Promise {
+ const s = await state()
+ const id = Identifier.ascending("question")
+
+ log.info("asking", { id, questions: input.questions.length })
+
+ return new Promise((resolve, reject) => {
+ const info: Request = {
+ id,
+ sessionID: input.sessionID,
+ questions: input.questions,
+ tool: input.tool,
+ }
+ s.pending[id] = {
+ info,
+ resolve,
+ reject,
+ }
+ Bus.publish(Event.Asked, info)
+ })
+ }
+
+ export async function reply(input: { requestID: string; answers: string[] }): Promise {
+ const s = await state()
+ const existing = s.pending[input.requestID]
+ if (!existing) {
+ log.warn("reply for unknown request", { requestID: input.requestID })
+ return
+ }
+ delete s.pending[input.requestID]
+
+ log.info("replied", { requestID: input.requestID, answers: input.answers })
+
+ Bus.publish(Event.Replied, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ answers: input.answers,
+ })
+
+ existing.resolve(input.answers)
+ }
+
+ export async function reject(requestID: string): Promise {
+ const s = await state()
+ const existing = s.pending[requestID]
+ if (!existing) {
+ log.warn("reject for unknown request", { requestID })
+ return
+ }
+ delete s.pending[requestID]
+
+ log.info("rejected", { requestID })
+
+ Bus.publish(Event.Rejected, {
+ sessionID: existing.info.sessionID,
+ requestID: existing.info.id,
+ })
+
+ existing.reject(new RejectedError())
+ }
+
+ export class RejectedError extends Error {
+ constructor() {
+ super("The user dismissed this question")
+ }
+ }
+
+ export async function list() {
+ return state().then((x) => Object.values(x.pending).map((x) => x.info))
+ }
+}
diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts
index 615d927286..8422d7d4dd 100644
--- a/packages/opencode/src/server/server.ts
+++ b/packages/opencode/src/server/server.ts
@@ -48,6 +48,7 @@ import { upgradeWebSocket, websocket } from "hono/bun"
import { errors } from "./error"
import { Pty } from "@/pty"
import { PermissionNext } from "@/permission/next"
+import { Question } from "@/question"
import { Installation } from "@/installation"
import { MDNS } from "./mdns"
import { Worktree } from "../worktree"
@@ -1684,6 +1685,93 @@ export namespace Server {
return c.json(permissions)
},
)
+ .get(
+ "/question",
+ describeRoute({
+ summary: "List pending questions",
+ description: "Get all pending question requests across all sessions.",
+ operationId: "question.list",
+ responses: {
+ 200: {
+ description: "List of pending questions",
+ content: {
+ "application/json": {
+ schema: resolver(Question.Request.array()),
+ },
+ },
+ },
+ },
+ }),
+ async (c) => {
+ const questions = await Question.list()
+ return c.json(questions)
+ },
+ )
+ .post(
+ "/question/:requestID/reply",
+ describeRoute({
+ summary: "Reply to question request",
+ description: "Provide answers to a question request from the AI assistant.",
+ operationId: "question.reply",
+ responses: {
+ 200: {
+ description: "Question answered successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ requestID: z.string(),
+ }),
+ ),
+ validator("json", z.object({ answers: z.array(z.string()) })),
+ async (c) => {
+ const params = c.req.valid("param")
+ const json = c.req.valid("json")
+ await Question.reply({
+ requestID: params.requestID,
+ answers: json.answers,
+ })
+ return c.json(true)
+ },
+ )
+ .post(
+ "/question/:requestID/reject",
+ describeRoute({
+ summary: "Reject question request",
+ description: "Reject a question request from the AI assistant.",
+ operationId: "question.reject",
+ responses: {
+ 200: {
+ description: "Question rejected successfully",
+ content: {
+ "application/json": {
+ schema: resolver(z.boolean()),
+ },
+ },
+ },
+ ...errors(400, 404),
+ },
+ }),
+ validator(
+ "param",
+ z.object({
+ requestID: z.string(),
+ }),
+ ),
+ async (c) => {
+ const params = c.req.valid("param")
+ await Question.reject(params.requestID)
+ return c.json(true)
+ },
+ )
.get(
"/command",
describeRoute({
diff --git a/packages/opencode/src/session/processor.ts b/packages/opencode/src/session/processor.ts
index 227ca64bb9..71db7f1367 100644
--- a/packages/opencode/src/session/processor.ts
+++ b/packages/opencode/src/session/processor.ts
@@ -14,6 +14,7 @@ import { LLM } from "./llm"
import { Config } from "@/config/config"
import { SessionCompaction } from "./compaction"
import { PermissionNext } from "@/permission/next"
+import { Question } from "@/question"
export namespace SessionProcessor {
const DOOM_LOOP_THRESHOLD = 3
@@ -208,7 +209,10 @@ export namespace SessionProcessor {
},
})
- if (value.error instanceof PermissionNext.RejectedError) {
+ if (
+ value.error instanceof PermissionNext.RejectedError ||
+ value.error instanceof Question.RejectedError
+ ) {
blocked = shouldBreak
}
delete toolcalls[value.toolCallId]
diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts
new file mode 100644
index 0000000000..5b34875c15
--- /dev/null
+++ b/packages/opencode/src/tool/question.ts
@@ -0,0 +1,28 @@
+import z from "zod"
+import { Tool } from "./tool"
+import { Question } from "../question"
+import DESCRIPTION from "./question.txt"
+
+export const QuestionTool = Tool.define("question", {
+ description: DESCRIPTION,
+ parameters: z.object({
+ questions: z.array(Question.Info).describe("Questions to ask"),
+ }),
+ async execute(params, ctx) {
+ const answers = await Question.ask({
+ sessionID: ctx.sessionID,
+ questions: params.questions,
+ tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
+ })
+
+ const formatted = params.questions.map((q, i) => `"${q.question}"="${answers[i] ?? "Unanswered"}"`).join(", ")
+
+ return {
+ title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
+ output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
+ metadata: {
+ answers,
+ },
+ }
+ },
+})
diff --git a/packages/opencode/src/tool/question.txt b/packages/opencode/src/tool/question.txt
new file mode 100644
index 0000000000..bb5af82756
--- /dev/null
+++ b/packages/opencode/src/tool/question.txt
@@ -0,0 +1,9 @@
+Use this tool when you need to ask the user questions during execution. This allows you to:
+1. Gather user preferences or requirements
+2. Clarify ambiguous instructions
+3. Get decisions on implementation choices as you work
+4. Offer choices to the user about what direction to take.
+
+Usage notes:
+- Users will always be able to select "Other" to provide custom text input
+- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts
index 608edc65eb..15a5c44e11 100644
--- a/packages/opencode/src/tool/registry.ts
+++ b/packages/opencode/src/tool/registry.ts
@@ -1,3 +1,4 @@
+import { QuestionTool } from "./question"
import { BashTool } from "./bash"
import { EditTool } from "./edit"
import { GlobTool } from "./glob"
@@ -92,6 +93,7 @@ export namespace ToolRegistry {
return [
InvalidTool,
+ QuestionTool,
BashTool,
ReadTool,
GlobTool,
diff --git a/packages/opencode/test/question/question.test.ts b/packages/opencode/test/question/question.test.ts
new file mode 100644
index 0000000000..2e4b2d7ab5
--- /dev/null
+++ b/packages/opencode/test/question/question.test.ts
@@ -0,0 +1,300 @@
+import { test, expect } from "bun:test"
+import { Question } from "../../src/question"
+import { Instance } from "../../src/project/instance"
+import { tmpdir } from "../fixture/fixture"
+
+test("ask - returns pending promise", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const promise = Question.ask({
+ sessionID: "ses_test",
+ questions: [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ],
+ })
+ expect(promise).toBeInstanceOf(Promise)
+ },
+ })
+})
+
+test("ask - adds to pending list", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const questions = [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ]
+
+ Question.ask({
+ sessionID: "ses_test",
+ questions,
+ })
+
+ const pending = await Question.list()
+ expect(pending.length).toBe(1)
+ expect(pending[0].questions).toEqual(questions)
+ },
+ })
+})
+
+// reply tests
+
+test("reply - resolves the pending ask with answers", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const questions = [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ]
+
+ const askPromise = Question.ask({
+ sessionID: "ses_test",
+ questions,
+ })
+
+ const pending = await Question.list()
+ const requestID = pending[0].id
+
+ await Question.reply({
+ requestID,
+ answers: ["Option 1"],
+ })
+
+ const answers = await askPromise
+ expect(answers).toEqual(["Option 1"])
+ },
+ })
+})
+
+test("reply - removes from pending list", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ Question.ask({
+ sessionID: "ses_test",
+ questions: [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ],
+ })
+
+ const pending = await Question.list()
+ expect(pending.length).toBe(1)
+
+ await Question.reply({
+ requestID: pending[0].id,
+ answers: ["Option 1"],
+ })
+
+ const pendingAfter = await Question.list()
+ expect(pendingAfter.length).toBe(0)
+ },
+ })
+})
+
+test("reply - does nothing for unknown requestID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Question.reply({
+ requestID: "que_unknown",
+ answers: ["Option 1"],
+ })
+ // Should not throw
+ },
+ })
+})
+
+// reject tests
+
+test("reject - throws RejectedError", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise = Question.ask({
+ sessionID: "ses_test",
+ questions: [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ],
+ })
+
+ const pending = await Question.list()
+ await Question.reject(pending[0].id)
+
+ await expect(askPromise).rejects.toBeInstanceOf(Question.RejectedError)
+ },
+ })
+})
+
+test("reject - removes from pending list", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const askPromise = Question.ask({
+ sessionID: "ses_test",
+ questions: [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Option 1", description: "First option" },
+ { label: "Option 2", description: "Second option" },
+ ],
+ },
+ ],
+ })
+
+ const pending = await Question.list()
+ expect(pending.length).toBe(1)
+
+ await Question.reject(pending[0].id)
+ askPromise.catch(() => {}) // Ignore rejection
+
+ const pendingAfter = await Question.list()
+ expect(pendingAfter.length).toBe(0)
+ },
+ })
+})
+
+test("reject - does nothing for unknown requestID", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ await Question.reject("que_unknown")
+ // Should not throw
+ },
+ })
+})
+
+// multiple questions tests
+
+test("ask - handles multiple questions", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const questions = [
+ {
+ question: "What would you like to do?",
+ header: "Action",
+ options: [
+ { label: "Build", description: "Build the project" },
+ { label: "Test", description: "Run tests" },
+ ],
+ },
+ {
+ question: "Which environment?",
+ header: "Env",
+ options: [
+ { label: "Dev", description: "Development" },
+ { label: "Prod", description: "Production" },
+ ],
+ },
+ ]
+
+ const askPromise = Question.ask({
+ sessionID: "ses_test",
+ questions,
+ })
+
+ const pending = await Question.list()
+
+ await Question.reply({
+ requestID: pending[0].id,
+ answers: ["Build", "Dev"],
+ })
+
+ const answers = await askPromise
+ expect(answers).toEqual(["Build", "Dev"])
+ },
+ })
+})
+
+// list tests
+
+test("list - returns all pending requests", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ Question.ask({
+ sessionID: "ses_test1",
+ questions: [
+ {
+ question: "Question 1?",
+ header: "Q1",
+ options: [{ label: "A", description: "A" }],
+ },
+ ],
+ })
+
+ Question.ask({
+ sessionID: "ses_test2",
+ questions: [
+ {
+ question: "Question 2?",
+ header: "Q2",
+ options: [{ label: "B", description: "B" }],
+ },
+ ],
+ })
+
+ const pending = await Question.list()
+ expect(pending.length).toBe(2)
+ },
+ })
+})
+
+test("list - returns empty when no pending", async () => {
+ await using tmp = await tmpdir({ git: true })
+ await Instance.provide({
+ directory: tmp.path,
+ fn: async () => {
+ const pending = await Question.list()
+ expect(pending.length).toBe(0)
+ },
+ })
+})
diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts
index a26cefb176..dae865a7cf 100644
--- a/packages/sdk/js/src/v2/gen/sdk.gen.ts
+++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts
@@ -84,6 +84,11 @@ import type {
PtyRemoveResponses,
PtyUpdateErrors,
PtyUpdateResponses,
+ QuestionListResponses,
+ QuestionRejectErrors,
+ QuestionRejectResponses,
+ QuestionReplyErrors,
+ QuestionReplyResponses,
SessionAbortErrors,
SessionAbortResponses,
SessionChildrenErrors,
@@ -1781,6 +1786,94 @@ export class Permission extends HeyApiClient {
}
}
+export class Question extends HeyApiClient {
+ /**
+ * List pending questions
+ *
+ * Get all pending question requests across all sessions.
+ */
+ public list(
+ parameters?: {
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams([parameters], [{ args: [{ in: "query", key: "directory" }] }])
+ return (options?.client ?? this.client).get({
+ url: "/question",
+ ...options,
+ ...params,
+ })
+ }
+
+ /**
+ * Reply to question request
+ *
+ * Provide answers to a question request from the AI assistant.
+ */
+ public reply(
+ parameters: {
+ requestID: string
+ directory?: string
+ answers?: Array
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "requestID" },
+ { in: "query", key: "directory" },
+ { in: "body", key: "answers" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/question/{requestID}/reply",
+ ...options,
+ ...params,
+ headers: {
+ "Content-Type": "application/json",
+ ...options?.headers,
+ ...params.headers,
+ },
+ })
+ }
+
+ /**
+ * Reject question request
+ *
+ * Reject a question request from the AI assistant.
+ */
+ public reject(
+ parameters: {
+ requestID: string
+ directory?: string
+ },
+ options?: Options,
+ ) {
+ const params = buildClientParams(
+ [parameters],
+ [
+ {
+ args: [
+ { in: "path", key: "requestID" },
+ { in: "query", key: "directory" },
+ ],
+ },
+ ],
+ )
+ return (options?.client ?? this.client).post({
+ url: "/question/{requestID}/reject",
+ ...options,
+ ...params,
+ })
+ }
+}
+
export class Command extends HeyApiClient {
/**
* List commands
@@ -2912,6 +3005,8 @@ export class OpencodeClient extends HeyApiClient {
permission = new Permission({ client: this.client })
+ question = new Question({ client: this.client })
+
command = new Command({ client: this.client })
provider = new Provider({ client: this.client })
diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts
index 97a695162e..d42e7d176a 100644
--- a/packages/sdk/js/src/v2/gen/types.gen.ts
+++ b/packages/sdk/js/src/v2/gen/types.gen.ts
@@ -524,6 +524,67 @@ export type EventSessionCompacted = {
}
}
+export type QuestionOption = {
+ /**
+ * Display text (1-5 words, concise)
+ */
+ label: string
+ /**
+ * Explanation of choice
+ */
+ description: string
+}
+
+export type QuestionInfo = {
+ /**
+ * Complete question
+ */
+ question: string
+ /**
+ * Very short label (max 12 chars)
+ */
+ header: string
+ /**
+ * Available choices
+ */
+ options: Array
+}
+
+export type QuestionRequest = {
+ id: string
+ sessionID: string
+ /**
+ * Questions to ask
+ */
+ questions: Array
+ tool?: {
+ messageID: string
+ callID: string
+ }
+}
+
+export type EventQuestionAsked = {
+ type: "question.asked"
+ properties: QuestionRequest
+}
+
+export type EventQuestionReplied = {
+ type: "question.replied"
+ properties: {
+ sessionID: string
+ requestID: string
+ answers: Array
+ }
+}
+
+export type EventQuestionRejected = {
+ type: "question.rejected"
+ properties: {
+ sessionID: string
+ requestID: string
+ }
+}
+
export type EventFileEdited = {
type: "file.edited"
properties: {
@@ -789,6 +850,9 @@ export type Event =
| EventSessionStatus
| EventSessionIdle
| EventSessionCompacted
+ | EventQuestionAsked
+ | EventQuestionReplied
+ | EventQuestionRejected
| EventFileEdited
| EventTodoUpdated
| EventTuiPromptAppend
@@ -3545,6 +3609,92 @@ export type PermissionListResponses = {
export type PermissionListResponse = PermissionListResponses[keyof PermissionListResponses]
+export type QuestionListData = {
+ body?: never
+ path?: never
+ query?: {
+ directory?: string
+ }
+ url: "/question"
+}
+
+export type QuestionListResponses = {
+ /**
+ * List of pending questions
+ */
+ 200: Array
+}
+
+export type QuestionListResponse = QuestionListResponses[keyof QuestionListResponses]
+
+export type QuestionReplyData = {
+ body?: {
+ answers: Array
+ }
+ path: {
+ requestID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/question/{requestID}/reply"
+}
+
+export type QuestionReplyErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type QuestionReplyError = QuestionReplyErrors[keyof QuestionReplyErrors]
+
+export type QuestionReplyResponses = {
+ /**
+ * Question answered successfully
+ */
+ 200: boolean
+}
+
+export type QuestionReplyResponse = QuestionReplyResponses[keyof QuestionReplyResponses]
+
+export type QuestionRejectData = {
+ body?: never
+ path: {
+ requestID: string
+ }
+ query?: {
+ directory?: string
+ }
+ url: "/question/{requestID}/reject"
+}
+
+export type QuestionRejectErrors = {
+ /**
+ * Bad request
+ */
+ 400: BadRequestError
+ /**
+ * Not found
+ */
+ 404: NotFoundError
+}
+
+export type QuestionRejectError = QuestionRejectErrors[keyof QuestionRejectErrors]
+
+export type QuestionRejectResponses = {
+ /**
+ * Question rejected successfully
+ */
+ 200: boolean
+}
+
+export type QuestionRejectResponse = QuestionRejectResponses[keyof QuestionRejectResponses]
+
export type CommandListData = {
body?: never
path?: never