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.
Files changed (71) hide show
  1. {langstage_cli-0.6.43/langstage_cli.egg-info → langstage_cli-0.6.44}/PKG-INFO +4 -2
  2. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/README.md +3 -1
  3. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/cli.py +53 -4
  4. {langstage_cli-0.6.43 → langstage_cli-0.6.44/langstage_cli.egg-info}/PKG-INFO +4 -2
  5. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/SOURCES.txt +1 -0
  6. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/pyproject.toml +1 -1
  7. langstage_cli-0.6.44/tests/test_dumb_term.py +190 -0
  8. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/LICENSE +0 -0
  9. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/deepagent_code/__init__.py +0 -0
  10. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/__init__.py +0 -0
  11. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/agui_stream.py +0 -0
  12. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/config.py +0 -0
  13. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/exit_codes.py +0 -0
  14. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli/sessions.py +0 -0
  15. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/dependency_links.txt +0 -0
  16. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/entry_points.txt +0 -0
  17. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/requires.txt +0 -0
  18. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/langstage_cli.egg-info/top_level.txt +0 -0
  19. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/setup.cfg +0 -0
  20. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_advertised_wave4.py +0 -0
  21. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_agui_stream.py +0 -0
  22. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_approval_menu_abort.py +0 -0
  23. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_async_mode.py +0 -0
  24. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_checkpointer.py +0 -0
  25. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_cli.py +0 -0
  26. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_cli_help.py +0 -0
  27. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_codeconfig.py +0 -0
  28. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_config.py +0 -0
  29. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_config_command.py +0 -0
  30. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_configurable.py +0 -0
  31. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_deferred_pass.py +0 -0
  32. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_exit_codes.py +0 -0
  33. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_generic_interrupt.py +0 -0
  34. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_help_render.py +0 -0
  35. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_history_persist.py +0 -0
  36. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_hitl_approval_preview.py +0 -0
  37. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_hitl_resume_warning.py +0 -0
  38. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_init.py +0 -0
  39. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_interrupt_non_tty.py +0 -0
  40. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_legacy_config_home.py +0 -0
  41. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_load_error.py +0 -0
  42. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_message_boundaries.py +0 -0
  43. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_color.py +0 -0
  44. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_interactive_approve.py +0 -0
  45. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_no_persist_resume.py +0 -0
  46. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_persist_env.py +0 -0
  47. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_quiet_output.py +0 -0
  48. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_reasoning_render.py +0 -0
  49. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_rename_shim.py +0 -0
  50. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_render_markdown.py +0 -0
  51. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_session_lifecycle.py +0 -0
  52. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_sessions.py +0 -0
  53. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_show_config.py +0 -0
  54. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_show_config_malformed.py +0 -0
  55. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spec_loading_core.py +0 -0
  56. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spec_resolution.py +0 -0
  57. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_spinner_reply_survives.py +0 -0
  58. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stdin_interactive.py +0 -0
  59. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stdin_single_shot.py +0 -0
  60. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_stream_mode.py +0 -0
  61. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_streaming_marker.py +0 -0
  62. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_terminal_width.py +0 -0
  63. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_toml_relative_paths.py +0 -0
  64. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_tool_output_escape.py +0 -0
  65. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_turn_error_verbose.py +0 -0
  66. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_turn_exit_and_render.py +0 -0
  67. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_unicode_console.py +0 -0
  68. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_verbose_command.py +0 -0
  69. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_verify_flag.py +0 -0
  70. {langstage_cli-0.6.43 → langstage_cli-0.6.44}/tests/test_workspace.py +0 -0
  71. {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.43
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
- self.running = True
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
- if _QUIET or not _is_tty or os.environ.get("NO_COLOR"):
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.43
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
 
@@ -26,6 +26,7 @@ tests/test_config.py
26
26
  tests/test_config_command.py
27
27
  tests/test_configurable.py
28
28
  tests/test_deferred_pass.py
29
+ tests/test_dumb_term.py
29
30
  tests/test_exit_codes.py
30
31
  tests/test_generic_interrupt.py
31
32
  tests/test_help_render.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.43"
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