From a49442bf94659a2df3db6e4b5104b02ab56c9d2f Mon Sep 17 00:00:00 2001 From: Haider Date: Fri, 12 Jun 2026 03:43:52 +0530 Subject: [PATCH 1/2] fix: auto-resolve question tool in non-interactive contexts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 / =: 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 --- packages/opencode/src/tool/question.ts | 81 +++++++++++- packages/opencode/test/tool/question.test.ts | 123 +++++++++++++++++++ 2 files changed, 198 insertions(+), 6 deletions(-) diff --git a/packages/opencode/src/tool/question.ts b/packages/opencode/src/tool/question.ts index a2887546d4..f3f9d749c9 100644 --- a/packages/opencode/src/tool/question.ts +++ b/packages/opencode/src/tool/question.ts @@ -3,17 +3,78 @@ import { Tool } from "./tool" import { Question } from "../question" import DESCRIPTION from "./question.txt" +// altimate_change start — non-interactive auto-answer support. +// When running under `claude --print`, CI, or any other context without a TTY, +// there is nobody to click an option in the TUI. The default Question.ask() +// behaviour is to await a Deferred indefinitely, which causes the parent +// process to TaskStop the subprocess after a long wait — looking exactly like +// a hang. See deliverable 02 (Run F first sub-session) for the trace. +// +// Resolution policy: in non-interactive mode, pick the option whose label +// contains a "safe" keyword (skip / cancel / profile only / no / abort). +// If no such option exists, pick the LAST option (UX convention: safer/cancel +// usually sits at the end). The agent then sees a concrete answer in the +// tool result and can continue without blocking. Override via env var: +// ALTIMATE_AUTO_ANSWER=first — always pick first option +// ALTIMATE_AUTO_ANSWER=last — always pick last option (default) +// ALTIMATE_AUTO_ANSWER=skip — return Unanswered for all questions +const SAFE_KEYWORDS = [ + "skip", + "cancel", + "no", + "abort", + "profile only", + "profile-only", + "decline", + "deny", + "stop", +] + +function isNonInteractive(): boolean { + if (process.env["ALTIMATE_FORCE_INTERACTIVE"] === "1") return false + if (process.env["ALTIMATE_NON_INTERACTIVE"] === "1") return true + return !process.stdin.isTTY +} + +function autoAnswer(questions: Question.Info[]): Question.Answer[] { + const mode = (process.env["ALTIMATE_AUTO_ANSWER"] ?? "last").toLowerCase() + return questions.map((q) => { + if (mode === "skip") return [] + if (mode === "first") return q.options[0] ? [q.options[0].label] : [] + if (mode === "last") { + const safe = q.options.find((o) => { + const text = `${o.label} ${o.description}`.toLowerCase() + return SAFE_KEYWORDS.some((k) => text.includes(k)) + }) + if (safe) return [safe.label] + const last = q.options[q.options.length - 1] + return last ? [last.label] : [] + } + // exact label match for explicit answers, e.g. ALTIMATE_AUTO_ANSWER="Profile only" + const match = q.options.find((o) => o.label.toLowerCase() === mode) + return match ? [match.label] : [] + }) +} +// altimate_change end + export const QuestionTool = Tool.define("question", { description: DESCRIPTION, parameters: z.object({ questions: z.array(Question.Info.omit({ custom: true })).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, - }) + // altimate_change start — short-circuit when no human is listening. + let answers: Question.Answer[] + if (isNonInteractive()) { + answers = autoAnswer(params.questions) + } else { + answers = await Question.ask({ + sessionID: ctx.sessionID, + questions: params.questions, + tool: ctx.callID ? { messageID: ctx.messageID, callID: ctx.callID } : undefined, + }) + } + // altimate_change end function format(answer: Question.Answer | undefined) { if (!answer?.length) return "Unanswered" @@ -22,9 +83,17 @@ export const QuestionTool = Tool.define("question", { const formatted = params.questions.map((q, i) => `"${q.question}"="${format(answers[i])}"`).join(", ") + // altimate_change start — flag auto-answers explicitly so the agent + // knows the user didn't actually answer and can decide whether to + // proceed with that choice or fail back gracefully. + const prefix = isNonInteractive() + ? `Running in non-interactive mode (no TTY). Auto-answered with safe defaults: ` + : `User has answered your questions: ` + // altimate_change end + 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.`, + output: `${prefix}${formatted}. You can now continue with the user's answers in mind.`, metadata: { answers, }, diff --git a/packages/opencode/test/tool/question.test.ts b/packages/opencode/test/tool/question.test.ts index 9157aaa9a4..73833a46f1 100644 --- a/packages/opencode/test/tool/question.test.ts +++ b/packages/opencode/test/tool/question.test.ts @@ -19,12 +19,18 @@ describe("tool.question", () => { let askSpy: any beforeEach(() => { + // Force the original interactive path for the legacy tests below — the + // test environment is non-TTY (bun:test runs without a terminal), so + // without this override the non-interactive auto-answer branch would + // short-circuit `Question.ask` and the existing spies would never fire. + process.env["ALTIMATE_FORCE_INTERACTIVE"] = "1" askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => { return [] }) }) afterEach(() => { + delete process.env["ALTIMATE_FORCE_INTERACTIVE"] askSpy.mockRestore() }) @@ -106,3 +112,120 @@ describe("tool.question", () => { // } // }) }) + +describe("tool.question non-interactive auto-answer", () => { + let askSpy: any + + beforeEach(() => { + process.env["ALTIMATE_NON_INTERACTIVE"] = "1" + askSpy = spyOn(QuestionModule.Question, "ask").mockImplementation(async () => []) + }) + + afterEach(() => { + delete process.env["ALTIMATE_NON_INTERACTIVE"] + delete process.env["ALTIMATE_AUTO_ANSWER"] + askSpy.mockRestore() + }) + + test("picks safe-keyword option when present and does not invoke Question.ask", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "May I run row-level hashdiff comparisons?", + header: "PII consent", + options: [ + { label: "Approve row diff", description: "Sample rows may appear" }, + { label: "Profile only", description: "Safer; no row content surfaced" }, + ], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(askSpy).not.toHaveBeenCalled() + expect(result.output).toContain("Profile only") + expect(result.output).toContain("non-interactive mode") + }) + + test("falls back to last option when no safe keyword matches", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "Pick a color", + header: "Color", + options: [ + { label: "Red", description: "The color of passion" }, + { label: "Blue", description: "The color of sky" }, + ], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(askSpy).not.toHaveBeenCalled() + expect(result.output).toContain("Blue") + }) + + test("ALTIMATE_AUTO_ANSWER=first picks first option", async () => { + process.env["ALTIMATE_AUTO_ANSWER"] = "first" + const tool = await QuestionTool.init() + const questions = [ + { + question: "Pick a color", + header: "Color", + options: [ + { label: "Red", description: "" }, + { label: "Blue", description: "" }, + ], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain("Red") + }) + + test("ALTIMATE_AUTO_ANSWER=skip returns Unanswered for each question", async () => { + process.env["ALTIMATE_AUTO_ANSWER"] = "skip" + const tool = await QuestionTool.init() + const questions = [ + { + question: "Pick a color", + header: "Color", + options: [{ label: "Red", description: "" }], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain("Unanswered") + }) + + test("ALTIMATE_AUTO_ANSWER= picks matching option", async () => { + process.env["ALTIMATE_AUTO_ANSWER"] = "blue" + const tool = await QuestionTool.init() + const questions = [ + { + question: "Pick a color", + header: "Color", + options: [ + { label: "Red", description: "" }, + { label: "Blue", description: "" }, + ], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(result.output).toContain("Blue") + }) + + test("non-interactive prefix is set when Question.ask is bypassed", async () => { + const tool = await QuestionTool.init() + const questions = [ + { + question: "OK to proceed?", + header: "Proceed", + options: [{ label: "Cancel", description: "Stop" }], + }, + ] + + const result = await tool.execute({ questions }, ctx) + expect(result.output.startsWith("Running in non-interactive mode")).toBe(true) + }) +}) From f981199e560142c2bf7e684fa3c6ef6cb9d1a723 Mon Sep 17 00:00:00 2001 From: Haider Date: Sat, 13 Jun 2026 04:02:11 +0530 Subject: [PATCH 2/2] fix(question): return Unanswered in non-interactive mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier non-interactive policy in this branch scanned option label text for "safe" keywords (skip/cancel/no/abort/...) and fell back to the last option. That tried to recover semantics the LLM already knew at construction time, and false-positived on substrings — "no" matched inside "Snowflake", "Annotate", "Knowledge", "Honor". The fix: don't guess. Return Unanswered for every question when no TTY is present and let the agent decide. The agent has full context — it knows what action it was about to take and why it asked. It can pick a safe path from that context or report that user input is required. Pretending a decision was made that wasn't is the worse failure mode. Changes: - Drop SAFE_KEYWORDS and the label-text scan entirely. - Default non-interactive behavior returns [] (renders as "Unanswered" via the existing format()). - Cache isNonInteractive() once at execute() entry so the result prefix can't disagree with the path that produced the answer. - Non-interactive prefix tells the agent how to proceed AND names the escape hatch (ALTIMATE_AUTO_ANSWER=first|last|