Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions libs/code/deepagents_code/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down Expand Up @@ -2389,20 +2399,28 @@ 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:
if args.initial_prompt:
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
Expand Down
80 changes: 80 additions & 0 deletions libs/code/tests/unit_tests/test_main_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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")
Expand Down
Loading