Skip to content

fix: auto-resolve question tool in non-interactive contexts#937

Open
sahrizvi wants to merge 1 commit into
mainfrom
fix/question-non-interactive-936
Open

fix: auto-resolve question tool in non-interactive contexts#937
sahrizvi wants to merge 1 commit into
mainfrom
fix/question-non-interactive-936

Conversation

@sahrizvi

@sahrizvi sahrizvi commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Closes #936

Summary

packages/opencode/src/tool/question.ts calls Question.ask(), which 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, subprocess.run, plugin host) and a skill that uses the question tool fires, nobody can ever click — the deferred awaits forever and the parent eventually TaskStops the subprocess. Symptom: 0% CPU, no log activity, no error, indistinguishable from a hang.

This PR short-circuits the question tool in non-interactive contexts with a conservative-by-default auto-answer policy.

Resolution policy

  • Non-interactive detection: !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 the end).
  • Explicit overrides:
    • ALTIMATE_AUTO_ANSWER=first — always pick first option.
    • ALTIMATE_AUTO_ANSWER=skip — return Unanswered for all questions.
    • ALTIMATE_AUTO_ANSWER="<exact label>" — exact-match an option's label (case-insensitive).
  • 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 and can adapt strategy.

Why not just always pick "cancel"?

Picking cancel/abort blindly fails open in the opposite direction: skills that ask permission to do reasonable work would always get a no, breaking the user's actual intent. The safe-keyword scan tries to match the question author's intent (these are typically "may I do destructive thing X?" prompts) without blocking legitimate flows.

Where this lives

Two reasonable choices:

  1. At the tool boundary (this PR)packages/opencode/src/tool/question.ts short-circuits before calling Question.ask().
  2. At the Effect layer — push the non-interactive detection into Question.ask() itself, so any caller of Question.ask (not just the tool) benefits.

Option 1 is what this PR ships; the scope is contained and the diff is reviewable. Option 2 is the deeper, more invasive change and may be the right long-term home. Happy to refactor if reviewers prefer.

Test plan

  • bun test test/tool/question.test.ts8 pass, 0 fail, 12 expect() calls. Verified locally before pushing.
    • 2 pre-existing legacy tests retained (gated with ALTIMATE_FORCE_INTERACTIVE=1 in beforeEach so they preserve their original intent under non-TTY CI).
    • 6 new tests covering: safe-keyword selection, last-option fallback, ALTIMATE_AUTO_ANSWER=first, =skip, =<exact label>, non-interactive prefix wording.
  • Reviewer smoke: trigger any skill using question from Claude Code's Bash tool — expect completion in seconds with auto-answered output, not a hang.

Diff size

packages/opencode/src/tool/question.ts       |  81 ++++++++++++++++--
packages/opencode/test/tool/question.test.ts | 123 +++++++++++++++++++++++++++
2 files changed, 198 insertions(+), 6 deletions(-)

The patch is roughly 70 lines of source change plus tests. Larger than #935 (the stdin-wedge guard) because the resolution policy has real branching to implement; still bounded to one tool file.

Risk

Low. Default behavior under TTY is unchanged (the new branch only fires when !isTTY or ALTIMATE_NON_INTERACTIVE=1). The non-TTY auto-answer surfaces explicitly in the tool result, so a downstream agent treating the choice as a real user click is impossible. ALTIMATE_FORCE_INTERACTIVE=1 provides an escape hatch for any consumer that wants the original behavior even in non-TTY.

Links


Summary by cubic

Fixes hangs in the question tool when no TTY is present by auto-answering conservatively and labeling the output as non-interactive, addressing #936. Interactive behavior is unchanged.

  • Bug Fixes
    • Detect non-interactive via !process.stdin.isTTY; overrides: ALTIMATE_FORCE_INTERACTIVE=1, ALTIMATE_NON_INTERACTIVE=1.
    • Default policy (ALTIMATE_AUTO_ANSWER=last): pick a safe-keyword option (skip/cancel/no/abort/profile only/decline/deny/stop) or fall back to the last option; overrides: first, skip, or "<exact label>".
    • Output prefix clearly marks non-interactive auto-answers so agents don’t treat them as real user input.
    • Added tests for safe-keyword selection, fallbacks, overrides, and the prefix.

Written for commit a49442b. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

Release Notes

  • New Features

    • Added support for non-interactive environments with automatic answer generation when TTY is unavailable.
    • Implemented keyword-based safe default selection policy for automated responses.
    • Added configurable auto-answer behavior via environment variables for flexible automation control.
    • Tool output now explicitly indicates when answers are auto-generated.
  • Tests

    • Added comprehensive test coverage for non-interactive mode behavior.

