Skip to content

Commit f99b84a

Browse files
committed
feat(opencode): fix nested agent questions and improve question tool UX
- Fix questions from nested sub-agents not surfacing to root session (BFS traversal) - Add 'from' field to identify which agent is asking - Improve answer display with bordered table layout - Overhaul multi-select: checkbox prefixes, space to toggle, enter to confirm - Add OPENCODE_TEST_SKIP_GIT env var for test fixtures
1 parent f882cca commit f99b84a

10 files changed

Lines changed: 269 additions & 38 deletions

File tree

CONTRIBUTING.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,34 @@ This runs `bun run --cwd packages/desktop build` automatically via Tauri’s `be
115115
116116
Please try to follow the [style guide](./STYLE_GUIDE.md)
117117

118+
### Running Tests
119+
120+
Run the test suite from the `packages/opencode` directory:
121+
122+
```bash
123+
bun test
124+
```
125+
126+
To run a specific test file:
127+
128+
```bash
129+
bun test test/tool/tool.test.ts
130+
```
131+
132+
#### Test Environment Variables
133+
134+
Environment variables prefixed with `OPENCODE_TEST_` can be used to alter test behavior. When tests start, any such variables are printed to the console for visibility.
135+
136+
| Variable | Description |
137+
| ------------------------ | -------------------------------------------------------------------------------------------- |
138+
| `OPENCODE_TEST_SKIP_GIT` | Skip git repository initialization in test fixtures (useful when commit signing is required) |
139+
140+
Example:
141+
142+
```bash
143+
OPENCODE_TEST_SKIP_GIT=1 bun test
144+
```
145+
118146
### Setting up a Debugger
119147

120148
Bun debugging is currently rough around the edges. We hope this guide helps you get set up and avoid some pain points.

packages/opencode/src/cli/cmd/tui/routes/session/index.tsx

Lines changed: 97 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,15 @@ import {
2727
RGBA,
2828
} from "@opentui/core"
2929
import { Prompt, type PromptRef } from "@tui/component/prompt"
30-
import type { AssistantMessage, Part, ToolPart, UserMessage, TextPart, ReasoningPart } from "@opencode-ai/sdk/v2"
30+
import type {
31+
AssistantMessage,
32+
Part,
33+
ToolPart,
34+
UserMessage,
35+
TextPart,
36+
ReasoningPart,
37+
Session as SessionType,
38+
} from "@opencode-ai/sdk/v2"
3139
import { useLocal } from "@tui/context/local"
3240
import { Locale } from "@/util/locale"
3341
import type { Tool } from "@/tool/tool"
@@ -111,6 +119,31 @@ export function Session() {
111119
const { theme } = useTheme()
112120
const promptRef = usePromptRef()
113121
const session = createMemo(() => sync.session.get(route.sessionID))
122+
const descendants = createMemo(() => {
123+
const rootID = session()?.parentID ?? session()?.id
124+
if (!rootID) return []
125+
126+
const result: SessionType[] = []
127+
const visited = new Set<string>()
128+
const queue = [rootID]
129+
130+
while (queue.length > 0) {
131+
const currentID = queue.shift()!
132+
if (visited.has(currentID)) continue
133+
visited.add(currentID)
134+
135+
const current = sync.data.session.find((x) => x.id === currentID)
136+
if (current) result.push(current)
137+
138+
for (const s of sync.data.session) {
139+
if (s.parentID === currentID && !visited.has(s.id)) {
140+
queue.push(s.id)
141+
}
142+
}
143+
}
144+
145+
return result.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
146+
})
114147
const children = createMemo(() => {
115148
const parentID = session()?.parentID ?? session()?.id
116149
return sync.data.session
@@ -120,11 +153,13 @@ export function Session() {
120153
const messages = createMemo(() => sync.data.message[route.sessionID] ?? [])
121154
const permissions = createMemo(() => {
122155
if (session()?.parentID) return []
123-
return children().flatMap((x) => sync.data.permission[x.id] ?? [])
156+
return descendants().flatMap((x) => sync.data.permission[x.id] ?? [])
124157
})
125158
const questions = createMemo(() => {
126159
if (session()?.parentID) return []
127-
return children().flatMap((x) => sync.data.question[x.id] ?? [])
160+
return descendants()
161+
.flatMap((x) => sync.data.question[x.id] ?? [])
162+
.toSorted((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
128163
})
129164

130165
const pending = createMemo(() => {
@@ -1841,26 +1876,68 @@ function Question(props: ToolProps<typeof QuestionTool>) {
18411876
const { theme } = useTheme()
18421877
const count = createMemo(() => props.input.questions?.length ?? 0)
18431878

1844-
function format(answer?: string[]) {
1845-
if (!answer?.length) return "(no answer)"
1846-
return answer.join(", ")
1847-
}
1848-
18491879
return (
18501880
<Switch>
18511881
<Match when={props.metadata.answers}>
1852-
<BlockTool title="# Questions" part={props.part}>
1853-
<box>
1854-
<For each={props.input.questions ?? []}>
1855-
{(q, i) => (
1856-
<box flexDirection="row" gap={1}>
1857-
<text fg={theme.textMuted}>{q.question}</text>
1858-
<text fg={theme.text}>{format(props.metadata.answers?.[i()])}</text>
1859-
</box>
1860-
)}
1861-
</For>
1862-
</box>
1863-
</BlockTool>
1882+
{(() => {
1883+
const ctx = use()
1884+
const allAnswers = () => (props.input.questions ?? []).map((_, idx) => props.metadata.answers?.[idx] ?? [])
1885+
const maxAnswerLen = () => Math.max(20, ...allAnswers().flatMap((answers) => answers.map((a) => a.length)))
1886+
const tableWidth = () => ctx.width - 6
1887+
const halfWidth = () => Math.floor(tableWidth() * 0.5)
1888+
const answerWidth = () => Math.max(20, Math.min(maxAnswerLen() + 2, halfWidth()))
1889+
return (
1890+
<BlockTool title="# Questions" part={props.part}>
1891+
<box width={tableWidth()} border={["top", "left", "right", "bottom"]} borderColor={theme.border}>
1892+
<For each={props.input.questions ?? []}>
1893+
{(q, i) => {
1894+
const answers = () => props.metadata.answers?.[i()] ?? []
1895+
const isLast = () => i() === (props.input.questions?.length ?? 0) - 1
1896+
const isMulti = () => answers().length > 1
1897+
return (
1898+
<box
1899+
flexDirection="row"
1900+
width="100%"
1901+
border={isLast() ? [] : ["bottom"]}
1902+
borderColor={theme.border}
1903+
>
1904+
<box
1905+
flexGrow={1}
1906+
paddingLeft={1}
1907+
paddingRight={1}
1908+
border={["right"]}
1909+
borderColor={theme.border}
1910+
>
1911+
<text fg={theme.textMuted} wrapMode="word">
1912+
{q.question}
1913+
</text>
1914+
</box>
1915+
<box width={answerWidth()} paddingLeft={1} paddingRight={1} gap={isMulti() ? 1 : 0}>
1916+
<Show
1917+
when={answers().length > 0}
1918+
fallback={
1919+
<text fg={theme.textMuted} wrapMode="word">
1920+
(no answer)
1921+
</text>
1922+
}
1923+
>
1924+
<For each={answers()}>
1925+
{(answer) => (
1926+
<text fg={theme.text} wrapMode="word">
1927+
{answer}
1928+
</text>
1929+
)}
1930+
</For>
1931+
</Show>
1932+
</box>
1933+
</box>
1934+
)
1935+
}}
1936+
</For>
1937+
</box>
1938+
</BlockTool>
1939+
)
1940+
})()}
18641941
</Match>
18651942
<Match when={true}>
18661943
<InlineTool icon="→" pending="Asking questions..." complete={count()} part={props.part}>

packages/opencode/src/cli/cmd/tui/routes/session/question.tsx

Lines changed: 58 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { createStore } from "solid-js/store"
2-
import { createMemo, For, Show } from "solid-js"
2+
import { createEffect, createMemo, For, on, Show } from "solid-js"
33
import { useKeyboard } from "@opentui/solid"
44
import type { TextareaRenderable } from "@opentui/core"
55
import { useKeybind } from "../../context/keybind"
@@ -41,6 +41,20 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
4141
return store.answers[store.tab]?.includes(value) ?? false
4242
})
4343

44+
createEffect(
45+
on(
46+
() => props.request.id,
47+
() => {
48+
setStore("tab", 0)
49+
setStore("answers", [])
50+
setStore("custom", [])
51+
setStore("selected", 0)
52+
setStore("editing", false)
53+
},
54+
{ defer: true },
55+
),
56+
)
57+
4458
function submit() {
4559
const answers = questions().map((_, i) => store.answers[i] ?? [])
4660
sdk.client.question.reply({
@@ -184,13 +198,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
184198
setStore("selected", (store.selected + 1) % total)
185199
}
186200

187-
if (evt.name === "return") {
201+
if (evt.name === "space" && multi()) {
188202
evt.preventDefault()
189203
if (other()) {
190-
if (!multi()) {
191-
setStore("editing", true)
192-
return
193-
}
194204
const value = input()
195205
if (value && customPicked()) {
196206
toggle(value)
@@ -201,10 +211,29 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
201211
}
202212
const opt = opts[store.selected]
203213
if (!opt) return
214+
toggle(opt.label)
215+
return
216+
}
217+
218+
if (evt.name === "return") {
219+
evt.preventDefault()
204220
if (multi()) {
205-
toggle(opt.label)
221+
const hasSelections = (store.answers[store.tab]?.length ?? 0) > 0
222+
if (!hasSelections) return
223+
if (single()) {
224+
submit()
225+
return
226+
}
227+
setStore("tab", store.tab + 1)
228+
setStore("selected", 0)
206229
return
207230
}
231+
if (other()) {
232+
setStore("editing", true)
233+
return
234+
}
235+
const opt = opts[store.selected]
236+
if (!opt) return
208237
pick(opt.label)
209238
}
210239

@@ -223,6 +252,13 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
223252
customBorderChars={SplitBorder.customBorderChars}
224253
>
225254
<box gap={1} paddingLeft={1} paddingRight={3} paddingTop={1} paddingBottom={1}>
255+
<Show when={props.request.from}>
256+
<box paddingLeft={1}>
257+
<text fg={theme.textMuted}>
258+
Question from <span style={{ fg: theme.accent }}>{props.request.from}</span>
259+
</text>
260+
</box>
261+
</Show>
226262
<Show when={!single()}>
227263
<box flexDirection="row" gap={1} paddingLeft={1}>
228264
<For each={questions()}>
@@ -253,25 +289,22 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
253289
<Show when={!confirm()}>
254290
<box paddingLeft={1} gap={1}>
255291
<box>
256-
<text fg={theme.text}>
257-
{question()?.question}
258-
{multi() ? " (select all that apply)" : ""}
259-
</text>
292+
<text fg={theme.text}>{question()?.question}</text>
260293
</box>
261294
<box>
262295
<For each={options()}>
263296
{(opt, i) => {
264297
const active = () => i() === store.selected
265298
const picked = () => store.answers[store.tab]?.includes(opt.label) ?? false
299+
const checkbox = () => (multi() ? (picked() ? "■" : "☐") : "")
266300
return (
267301
<box>
268302
<box flexDirection="row" gap={1}>
269303
<box backgroundColor={active() ? theme.backgroundElement : undefined}>
270304
<text fg={active() ? theme.secondary : picked() ? theme.success : theme.text}>
271-
{i() + 1}. {opt.label}
305+
{multi() ? checkbox() : `${i() + 1}.`} {opt.label}
272306
</text>
273307
</box>
274-
<text fg={theme.success}>{picked() ? "✓" : ""}</text>
275308
</box>
276309
<box paddingLeft={3}>
277310
<text fg={theme.textMuted}>{opt.description}</text>
@@ -284,10 +317,9 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
284317
<box flexDirection="row" gap={1}>
285318
<box backgroundColor={other() ? theme.backgroundElement : undefined}>
286319
<text fg={other() ? theme.secondary : customPicked() ? theme.success : theme.text}>
287-
{options().length + 1}. Type your own answer
320+
{multi() ? (customPicked() ? "■" : "☐") : `${options().length + 1}.`} Type your own answer
288321
</text>
289322
</box>
290-
<text fg={theme.success}>{customPicked() ? "✓" : ""}</text>
291323
</box>
292324
<Show when={store.editing}>
293325
<box paddingLeft={3}>
@@ -341,6 +373,11 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
341373
justifyContent="space-between"
342374
>
343375
<box flexDirection="row" gap={2}>
376+
<Show when={!confirm()}>
377+
<box paddingLeft={1} paddingRight={1} backgroundColor={theme.backgroundElement}>
378+
<text fg={theme.textMuted}>{multi() ? "Multiple" : "Single"}</text>
379+
</box>
380+
</Show>
344381
<Show when={!single()}>
345382
<text fg={theme.text}>
346383
{"⇆"} <span style={{ fg: theme.textMuted }}>tab</span>
@@ -351,10 +388,15 @@ export function QuestionPrompt(props: { request: QuestionRequest }) {
351388
{"↑↓"} <span style={{ fg: theme.textMuted }}>select</span>
352389
</text>
353390
</Show>
391+
<Show when={!confirm() && multi()}>
392+
<text fg={theme.text}>
393+
space <span style={{ fg: theme.textMuted }}>toggle</span>
394+
</text>
395+
</Show>
354396
<text fg={theme.text}>
355397
enter{" "}
356398
<span style={{ fg: theme.textMuted }}>
357-
{confirm() ? "submit" : multi() ? "toggle" : single() ? "submit" : "confirm"}
399+
{confirm() ? "submit" : multi() ? "confirm" : single() ? "submit" : "confirm"}
358400
</span>
359401
</text>
360402

packages/opencode/src/question/index.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ export namespace Question {
3535
id: Identifier.schema("question"),
3636
sessionID: Identifier.schema("session"),
3737
questions: z.array(Info).describe("Questions to ask"),
38+
from: z.string().optional().describe("Identifier of who is asking the question (e.g. agent name)"),
3839
tool: z
3940
.object({
4041
messageID: z.string(),
@@ -96,18 +97,20 @@ export namespace Question {
9697
export async function ask(input: {
9798
sessionID: string
9899
questions: Info[]
100+
from?: string
99101
tool?: { messageID: string; callID: string }
100102
}): Promise<Answer[]> {
101103
const s = await state()
102104
const id = Identifier.ascending("question")
103105

104-
log.info("asking", { id, questions: input.questions.length })
106+
log.info("asking", { id, questions: input.questions.length, from: input.from })
105107

106108
return new Promise<Answer[]>((resolve, reject) => {
107109
const info: Request = {
108110
id,
109111
sessionID: input.sessionID,
110112
questions: input.questions,
113+
from: input.from,
111114
tool: input.tool,
112115
}
113116
s.pending[id] = {

packages/opencode/src/tool/question.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,18 @@ export const QuestionTool = Tool.define("question", {
77
description: DESCRIPTION,
88
parameters: z.object({
99
questions: z.array(Question.Info).describe("Questions to ask"),
10+
from: z
11+
.string()
12+
.optional()
13+
.describe(
14+
"Identifier of who is asking the question (e.g. agent name). Used to inform the user which agent is asking.",
15+
),
1016
}),
1117
async execute(params, ctx) {
1218
const answers = await Question.ask({
1319
sessionID: ctx.sessionID,
1420
questions: params.questions,
21+
from: params.from,
1522
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
1623
})
1724

packages/opencode/src/tool/question.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ Usage notes:
88
- Users will always be able to select "Other" to provide custom text input
99
- Answers are returned as arrays of labels; set `multiple: true` to allow selecting more than one
1010
- If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
11+
- Use the optional `from` parameter to identify who is asking the question (e.g. your agent name). This helps the user understand which agent is requesting input.

0 commit comments

Comments
 (0)