fix(code): route explicit --stdin + --skill to headless path (#4611)

`dcode --skill ... --stdin` with piped input now runs headless instead
of launching the interactive TUI.

---

When a user explicitly passes `--stdin` together with `--skill`, `dcode`
previously routed the piped text into the interactive TUI seed
(`initial_prompt`), which hangs/fails on non-TTY environments.
`apply_stdin_pipe` now skips the skill→interactive convenience when
`--stdin` is explicit, falling through to the headless
`non_interactive_message` path (which already supports `--skill`).
Auto-detected pipes (no `--stdin`) keep the interactive seeding
behavior.

Made by [Open
SWE](https://openswe.vercel.app/agents/057272c4-7946-e220-f7b8-ea929d2fe9ce)

---------

Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
Mason Daugherty
2026-07-13 09:52:59 -04:00
committed by GitHub
parent 7add92d21c
commit 724e24a315
2 changed files with 103 additions and 5 deletions
+23 -5
View File
@@ -2305,15 +2305,25 @@ def apply_stdin_pipe(args: argparse.Namespace) -> None:
# initial_prompt = "{contents of error.log}\n\nexplain this"
```
- If `initial_skill` is already set (`--skill`, but not `-n`), stores the
piped text in `initial_prompt` so the skill receives it as the
startup request:
- If `initial_skill` is already set (`--skill`, but not `-n`/`-m`) and the
pipe was auto-detected (no explicit `--stdin`), stores the piped text in
`initial_prompt` so the skill receives it as the seed for the
interactive TUI:
```bash
cat diff.txt | dcode --skill code-review
# initial_prompt = "{contents of diff.txt}"
```
When `--stdin` is passed explicitly, this convenience is skipped: the
piped text falls through to `non_interactive_message` so the skill runs
headless (see below):
```bash
cat diff.txt | dcode --skill code-review --stdin
# non_interactive_message = "{contents of diff.txt}"
```
- Otherwise, sets `non_interactive_message` to the piped text, causing
the CLI to run non-interactively with it as the prompt:
@@ -2389,12 +2399,20 @@ def apply_stdin_pipe(args: argparse.Namespace) -> None:
if not stdin_text:
return
# Priority: -n message > -m prompt > --skill (no -m) > fallback to -n.
# Priority: -n message > -m prompt > --skill (no -m, no explicit --stdin)
# > fallback to -n.
# The initial_prompt branch uses `is not None` (not truthiness) so that
# `-m ""` is distinguished from "no -m at all", allowing stdin to land
# in initial_prompt even when the explicit value is empty. The --skill
# branch only fires when -m was NOT provided; when both -m and --skill
# are set, stdin merges with the -m value (previous branch).
#
# The --skill -> interactive `initial_prompt` routing applies only to
# auto-detected pipes (no explicit `--stdin`), where seeding an interactive
# TUI is a deliberate convenience. When the user passes `--stdin`
# explicitly, that signals non-interactive intent, so we skip this branch
# and fall through to `non_interactive_message` (headless), which also
# supports `--skill`.
if args.non_interactive_message:
args.non_interactive_message = f"{stdin_text}\n\n{args.non_interactive_message}"
elif args.initial_prompt is not None:
@@ -2402,7 +2420,7 @@ def apply_stdin_pipe(args: argparse.Namespace) -> None:
args.initial_prompt = f"{stdin_text}\n\n{args.initial_prompt}"
else:
args.initial_prompt = stdin_text
elif getattr(args, "initial_skill", None):
elif getattr(args, "initial_skill", None) and not explicit_stdin:
args.initial_prompt = stdin_text
else:
args.non_interactive_message = stdin_text
@@ -612,6 +612,46 @@ class TestSkillFlagValidation:
cli_main()
assert exc_info.value.code == 2
def test_skill_with_explicit_stdin_and_quiet_runs_headless(self) -> None:
"""`--skill --stdin -q` clears the guard and forwards the skill headless.
Explicit `--stdin` routes the piped text to `non_interactive_message`
(not the interactive `-m` seed), which satisfies the `--skill` +
`--quiet` guard and reaches `run_non_interactive` with both the piped
message and `initial_skill`.
"""
from deepagents_code.main import cli_main
mock_stdin = MagicMock()
mock_stdin.isatty.return_value = False
mock_stdin.read.return_value = "review this repo"
with (
patch.object(
sys,
"argv",
["deepagents", "--skill", "code-review", "--stdin", "-q"],
),
patch.object(sys, "stdin", mock_stdin),
patch("deepagents_code.main.check_optional_tools", return_value=[]),
patch(
"deepagents_code.main._should_ensure_managed_ripgrep",
return_value=False,
),
# Skip the /dev/tty dance — os.open would fail in test sandboxes
# and the real code path already tolerates that failure.
patch("os.open", side_effect=OSError("No tty in test sandbox")),
patch(
"deepagents_code.client.non_interactive.run_non_interactive",
new_callable=AsyncMock,
return_value=0,
) as mock_run,
pytest.raises(SystemExit) as exc_info,
):
cli_main()
assert exc_info.value.code == 0
assert mock_run.await_args.kwargs["initial_skill"] == "code-review" # ty: ignore
assert mock_run.await_args.kwargs["message"] == "review this repo" # ty: ignore
class TestMaxTurnsArgument:
"""Tests for --max-turns argument parsing and validation."""
@@ -1183,6 +1223,46 @@ class TestApplyStdinPipe:
assert args.initial_prompt == "diff contents\n\nreview this"
assert args.non_interactive_message is None
def test_explicit_stdin_with_skill_runs_headless(self) -> None:
"""Explicit `--stdin` + `--skill` runs headless, not the seeded TUI."""
args = _make_args(initial_skill="code-review", stdin=True)
fake_stdin = io.StringIO("review this repo")
fake_stdin.isatty = lambda: False # ty: ignore
with patch.object(sys, "stdin", fake_stdin):
apply_stdin_pipe(args)
assert args.non_interactive_message == "review this repo"
assert args.initial_prompt is None
def test_explicit_stdin_without_skill_sets_non_interactive(self) -> None:
"""Explicit `--stdin` with no skill/`-n`/`-m` sets non_interactive_message."""
args = _make_args(stdin=True)
fake_stdin = io.StringIO("my prompt")
fake_stdin.isatty = lambda: False # ty: ignore
with patch.object(sys, "stdin", fake_stdin):
apply_stdin_pipe(args)
assert args.non_interactive_message == "my prompt"
assert args.initial_prompt is None
def test_explicit_stdin_prepends_to_non_interactive(self) -> None:
"""Explicit `--stdin` still prepends to an existing -n message."""
args = _make_args(non_interactive_message="do something", stdin=True)
fake_stdin = io.StringIO("context from pipe")
fake_stdin.isatty = lambda: False # ty: ignore
with patch.object(sys, "stdin", fake_stdin):
apply_stdin_pipe(args)
assert args.non_interactive_message == "context from pipe\n\ndo something"
assert args.initial_prompt is None
def test_explicit_stdin_prepends_to_initial_prompt(self) -> None:
"""Explicit `--stdin` still merges into an existing -m message."""
args = _make_args(initial_prompt="explain this", stdin=True)
fake_stdin = io.StringIO("error log contents")
fake_stdin.isatty = lambda: False # ty: ignore
with patch.object(sys, "stdin", fake_stdin):
apply_stdin_pipe(args)
assert args.initial_prompt == "error log contents\n\nexplain this"
assert args.non_interactive_message is None
def test_non_interactive_takes_priority_over_initial_prompt(self) -> None:
"""When both -n and -m are set, stdin is prepended to -n."""
args = _make_args(non_interactive_message="task", initial_prompt="ignored")