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

Merged
SA
5mo ago
+381-7

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.ts → 8 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

  • Issue: #936
  • Original repro: deliverable 02 Run F first sub-session (the data-parity skill's PII consent question wedge)
  • Catalog entry: plugin-skill-experiments/03-issues-and-fixes.md Issue #5
  • Sister fix: #935 (stdin wedge in run.ts — same surface area, different code path)

Summary by cubic

Fixes hangs in the question tool for headless runs by treating altimate-code run as non-interactive, defaulting to Unanswered, and clearly labeling the mode. Interactive server flows remain unchanged; addresses #936.

  • Bug Fixes
    • Env-driven detection: ALTIMATE_NON_INTERACTIVE=1 enables non-interactive; ALTIMATE_FORCE_INTERACTIVE=1 forces interactive; ALTIMATE_NON_INTERACTIVE=0 opts out. run sets ALTIMATE_NON_INTERACTIVE=1 by default (skipped with --attach).
    • Non-interactive behavior: return Unanswered for all questions; removed label heuristics. Optional auto-answer via ALTIMATE_AUTO_ANSWER=first, =last, or ="<exact label>".
    • Output: prefix states “non-interactive mode (no answer channel available)”, removes the interactive trailer, and points to ALTIMATE_AUTO_ANSWER.
    • bash tool strips ALTIMATE_NON_INTERACTIVE from child env to avoid breaking nested server reply paths.
    • run stdin read is now fully null-safe: use process.stdin && !process.stdin.isTTY and skip the read when stdin is undefined to prevent stalls.
    • Tests cover env-var detection, precedence (FORCE_INTERACTIVE over NON_INTERACTIVE), explicit opt-out (NON_INTERACTIVE=0), non-interactive prefix, and suite isolation fixes.

Summary by CodeRabbit

  • New Features

    • Improved non-interactive execution with deterministic question auto-answering (first, last, or exact option label) via configuration, or leaving items unanswered.
    • Mode-specific messaging now clearly indicates when the tool is running non-interactively and how to preconfigure answers.
  • Bug Fixes

    • Reduced risk of blocking in the run entrypoint by enabling non-interactive mode by default when not attaching.
    • Improved robustness when stdin is unavailable.
    • Prevented non-interactive settings from affecting nested executions.
  • Tests

    • Added coverage for auto-answer modes, option-label matching, unanswered fallback, and non-interactive vs interactive messaging.