langstage-cli 0.6.43__tar.gz → 0.6.44__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {langstage_cli-0.6.43/langstage_cli.egg-info → langstage_cli-0.6.44}/PKG-INFO +4 -2
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/README.md +3 -1
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/cli.py +53 -4
- {langstage_cli-0.6.43 → langstage_cli-0.6.44/langstage_cli.egg-info}/PKG-INFO +4 -2
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/SOURCES.txt +1 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/pyproject.toml +1 -1
- langstage_cli-0.6.44/tests/test_dumb_term.py +190 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/LICENSE +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/exit_codes.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/sessions.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/requires.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/setup.cfg +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_advertised_wave4.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_approval_menu_abort.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_async_mode.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_config_command.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_configurable.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_deferred_pass.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_exit_codes.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_generic_interrupt.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_history_persist.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_hitl_approval_preview.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_hitl_resume_warning.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_init.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_interrupt_non_tty.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_legacy_config_home.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_load_error.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_message_boundaries.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_color.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_persist_resume.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_persist_env.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_quiet_output.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_reasoning_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_render_markdown.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_session_lifecycle.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_sessions.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_show_config_malformed.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spec_loading_core.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spinner_reply_survives.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stdin_interactive.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stdin_single_shot.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_streaming_marker.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_terminal_width.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_toml_relative_paths.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_tool_output_escape.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_turn_error_verbose.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_turn_exit_and_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_unicode_console.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_verbose_command.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_verify_flag.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_workspace.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_wrong_type_graph.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langstage-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.44
|
|
4
4
|
Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
|
|
5
5
|
Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -322,7 +322,9 @@ cat prompt.txt | langstage-cli --demo
|
|
|
322
322
|
|
|
323
323
|
Pass `-q/--quiet` to force the same clean output in a terminal. Set
|
|
324
324
|
[`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
|
|
325
|
-
too; the layout and spinner stay, uncolored.
|
|
325
|
+
too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
|
|
326
|
+
the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
|
|
327
|
+
approval menu you answer by typing a number.
|
|
326
328
|
|
|
327
329
|
### Exit codes
|
|
328
330
|
|
|
@@ -284,7 +284,9 @@ cat prompt.txt | langstage-cli --demo
|
|
|
284
284
|
|
|
285
285
|
Pass `-q/--quiet` to force the same clean output in a terminal. Set
|
|
286
286
|
[`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
|
|
287
|
-
too; the layout and spinner stay, uncolored.
|
|
287
|
+
too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
|
|
288
|
+
the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
|
|
289
|
+
approval menu you answer by typing a number.
|
|
288
290
|
|
|
289
291
|
### Exit codes
|
|
290
292
|
|
|
@@ -61,6 +61,16 @@ BRIGHT_GREEN, BRIGHT_YELLOW = "\033[92m", "\033[93m"
|
|
|
61
61
|
# strip ANSI, so the pipe/file receives ONLY the agent's reply. Toggled once in
|
|
62
62
|
# main(); the render helpers below read it as a module global.
|
|
63
63
|
_QUIET = False
|
|
64
|
+
# A dumb terminal (TERM=dumb: Emacs shell/compile, vim :!, serial consoles) shows escape
|
|
65
|
+
# sequences as literal text, so it gets none at all: no color (via _disable_ansi()), no
|
|
66
|
+
# animated spinner, no line clears or cursor hide/show, and a typed-number approval
|
|
67
|
+
# menu instead of the arrow-key one. Set once in main(). (gh #182)
|
|
68
|
+
_DUMB_TERM = False
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _is_dumb_term() -> bool:
|
|
72
|
+
"""``TERM=dumb``: the long-standing "this terminal can't do escapes" convention."""
|
|
73
|
+
return os.environ.get("TERM", "").strip().lower() == "dumb"
|
|
64
74
|
|
|
65
75
|
|
|
66
76
|
def _is_a_tty(stream) -> bool:
|
|
@@ -298,7 +308,7 @@ def rl_wrap(code: str) -> str:
|
|
|
298
308
|
This causes issues with line wrapping when using input().
|
|
299
309
|
Wrapping with \\001 and \\002 tells readline to ignore these characters.
|
|
300
310
|
"""
|
|
301
|
-
if HAS_READLINE:
|
|
311
|
+
if HAS_READLINE and code:
|
|
302
312
|
return f"\001{code}\002"
|
|
303
313
|
return code
|
|
304
314
|
|
|
@@ -368,10 +378,17 @@ class Spinner:
|
|
|
368
378
|
time.sleep(0.08)
|
|
369
379
|
|
|
370
380
|
def start(self):
|
|
371
|
-
"""Start the spinner.
|
|
372
|
-
|
|
381
|
+
"""Start the spinner. On a dumb terminal it can't redraw a line, so print the
|
|
382
|
+
message once as a plain line and don't animate (gh #182)."""
|
|
373
383
|
self._stopped = False
|
|
374
384
|
self.start_time = time.time()
|
|
385
|
+
if _DUMB_TERM:
|
|
386
|
+
try:
|
|
387
|
+
print(f"{self.message}...", flush=True)
|
|
388
|
+
except (UnicodeEncodeError, ValueError):
|
|
389
|
+
pass
|
|
390
|
+
return
|
|
391
|
+
self.running = True
|
|
375
392
|
self.thread = threading.Thread(target=self._spin, daemon=True)
|
|
376
393
|
self.thread.start()
|
|
377
394
|
|
|
@@ -399,6 +416,8 @@ class Spinner:
|
|
|
399
416
|
self.running = False
|
|
400
417
|
if self.thread:
|
|
401
418
|
self.thread.join(timeout=0.2)
|
|
419
|
+
if _DUMB_TERM:
|
|
420
|
+
return # start() printed a whole line; there is nothing to clear (gh #182)
|
|
402
421
|
# Clear the spinner line. With color off (NO_COLOR, or stdout not a terminal:
|
|
403
422
|
# _disable_ansi() has blanked RESET), overwrite it with spaces instead of CSI 2K
|
|
404
423
|
# so that output carries no escape sequence at all. (gh #172)
|
|
@@ -1633,6 +1652,8 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
|
1633
1652
|
Returns:
|
|
1634
1653
|
Index of selected option (0-based)
|
|
1635
1654
|
"""
|
|
1655
|
+
if _DUMB_TERM:
|
|
1656
|
+
return _select_option_typed(options, prompt)
|
|
1636
1657
|
selected = 0
|
|
1637
1658
|
num_options = len(options)
|
|
1638
1659
|
|
|
@@ -1687,6 +1708,30 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
|
1687
1708
|
print("\033[?25h", end="")
|
|
1688
1709
|
|
|
1689
1710
|
|
|
1711
|
+
def _select_option_typed(options: List[str], prompt: str) -> int:
|
|
1712
|
+
"""The approval menu for a dumb terminal: a numbered list and a typed choice.
|
|
1713
|
+
|
|
1714
|
+
The arrow-key menu redraws itself with cursor-up and line-clear escapes, which a
|
|
1715
|
+
dumb terminal prints as literal text, and editor shells (Emacs ``M-x shell``) send
|
|
1716
|
+
whole lines, not keypresses. Enter alone picks the first option. Ctrl-C or EOF
|
|
1717
|
+
abandons the prompt the same way the arrow-key menu does: exit 2, paused. (gh #182)
|
|
1718
|
+
"""
|
|
1719
|
+
print(f"\n{prompt}")
|
|
1720
|
+
for i, opt in enumerate(options, 1):
|
|
1721
|
+
print(f" {i}. {opt}")
|
|
1722
|
+
while True:
|
|
1723
|
+
try:
|
|
1724
|
+
raw = input(f"Choose 1-{len(options)} [1]: ").strip()
|
|
1725
|
+
except (KeyboardInterrupt, EOFError):
|
|
1726
|
+
print()
|
|
1727
|
+
sys.exit(EXIT_PAUSED)
|
|
1728
|
+
if not raw:
|
|
1729
|
+
return 0
|
|
1730
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
1731
|
+
return int(raw) - 1
|
|
1732
|
+
print(f"Enter a number from 1 to {len(options)}.")
|
|
1733
|
+
|
|
1734
|
+
|
|
1690
1735
|
def handle_interrupt_input(num_actions: int = 1, is_generic: bool = False) -> Any:
|
|
1691
1736
|
"""
|
|
1692
1737
|
Handle user input for an interrupt using arrow key navigation, returning the
|
|
@@ -3215,13 +3260,17 @@ def main(
|
|
|
3215
3260
|
# stripped whenever stdout is not a TTY, matching well-behaved CLIs, and whenever
|
|
3216
3261
|
# NO_COLOR is set to a non-empty value (https://no-color.org), a live terminal
|
|
3217
3262
|
# included. An empty NO_COLOR doesn't count, per the convention. (gh #172)
|
|
3263
|
+
# TERM=dumb also strips color, and the spinner and approval menu drop their
|
|
3264
|
+
# escape-driven redraws (gh #182).
|
|
3218
3265
|
_is_tty = _is_a_tty(sys.stdout)
|
|
3219
3266
|
_stdin_is_tty = _stdin_is_interactive() # a mintty pty counts; NUL doesn't
|
|
3220
3267
|
global _QUIET
|
|
3221
3268
|
_QUIET = quiet or (
|
|
3222
3269
|
(bool(message or prompt_file) or verify_agent or not _stdin_is_tty) and not _is_tty
|
|
3223
3270
|
)
|
|
3224
|
-
|
|
3271
|
+
global _DUMB_TERM
|
|
3272
|
+
_DUMB_TERM = _is_dumb_term()
|
|
3273
|
+
if _QUIET or not _is_tty or os.environ.get("NO_COLOR") or _DUMB_TERM:
|
|
3225
3274
|
_disable_ansi()
|
|
3226
3275
|
|
|
3227
3276
|
# `langstage-cli init` — scaffold a runnable starter agent + langstage.toml, then
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: langstage-cli
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.44
|
|
4
4
|
Summary: The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph
|
|
5
5
|
Author-email: Kedar Dabhadkar <kdabhadk@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -322,7 +322,9 @@ cat prompt.txt | langstage-cli --demo
|
|
|
322
322
|
|
|
323
323
|
Pass `-q/--quiet` to force the same clean output in a terminal. Set
|
|
324
324
|
[`NO_COLOR`](https://no-color.org) to any non-empty value to turn color off in a terminal
|
|
325
|
-
too; the layout and spinner stay, uncolored.
|
|
325
|
+
too; the layout and spinner stay, uncolored. With `TERM=dumb` (Emacs shell, `vim :!`)
|
|
326
|
+
the CLI sends no escape sequences at all: no color, a spinner that prints once, and an
|
|
327
|
+
approval menu you answer by typing a number.
|
|
326
328
|
|
|
327
329
|
### Exit codes
|
|
328
330
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "langstage-cli"
|
|
7
|
-
version = "0.6.
|
|
7
|
+
version = "0.6.44"
|
|
8
8
|
description = "The terminal stage for your LangGraph agent — Claude Code-style CLI for any CompiledGraph"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.11"
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""``TERM=dumb`` gets no escape sequences at all (gh #182).
|
|
2
|
+
|
|
3
|
+
A dumb terminal (Emacs ``M-x shell``/``compile``, ``vim :!``, serial consoles) prints
|
|
4
|
+
escapes as literal text. ``main()`` honored ``NO_COLOR`` but not ``TERM=dumb``, and the
|
|
5
|
+
spinner and approval menu wrote line clears and cursor hide/show on their own. Now
|
|
6
|
+
``TERM=dumb`` strips color, the spinner prints one plain line, and the approval menu is
|
|
7
|
+
a numbered list answered by typing a number.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import io
|
|
11
|
+
import os
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
import time
|
|
15
|
+
from contextlib import redirect_stdout
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
from click.testing import CliRunner
|
|
19
|
+
|
|
20
|
+
from langstage_cli import cli
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@pytest.fixture
|
|
24
|
+
def dumb(monkeypatch):
|
|
25
|
+
monkeypatch.setattr(cli, "_DUMB_TERM", True)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _typed(monkeypatch, *lines):
|
|
29
|
+
it = iter(lines)
|
|
30
|
+
shown = []
|
|
31
|
+
|
|
32
|
+
def fake_input(prompt=""):
|
|
33
|
+
shown.append(prompt)
|
|
34
|
+
line = next(it)
|
|
35
|
+
if isinstance(line, BaseException):
|
|
36
|
+
raise line
|
|
37
|
+
return line
|
|
38
|
+
|
|
39
|
+
monkeypatch.setattr("builtins.input", fake_input)
|
|
40
|
+
return shown
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("value", ["dumb", "DUMB", " dumb "])
|
|
44
|
+
def test_term_dumb_is_detected(monkeypatch, value):
|
|
45
|
+
monkeypatch.setenv("TERM", value)
|
|
46
|
+
assert cli._is_dumb_term()
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@pytest.mark.parametrize("value", ["xterm-256color", "", "dumber"])
|
|
50
|
+
def test_other_terms_are_not_dumb(monkeypatch, value):
|
|
51
|
+
monkeypatch.setenv("TERM", value)
|
|
52
|
+
assert not cli._is_dumb_term()
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_term_dumb_strips_every_escape_on_a_terminal(tmp_path, monkeypatch):
|
|
56
|
+
monkeypatch.chdir(tmp_path)
|
|
57
|
+
monkeypatch.setattr(cli, "_is_a_tty", lambda stream: True)
|
|
58
|
+
monkeypatch.setenv("TERM", "dumb")
|
|
59
|
+
r = CliRunner().invoke(cli.main, ["--demo", "hi"])
|
|
60
|
+
assert r.exit_code == 0, r.output
|
|
61
|
+
assert "You said: hi" in r.output and "Loaded" in r.output, r.output
|
|
62
|
+
assert "\x1b" not in r.output, repr(r.output)
|
|
63
|
+
# The spinner never redraws: no carriage return anywhere.
|
|
64
|
+
assert "\r" not in r.output, repr(r.output)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_spinner_prints_one_plain_line_on_a_dumb_terminal(dumb, capsys):
|
|
68
|
+
spinner = cli.Spinner("Thinking")
|
|
69
|
+
spinner.start()
|
|
70
|
+
time.sleep(0.2) # an animating spinner would have drawn a couple of frames by now
|
|
71
|
+
spinner.stop()
|
|
72
|
+
assert capsys.readouterr().out == "Thinking...\n"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def test_menu_is_numbered_and_typed_on_a_dumb_terminal(dumb, monkeypatch):
|
|
76
|
+
monkeypatch.setattr(cli, "get_key", lambda: pytest.fail("no keypress reading"))
|
|
77
|
+
prompts = _typed(monkeypatch, "2")
|
|
78
|
+
buf = io.StringIO()
|
|
79
|
+
with redirect_stdout(buf):
|
|
80
|
+
choice = cli.select_option(["Approve", "Reject", "Exit"], "How would you like to proceed?")
|
|
81
|
+
assert choice == 1
|
|
82
|
+
out = buf.getvalue()
|
|
83
|
+
assert " 1. Approve\n 2. Reject\n 3. Exit\n" in out, repr(out)
|
|
84
|
+
assert "\x1b" not in out + "".join(prompts)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_menu_enter_picks_the_first_option(dumb, monkeypatch):
|
|
88
|
+
_typed(monkeypatch, "")
|
|
89
|
+
with redirect_stdout(io.StringIO()):
|
|
90
|
+
assert cli.select_option(["Approve", "Reject"]) == 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_menu_asks_again_on_a_bad_choice(dumb, monkeypatch):
|
|
94
|
+
prompts = _typed(monkeypatch, "9", "yes", "2")
|
|
95
|
+
buf = io.StringIO()
|
|
96
|
+
with redirect_stdout(buf):
|
|
97
|
+
assert cli.select_option(["Approve", "Reject"]) == 1
|
|
98
|
+
assert len(prompts) == 3
|
|
99
|
+
assert buf.getvalue().count("Enter a number from 1 to 2.") == 2
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@pytest.mark.parametrize("abort", [KeyboardInterrupt(), EOFError()], ids=["ctrl-c", "eof"])
|
|
103
|
+
def test_leaving_the_typed_menu_exits_paused(dumb, monkeypatch, abort):
|
|
104
|
+
_typed(monkeypatch, abort)
|
|
105
|
+
with redirect_stdout(io.StringIO()), pytest.raises(SystemExit) as exc:
|
|
106
|
+
cli.select_option(["Approve", "Exit"])
|
|
107
|
+
assert exc.value.code == 2
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _run_on_pty(tmp_path, term, args, answers=b""):
|
|
111
|
+
import pty
|
|
112
|
+
import select
|
|
113
|
+
|
|
114
|
+
env = {k: v for k, v in os.environ.items() if k not in ("NO_COLOR", "TERM")}
|
|
115
|
+
env["LANGSTAGE_CLI_SESSIONS_DIR"] = str(tmp_path / "_sessions")
|
|
116
|
+
env["TERM"] = term
|
|
117
|
+
master, slave = pty.openpty()
|
|
118
|
+
proc = subprocess.Popen(
|
|
119
|
+
[sys.executable, "-c", "from langstage_cli.cli import main; main()", *args],
|
|
120
|
+
stdin=slave,
|
|
121
|
+
stdout=slave,
|
|
122
|
+
stderr=slave,
|
|
123
|
+
cwd=tmp_path,
|
|
124
|
+
env=env,
|
|
125
|
+
close_fds=True,
|
|
126
|
+
)
|
|
127
|
+
os.close(slave)
|
|
128
|
+
out = b""
|
|
129
|
+
sent = False
|
|
130
|
+
deadline = time.monotonic() + 60
|
|
131
|
+
try:
|
|
132
|
+
while time.monotonic() < deadline:
|
|
133
|
+
ready, _, _ = select.select([master], [], [], 0.5)
|
|
134
|
+
if not ready:
|
|
135
|
+
if proc.poll() is not None:
|
|
136
|
+
break
|
|
137
|
+
continue
|
|
138
|
+
try:
|
|
139
|
+
data = os.read(master, 4096)
|
|
140
|
+
except OSError: # EIO: the child closed the terminal
|
|
141
|
+
break
|
|
142
|
+
if not data:
|
|
143
|
+
break
|
|
144
|
+
out += data
|
|
145
|
+
if answers and not sent and b"Choose 1-" in out:
|
|
146
|
+
os.write(master, answers)
|
|
147
|
+
sent = True
|
|
148
|
+
proc.wait(timeout=10)
|
|
149
|
+
finally:
|
|
150
|
+
if proc.poll() is None:
|
|
151
|
+
proc.kill()
|
|
152
|
+
os.close(master)
|
|
153
|
+
return proc.returncode, out.decode("utf-8", errors="replace")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
_HITL_AGENT = """
|
|
157
|
+
from langgraph.graph import StateGraph, START, END
|
|
158
|
+
from langgraph.graph.message import MessagesState
|
|
159
|
+
from langgraph.checkpoint.memory import MemorySaver
|
|
160
|
+
from langgraph.types import interrupt
|
|
161
|
+
from langchain_core.messages import AIMessage
|
|
162
|
+
|
|
163
|
+
def ask(state):
|
|
164
|
+
decision = interrupt({"action": "delete_file", "path": "/etc/hosts"})
|
|
165
|
+
return {"messages": [AIMessage(content=f"Decision was: {decision!r}")]}
|
|
166
|
+
|
|
167
|
+
g = StateGraph(MessagesState)
|
|
168
|
+
g.add_node("ask", ask)
|
|
169
|
+
g.add_edge(START, "ask")
|
|
170
|
+
g.add_edge("ask", END)
|
|
171
|
+
graph = g.compile(checkpointer=MemorySaver())
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="needs a POSIX pty")
|
|
176
|
+
def test_no_escapes_on_a_real_dumb_terminal(tmp_path):
|
|
177
|
+
code, out = _run_on_pty(tmp_path, "dumb", ["--demo", "hi"])
|
|
178
|
+
assert code == 0, out
|
|
179
|
+
assert "You said: hi" in out and "╭" in out, repr(out)
|
|
180
|
+
assert "\x1b" not in out, repr(out)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.mark.skipif(sys.platform == "win32", reason="needs a POSIX pty")
|
|
184
|
+
def test_approval_menu_works_on_a_real_dumb_terminal(tmp_path):
|
|
185
|
+
(tmp_path / "hitl_agent.py").write_text(_HITL_AGENT, encoding="utf-8")
|
|
186
|
+
code, out = _run_on_pty(tmp_path, "dumb", ["-a", "hitl_agent.py:graph", "go"], b"1\n")
|
|
187
|
+
assert "1. " in out and "Choose 1-" in out, repr(out)
|
|
188
|
+
assert "Decision was" in out, repr(out)
|
|
189
|
+
assert code == 0, out
|
|
190
|
+
assert "\x1b" not in out, repr(out)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|