`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
@coderabbitai

coderabbitai Bot commented Jun 11, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

The question tool now supports non-interactive environments by auto-generating answers when TTY is unavailable. A safe-keyword matching policy detects "safe" options (skip, cancel, no, abort, profile only, decline, deny, stop) and selects them by default; the tool falls back to the last option when no safe keyword matches. Environment variables ALTIMATE_FORCE_INTERACTIVE, ALTIMATE_NON_INTERACTIVE, and ALTIMATE_AUTO_ANSWER control behavior. Tool output explicitly flags non-interactive auto-answer mode.

Changes

Non-interactive auto-answer support

Layer / File(s) Summary
Auto-answer logic and tool output
packages/opencode/src/tool/question.ts
Adds safe-keyword matching for conservative option selection, detects interactive/non-interactive mode via TTY and env vars, computes auto-answers based on ALTIMATE_AUTO_ANSWER policy (last/first/skip/exact-label), and updates tool output to explicitly indicate non-interactive auto-answer with selected options.
Test setup and non-interactive validation
packages/opencode/test/tool/question.test.ts
Forces interactive execution path in legacy tests via ALTIMATE_FORCE_INTERACTIVE, adds new test suite validating safe-keyword preference, fallback to last option, ALTIMATE_AUTO_ANSWER modes (first/skip/exact-label matching), and non-interactive output prefix.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~22 minutes

Poem

🐰 A question tooling tale so bright,
When TTY fades, we auto-right—
Safe keywords guide our careful choice,
No hanging waits, no weary voice!
Skips, cancels, profiles flow,
The answer path just lets us go! 🎯

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Description check ⚠️ Warning The PR description is missing the required 'PINEAPPLE' keyword at the very top, which the template explicitly mandates for all AI-generated contributions. Add 'PINEAPPLE' as the first word in the PR description before any other content, as required by the description template for AI-generated contributions.
Docstring Coverage ⚠️ Warning Docstring coverage is 33.33% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title 'fix: auto-resolve question tool in non-interactive contexts' accurately summarizes the main change: adding auto-resolution logic to the question tool for non-TTY environments.
Linked Issues check ✅ Passed All coding objectives from linked issues #936 and #935 are met: non-interactive detection with env overrides, conservative auto-answer policy with safe keywords and fallbacks, explicit overrides (first/skip/exact label), tool result prefix indicating non-interactive mode, and comprehensive unit tests validating all behaviors.
Out of Scope Changes check ✅ Passed All changes are directly scoped to addressing #936: only question.ts and its test file are modified to implement non-interactive auto-resolution logic; no unrelated refactoring or feature additions present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/question-non-interactive-936

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

1 similar comment
@github-actions

Copy link
Copy Markdown

👋 This PR was automatically closed by our quality checks.

Common reasons:

  • New GitHub account with limited contribution history
  • PR description doesn't meet our guidelines
  • Contribution appears to be AI-generated without meaningful review

If you believe this was a mistake, please open an issue explaining your intended contribution and a maintainer will help you.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
packages/opencode/src/tool/question.ts (2)

39-57: 💤 Low value

Consider logging when ALTIMATE_AUTO_ANSWER doesn't match any known mode or option label.

When ALTIMATE_AUTO_ANSWER is set to a value that doesn't match "skip", "first", "last", or any option label (case-insensitive), the function silently returns empty arrays (Unanswered). This is safe but could make debugging harder if a user misspells a mode or label.

Consider logging a warning in this case to help users diagnose configuration issues.

📝 Optional: Add debug logging for unmatched modes
     // exact label match for explicit answers, e.g. ALTIMATE_AUTO_ANSWER="Profile only"
     const match = q.options.find((o) => o.label.toLowerCase() === mode)
+    if (!match && mode !== "skip" && mode !== "first" && mode !== "last") {
+      console.warn(`ALTIMATE_AUTO_ANSWER="${mode}" did not match any option label; returning Unanswered`)
+    }
     return match ? [match.label] : []
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/question.ts` around lines 39 - 57, The autoAnswer
function silently returns empty answers when ALTIMATE_AUTO_ANSWER contains an
unknown mode or when the explicit-label match fails; add a warning log to aid
debugging: detect when mode is not "skip"/"first"/"last" and no q.options label
matches the lowercase mode, and emit a single warning (e.g., console.warn or the
module's logger) including the provided ALTIMATE_AUTO_ANSWER value and question
id/label (use Question.Info properties) so users know the env value didn't match
any known mode or option; place this check inside autoAnswer just before
returning an empty array for unmatched cases and reference autoAnswer,
ALTIMATE_AUTO_ANSWER, and q.options in the change.

86-92: 💤 Low value

Consider mode-specific prefix wording for accuracy.

The prefix says "Auto-answered with safe defaults" but this is only accurate when ALTIMATE_AUTO_ANSWER is "last" (default). When the mode is "first" or an exact label match, the selection isn't necessarily using safe defaults—it's just picking the first option or the specified label.

While the key information ("Running in non-interactive mode") is accurate and sufficient, you might consider mode-specific wording for precision:

  • "last" → "Auto-answered with safe defaults"
  • "first" → "Auto-selected first option"
  • exact match → "Auto-selected option: {mode}"
  • "skip" → "Auto-skipped (Unanswered)"

This is a minor clarity improvement; the current wording is acceptable since the primary goal is signaling non-interactive execution.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/opencode/src/tool/question.ts` around lines 86 - 92, The prefix
message built in question.ts (variable prefix) misstates the non-interactive
auto-answer behavior by always saying "Auto-answered with safe defaults"; update
the logic that sets prefix (referencing isNonInteractive() and the
ALTIMATE_AUTO_ANSWER mode) to choose mode-specific wording: "Auto-answered with
safe defaults" for "last", "Auto-selected first option" for "first",
"Auto-selected option: {mode}" for exact label matches, and "Auto-skipped
(Unanswered)" for "skip", while keeping the existing "Running in non-interactive
mode" text and preserving the interactive branch "User has answered your
questions".
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@packages/opencode/src/tool/question.ts`:
- Around line 39-57: The autoAnswer function silently returns empty answers when
ALTIMATE_AUTO_ANSWER contains an unknown mode or when the explicit-label match
fails; add a warning log to aid debugging: detect when mode is not
"skip"/"first"/"last" and no q.options label matches the lowercase mode, and
emit a single warning (e.g., console.warn or the module's logger) including the
provided ALTIMATE_AUTO_ANSWER value and question id/label (use Question.Info
properties) so users know the env value didn't match any known mode or option;
place this check inside autoAnswer just before returning an empty array for
unmatched cases and reference autoAnswer, ALTIMATE_AUTO_ANSWER, and q.options in
the change.
- Around line 86-92: The prefix message built in question.ts (variable prefix)
misstates the non-interactive auto-answer behavior by always saying
"Auto-answered with safe defaults"; update the logic that sets prefix
(referencing isNonInteractive() and the ALTIMATE_AUTO_ANSWER mode) to choose
mode-specific wording: "Auto-answered with safe defaults" for "last",
"Auto-selected first option" for "first", "Auto-selected option: {mode}" for
exact label matches, and "Auto-skipped (Unanswered)" for "skip", while keeping
the existing "Running in non-interactive mode" text and preserving the
interactive branch "User has answered your questions".

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro

Run ID: 2e6c3623-6d27-4e80-adc6-f385f03948eb

📥 Commits

Reviewing files that changed from the base of the PR and between 146acea and a49442b.

📒 Files selected for processing (2)
  • packages/opencode/src/tool/question.ts
  • packages/opencode/test/tool/question.test.ts

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 2 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="packages/opencode/src/tool/question.ts">

<violation number="1" location="packages/opencode/src/tool/question.ts:47">
P2: `"no"` is matched as an arbitrary substring, causing false safe-keyword hits and incorrect auto-answers.</violation>

<violation number="2" location="packages/opencode/src/tool/question.ts:89">
P3: Mode is recomputed after awaiting, so output can claim non-interactive/interactive status inconsistent with how answers were produced.</violation>
</file>

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

if (mode === "last") {
const safe = q.options.find((o) => {
const text = `${o.label} ${o.description}`.toLowerCase()
return SAFE_KEYWORDS.some((k) => text.includes(k))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: "no" is matched as an arbitrary substring, causing false safe-keyword hits and incorrect auto-answers.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tool/question.ts, line 47:

<comment>`"no"` is matched as an arbitrary substring, causing false safe-keyword hits and incorrect auto-answers.</comment>

<file context>
@@ -3,17 +3,78 @@ import { Tool } from "./tool"
+    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]
</file context>
Suggested change
return SAFE_KEYWORDS.some((k) => text.includes(k))
return SAFE_KEYWORDS.some((k) => (k === "no" ? /\bno\b/.test(text) : text.includes(k)))

// 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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: Mode is recomputed after awaiting, so output can claim non-interactive/interactive status inconsistent with how answers were produced.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At packages/opencode/src/tool/question.ts, line 89:

<comment>Mode is recomputed after awaiting, so output can claim non-interactive/interactive status inconsistent with how answers were produced.</comment>

<file context>
@@ -22,9 +83,17 @@ export const QuestionTool = Tool.define("question", {
+    // 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: `
</file context>

@dev-punia-altimate

Copy link
Copy Markdown
Contributor

❌ Tests — Failures Detected

TypeScript — 15 failure(s)

  • connection_refused
  • timeout [1.00ms]
  • permission_denied
  • parse_error
  • network_error
  • auth_failure
  • rate_limit
  • internal_error
  • empty_error
  • connection_refused
  • timeout
  • permission_denied
  • parse_error
  • network_error
  • auth_failure

Next Step

Please address the failing cases above and re-run verification.

cc @sahrizvi

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

question tool blocks indefinitely in non-interactive contexts

2 participants