fix: auto-resolve question tool in non-interactive contexts
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— returnUnansweredfor 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:
- At the tool boundary (this PR) —
packages/opencode/src/tool/question.tsshort-circuits before callingQuestion.ask(). - At the Effect layer — push the non-interactive detection into
Question.ask()itself, so any caller ofQuestion.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=1inbeforeEachso 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.
- 2 pre-existing legacy tests retained (gated with
- Reviewer smoke: trigger any skill using
questionfrom 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-parityskill's PII consent question wedge) - Catalog entry:
plugin-skill-experiments/03-issues-and-fixes.mdIssue #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=1enables non-interactive;ALTIMATE_FORCE_INTERACTIVE=1forces interactive;ALTIMATE_NON_INTERACTIVE=0opts out.runsetsALTIMATE_NON_INTERACTIVE=1by 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. bashtool stripsALTIMATE_NON_INTERACTIVEfrom child env to avoid breaking nested server reply paths.runstdin read is now fully null-safe: useprocess.stdin && !process.stdin.isTTYand skip the read when stdin is undefined to prevent stalls.- Tests cover env-var detection, precedence (
FORCE_INTERACTIVEoverNON_INTERACTIVE), explicit opt-out (NON_INTERACTIVE=0), non-interactive prefix, and suite isolation fixes.
- Env-driven detection:
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.