Skip to content

Commit a49442b

Browse files
author
Haider
committed
fix: auto-resolve question tool in non-interactive contexts
`Question.ask()` awaits an Effect Deferred that only resolves on a TUI click. When `altimate-code run` is invoked as a subprocess (Claude Code's Bash tool, CI, plugin host) and a skill that uses `question` fires, nobody can ever click — the deferred awaits forever and the parent eventually TaskStops the subprocess. The symptom is indistinguishable from a hang: 0% CPU, no log activity, no error. In non-interactive contexts (no TTY, or explicit env-var opt-in), auto-resolve `question` with a conservative-by-default policy and flag the auto-answer in the tool result so the calling LLM can adapt instead of treating it as a real user choice. Resolution policy (env-var controlled): - Detect non-interactive: `!process.stdin.isTTY`. Overrides: ALTIMATE_FORCE_INTERACTIVE=1 — keep the original interactive Deferred path even when isTTY is false. ALTIMATE_NON_INTERACTIVE=1 — force non-interactive even when isTTY is true (useful for tests + CI assertions). - Default in non-TTY (ALTIMATE_AUTO_ANSWER=last): pick the option whose label/description contains a safe keyword (skip, cancel, no, abort, profile only, decline, deny, stop); fall back to the last option (UX convention: safer/cancel typically sits at end). - ALTIMATE_AUTO_ANSWER=first / =skip / =<exact label>: explicit overrides for callers who want a specific behavior. Tool result prefix reflects mode — "Running in non-interactive mode (no TTY). Auto-answered with safe defaults: ..." vs the original "User has answered your questions: ..." — so the agent knows the choice was not a real user answer. Tests: 6 new bun:test cases covering safe-keyword selection, last-option fallback, each ALTIMATE_AUTO_ANSWER mode, and the prefix wording. Existing 2 legacy tests gated with ALTIMATE_FORCE_INTERACTIVE=1 so they preserve their original intent under non-TTY CI. Closes #936
1 parent 146acea commit a49442b

2 files changed

Lines changed: 198 additions & 6 deletions

File tree

packages/opencode/src/tool/question.ts

Lines changed: 75 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,17 +3,78 @@ import { Tool } from "./tool"
33
import { Question } from "../question"
44
import DESCRIPTION from "./question.txt"
55

6+
// altimate_change start — non-interactive auto-answer support.
7+
// When running under `claude --print`, CI, or any other context without a TTY,
8+
// there is nobody to click an option in the TUI. The default Question.ask()
9+
// behaviour is to await a Deferred indefinitely, which causes the parent
10+
// process to TaskStop the subprocess after a long wait — looking exactly like
11+
// a hang. See deliverable 02 (Run F first sub-session) for the trace.
12+
//
13+
// Resolution policy: in non-interactive mode, pick the option whose label
14+
// contains a "safe" keyword (skip / cancel / profile only / no / abort).
15+
// If no such option exists, pick the LAST option (UX convention: safer/cancel
16+
// usually sits at the end). The agent then sees a concrete answer in the
17+
// tool result and can continue without blocking. Override via env var:
18+
// ALTIMATE_AUTO_ANSWER=first — always pick first option
19+
// ALTIMATE_AUTO_ANSWER=last — always pick last option (default)
20+
// ALTIMATE_AUTO_ANSWER=skip — return Unanswered for all questions
21+
const SAFE_KEYWORDS = [
22+
"skip",
23+
"cancel",
24+
"no",
25+
"abort",
26+
"profile only",
27+
"profile-only",
28+
"decline",
29+
"deny",
30+
"stop",
31+
]
32+
33+
function isNonInteractive(): boolean {
34+
if (process.env["ALTIMATE_FORCE_INTERACTIVE"] === "1") return false
35+
if (process.env["ALTIMATE_NON_INTERACTIVE"] === "1") return true
36+
return !process.stdin.isTTY
37+
}
38+
39+
function autoAnswer(questions: Question.Info[]): Question.Answer[] {
40+
const mode = (process.env["ALTIMATE_AUTO_ANSWER"] ?? "last").toLowerCase()
41+
return questions.map((q) => {
42+
if (mode === "skip") return []
43+
if (mode === "first") return q.options[0] ? [q.options[0].label] : []
44+
if (mode === "last") {
45+
const safe = q.options.find((o) => {
46+
const text = `${o.label} ${o.description}`.toLowerCase()
47+
return SAFE_KEYWORDS.some((k) => text.includes(k))
48+
})
49+
if (safe) return [safe.label]
50+
const last = q.options[q.options.length - 1]
51+
return last ? [last.label] : []
52+
}
53+
// exact label match for explicit answers, e.g. ALTIMATE_AUTO_ANSWER="Profile only"
54+
const match = q.options.find((o) => o.label.toLowerCase() === mode)
55+
return match ? [match.label] : []
56+
})
57+
}
58+
// altimate_change end
59+
660
export const QuestionTool = Tool.define("question", {
761
description: DESCRIPTION,
862
parameters: z.object({
963
questions: z.array(Question.Info.omit({ custom: true })).describe("Questions to ask"),
1064
}),
1165
async execute(params, ctx) {
12-
const answers = await Question.ask({
13-
sessionID: ctx.sessionID,
14-
questions: params.questions,
15-
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
16-
})
66+
// altimate_change start — short-circuit when no human is listening.
67+
let answers: Question.Answer[]
68+
if (isNonInteractive()) {
69+
answers = autoAnswer(params.questions)
70+
} else {
71+
answers = await Question.ask({
72+
sessionID: ctx.sessionID,
73+
questions: params.questions,
74+
tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined,
75+
})
76+
}
77+
// altimate_change end
1778

1879
function format(answer: Question.Answer | undefined) {
1980
if (!answer?.length) return "Unanswered"
@@ -22,9 +83,17 @@ export const QuestionTool = Tool.define("question", {
2283

2384
const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ")
2485

86+
// altimate_change start — flag auto-answers explicitly so the agent
87+
// knows the user didn't actually answer and can decide whether to
88+
// proceed with that choice or fail back gracefully.
89+
const prefix = isNonInteractive()
90+
? `Running in non-interactive mode (no TTY). Auto-answered with safe defaults: `
91+
: `User has answered your questions: `
92+
// altimate_change end
93+
2594
return {
2695
title: `Asked ${params.questions.length} question${params.questions.length > 1 ? "s" : ""}`,
27-
output: `User has answered your questions: ${formatted}. You can now continue with the user's answers in mind.`,
96+
output: `${prefix}${formatted}. You can now continue with the user's answers in mind.`,
2897
metadata: {
2998
answers,
3099
},

packages/opencode/test/tool/question.test.ts

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,18 @@ describe("tool.question", () => {
1919
let askSpy: any
2020

2121
beforeEach(() => {
22+
// Force the original interactive path for the legacy tests below — the
23+
// test environment is non-TTY (bun:test runs without a terminal), so
24+
// without this override the non-interactive auto-answer branch would
25+
// short-circuit `Question.ask` and the existing spies would never fire.
26+
process.env["ALTIMATE_FORCE_INTERACTIVE"] = "1"
2227
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => {
2328
return []
2429
})
2530
})
2631

2732
afterEach(() => {
33+
delete process.env["ALTIMATE_FORCE_INTERACTIVE"]
2834
askSpy.mockRestore()
2935
})
3036

@@ -106,3 +112,120 @@ describe("tool.question", () => {
106112
// }
107113
// })
108114
})
115+
116+
describe("tool.question non-interactive auto-answer", () => {
117+
let askSpy: any
118+
119+
beforeEach(() => {
120+
process.env["ALTIMATE_NON_INTERACTIVE"] = "1"
121+
askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => [])
122+
})
123+
124+
afterEach(() => {
125+
delete process.env["ALTIMATE_NON_INTERACTIVE"]
126+
delete process.env["ALTIMATE_AUTO_ANSWER"]
127+
askSpy.mockRestore()
128+
})
129+
130+
test("picks safe-keyword option when present and does not invoke Question.ask", async () => {
131+
const tool = await QuestionTool.init()
132+
const questions = [
133+
{
134+
question: "May I run row-level hashdiff comparisons?",
135+
header: "PII consent",
136+
options: [
137+
{ label: "Approve row diff", description: "Sample rows may appear" },
138+
{ label: "Profile only", description: "Safer; no row content surfaced" },
139+
],
140+
},
141+
]
142+
143+
const result = await tool.execute({ questions }, ctx)
144+
expect(askSpy).not.toHaveBeenCalled()
145+
expect(result.output).toContain("Profile only")
146+
expect(result.output).toContain("non-interactive mode")
147+
})
148+
149+
test("falls back to last option when no safe keyword matches", async () => {
150+
const tool = await QuestionTool.init()
151+
const questions = [
152+
{
153+
question: "Pick a color",
154+
header: "Color",
155+
options: [
156+
{ label: "Red", description: "The color of passion" },
157+
{ label: "Blue", description: "The color of sky" },
158+
],
159+
},
160+
]
161+
162+
const result = await tool.execute({ questions }, ctx)
163+
expect(askSpy).not.toHaveBeenCalled()
164+
expect(result.output).toContain("Blue")
165+
})
166+
167+
test("ALTIMATE_AUTO_ANSWER=first picks first option", async () => {
168+
process.env["ALTIMATE_AUTO_ANSWER"] = "first"
169+
const tool = await QuestionTool.init()
170+
const questions = [
171+
{
172+
question: "Pick a color",
173+
header: "Color",
174+
options: [
175+
{ label: "Red", description: "" },
176+
{ label: "Blue", description: "" },
177+
],
178+
},
179+
]
180+
181+
const result = await tool.execute({ questions }, ctx)
182+
expect(result.output).toContain("Red")
183+
})
184+
185+
test("ALTIMATE_AUTO_ANSWER=skip returns Unanswered for each question", async () => {
186+
process.env["ALTIMATE_AUTO_ANSWER"] = "skip"
187+
const tool = await QuestionTool.init()
188+
const questions = [
189+
{
190+
question: "Pick a color",
191+
header: "Color",
192+
options: [{ label: "Red", description: "" }],
193+
},
194+
]
195+
196+
const result = await tool.execute({ questions }, ctx)
197+
expect(result.output).toContain("Unanswered")
198+
})
199+
200+
test("ALTIMATE_AUTO_ANSWER=<exact label> picks matching option", async () => {
201+
process.env["ALTIMATE_AUTO_ANSWER"] = "blue"
202+
const tool = await QuestionTool.init()
203+
const questions = [
204+
{
205+
question: "Pick a color",
206+
header: "Color",
207+
options: [
208+
{ label: "Red", description: "" },
209+
{ label: "Blue", description: "" },
210+
],
211+
},
212+
]
213+
214+
const result = await tool.execute({ questions }, ctx)
215+
expect(result.output).toContain("Blue")
216+
})
217+
218+
test("non-interactive prefix is set when Question.ask is bypassed", async () => {
219+
const tool = await QuestionTool.init()
220+
const questions = [
221+
{
222+
question: "OK to proceed?",
223+
header: "Proceed",
224+
options: [{ label: "Cancel", description: "Stop" }],
225+
},
226+
]
227+
228+
const result = await tool.execute({ questions }, ctx)
229+
expect(result.output.startsWith("Running in non-interactive mode")).toBe(true)
230+
})
231+
})

0 commit comments

Comments
 (0)