diff --git a/libs/code/deepagents_code/main.py b/libs/code/deepagents_code/main.py index 8e5f9ee860c..91b933f0299 100644 --- a/libs/code/deepagents_code/main.py +++ b/libs/code/deepagents_code/main.py @@ -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 diff --git a/libs/code/tests/unit_tests/test_main_args.py b/libs/code/tests/unit_tests/test_main_args.py index e0bad7e643b..0ca9fdf33a3 100644 --- a/libs/code/tests/unit_tests/test_main_args.py +++ b/libs/code/tests/unit_tests/test_main_args.py @@ -612,6 +612,46 @@ def test_skill_with_no_stream_without_non_interactive_exits_2(self) -> None: 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 @@ def test_stdin_prepends_to_skill_prompt(self) -> None: 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")