diff --git a/CHANGELOG.md b/CHANGELOG.md index e3b5b93..d003e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,30 @@ # Changelog +## 0.6.23 - 2026-07-23 + +### Fixed +- **`-q/--quiet` and the non-TTY auto-quiet now apply when the message arrives on piped + stdin (gh #93).** `-q` is documented to suppress the header, spinner, tool chatter, + timing, and color and emit only the agent's reply, and #53 wired the same auto-quiet + for a non-TTY stdout so a piped run is clean without any flag. That contract held for a + single-shot run — a `MESSAGE` arg or `-f/--file` — but not for the most idiomatic pipe + form, `echo "hi" | langstage-cli --demo -q`: a stdin message is read by the interactive + loop, which honored neither `-q` nor the `stdout.isatty()==False` gating, so it still + emitted the `····` separator rules, the `❯` prompt row (with `\x01\x02` bracketed-paste + bytes), the `Nms` timing line, and a trailing `Goodbye!` — ~380 bytes of chrome around a + 25-byte reply. Two root causes: (1) the auto-quiet formula keyed only off a `MESSAGE`/ + `-f`/`--verify` single-shot, so a stdin-fed run never triggered it; and (2) the + interactive loop applied no quiet gating at all. Both are fixed. The auto-quiet gate is + now "stdout is not a TTY **and** the input is non-interactive" — a single-shot arg/file/ + verify **or** piped (non-TTY) stdin — and the conversation loop suppresses its + separators, prompt, timing line and `Goodbye!` under `_QUIET`, so a piped message + produces byte-identical output to the `-q "msg"` arg path (only the reply). The + interactive-loop path is kept (not rerouted to single-shot) deliberately: piped stdin + can still carry multiple lines and slash commands — `printf 'hi\nbye\n' | langstage-cli` + runs two clean turns, and `echo /config | langstage-cli` still drives the command — each + turn now just renders on the scriptable path. A live terminal session (stdin **is** a + tty, no `-q`) is unchanged: it keeps its header, separators, prompt, timing and farewell. + ## 0.6.22 - 2026-07-23 ### Fixed diff --git a/langstage_cli/cli.py b/langstage_cli/cli.py index 0874389..13964e4 100644 --- a/langstage_cli/cli.py +++ b/langstage_cli/cli.py @@ -1507,11 +1507,22 @@ def run_conversation_loop( if single_shot: return had_error - # Main conversation loop + # Main conversation loop. + # + # This loop also consumes a message on piped (non-TTY) stdin — `echo "hi" | + # langstage-cli` reads the line via input() and runs one turn, then EOF ends the + # loop. That path is scriptable, not an interactive session, so when _QUIET is + # set (via -q or the non-TTY auto-quiet above) every decorative element is + # suppressed: the `····` separators, the `❯` prompt (input() with no prompt, so + # no glyph and no bracketed-paste bytes), the `Nms` timing line, and the + # `Goodbye!`. What reaches stdout is then exactly the reply the MESSAGE-arg path + # emits. A real TTY session has _QUIET == False, so all of it renders unchanged. + # (gh #93) while True: try: - print(separator("dots")) - user_input = input(make_prompt()).strip() + if not _QUIET: + print(separator("dots")) + user_input = input("" if _QUIET else make_prompt()).strip() if not user_input: continue @@ -1565,22 +1576,32 @@ def run_conversation_loop( if user_input.lower() == "exit": break - print() # Space before response + if not _QUIET: + print() # Space before response # Run the agent (AG-UI is the only streaming path since langstage-core 1.0) duration, _ = asyncio.run( run_single_turn_agui(agui_agent, user_input, thread_id, interactive, verbose) ) - print_timing(duration, verbose) - print() + if _QUIET: + # Scriptable path (gh #93): cap the streamed reply with a single + # newline and emit no `Nms` timing line — exactly what the quiet + # MESSAGE-arg single-shot path does, so a piped one-liner produces + # byte-identical output whether the message is an arg or on stdin. + print() + else: + print_timing(duration, verbose) + print() except (EOFError, KeyboardInterrupt): break except Exception as err: print(f"\n{RED}✗ Error: {err}{RESET}\n") - # Print goodbye message - print_goodbye() + # Print goodbye message (interactive chrome — omitted on the scriptable/quiet + # path so a piped consumer never sees a trailing `Goodbye!`, gh #93). + if not _QUIET: + print_goodbye() @click.command() @@ -1738,14 +1759,22 @@ def main( except (AttributeError, ValueError): # non-reconfigurable stream pass - # Scriptable output (gh #53). A single-shot run (a MESSAGE arg or -f/--file) or - # a --verify preflight that is piped — stdout is not a TTY — auto-enables quiet - # so the consumer gets only the reply/verdict (no spinner or "Loaded" line); - # --quiet forces it in a terminal. Color is additionally stripped whenever stdout - # is not a TTY, matching well-behaved CLIs. + # Scriptable output (gh #53, gh #93). Auto-enable quiet when stdout is not a TTY + # AND the run is non-interactive input: an explicit single-shot (a MESSAGE arg, + # -f/--file, or a --verify preflight) OR a message on piped stdin + # (`echo "hi" | langstage-cli`). Piped stdin is non-interactive input — it is + # scriptable too, so it must not leak the REPL's `····` rules, the `❯` prompt + # (with bracketed-paste bytes), the `Nms` timing line or a trailing `Goodbye!`. + # The gate is `stdin is not a tty`, so a LIVE terminal session (a human typing, + # stdin IS a tty, no single-shot input) is never auto-quieted and its interactive + # UX is unchanged; --quiet still forces quiet anywhere. Color is additionally + # stripped whenever stdout is not a TTY, matching well-behaved CLIs. _is_tty = _is_a_tty(sys.stdout) + _stdin_is_tty = _is_a_tty(sys.stdin) global _QUIET - _QUIET = quiet or ((bool(message or prompt_file) or verify_agent) and not _is_tty) + _QUIET = quiet or ( + (bool(message or prompt_file) or verify_agent or not _stdin_is_tty) and not _is_tty + ) if _QUIET or not _is_tty: _disable_ansi() diff --git a/pyproject.toml b/pyproject.toml index d076f51..f18a83f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "langstage-cli" -version = "0.6.22" +version = "0.6.23" description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph" readme = "README.md" requires-python = ">=3.11" diff --git a/tests/test_quiet_output.py b/tests/test_quiet_output.py index a0ec451..1ece702 100644 --- a/tests/test_quiet_output.py +++ b/tests/test_quiet_output.py @@ -10,6 +10,8 @@ piped path these tests care about. """ +import re + from click.testing import CliRunner from langstage_cli import cli as c @@ -36,6 +38,42 @@ def test_quiet_flag_forces_clean_output(tmp_path, monkeypatch): assert "\x1b[" not in r.output and "⏺" not in r.output, r.output +def test_piped_stdin_quiet_output_matches_the_message_arg_path(tmp_path, monkeypatch): + # gh #93: a message on piped stdin (`echo "hi" | langstage-cli --demo -q`) used to + # be routed through the interactive REPL, which honored neither -q nor the non-TTY + # auto-quiet — so it leaked the `····` separator rules, the `❯` prompt row (with + # `\x01\x02` bracketed-paste bytes), the `Nms` timing line, and a trailing `Goodbye!` + # around the reply (~380 bytes of chrome). It must now emit exactly what the + # MESSAGE-arg quiet path emits: only the agent's reply. CliRunner's stdin and stdout + # are both non-TTYs, so `input="hi\n"` with no MESSAGE arg reproduces the piped pipe. + monkeypatch.chdir(tmp_path) + arg = CliRunner().invoke(main, ["--demo", "-q", "hi"]) + stdin = CliRunner().invoke(main, ["--demo", "-q"], input="hi\n") + assert arg.exit_code == 0 and stdin.exit_code == 0, (arg.output, stdin.output) + # Byte-for-byte identical reply streams — the stdin path carries no extra chrome. + assert stdin.stdout == arg.stdout, repr((arg.stdout, stdin.stdout)) + assert stdin.stdout == "(demo agent) You said: hi\n", repr(stdin.stdout) + # And none of the specific chrome the issue grepped for reaches the reply stream. + assert re.search(r"\d+ms", stdin.stdout) is None, repr(stdin.stdout) # no timing line + assert "·" not in stdin.stdout, repr(stdin.stdout) # no `····` separator rule + assert "❯" not in stdin.stdout, repr(stdin.stdout) # no prompt row + assert "\x01" not in stdin.stdout and "\x02" not in stdin.stdout # no bracketed-paste bytes + assert "Goodbye" not in stdin.stdout, repr(stdin.stdout) # no trailing farewell + + +def test_piped_stdin_auto_quiets_without_the_quiet_flag(tmp_path, monkeypatch): + # gh #93 / #53: the non-TTY auto-quiet must ALSO fire when the message arrives on + # stdin, with no -q — `echo "hi" | langstage-cli --demo | tool` is clean by default, + # exactly like the MESSAGE-arg auto-quiet. Piped stdin is non-interactive input, so + # the run is scriptable even without the explicit flag. + monkeypatch.chdir(tmp_path) + r = CliRunner().invoke(main, ["--demo"], input="hi\n") + assert r.exit_code == 0, r.output + assert r.stdout == "(demo agent) You said: hi\n", repr(r.stdout) + assert re.search(r"\d+ms", r.stdout) is None, repr(r.stdout) + assert "·" not in r.stdout and "❯" not in r.stdout and "Goodbye" not in r.stdout + + def test_make_prompt_uses_disabled_color_globals_after_ansi_is_disabled(): c._disable_ansi()