langstage-cli 0.6.43__tar.gz → 0.6.45__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.45}/PKG-INFO +4 -2
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/README.md +3 -1
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/cli.py +75 -15
- {langstage_cli-0.6.43 → langstage_cli-0.6.45/langstage_cli.egg-info}/PKG-INFO +4 -2
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/SOURCES.txt +2 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/pyproject.toml +1 -1
- langstage_cli-0.6.45/tests/test_dumb_term.py +190 -0
- langstage_cli-0.6.45/tests/test_tool_card_args.py +100 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/LICENSE +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/exit_codes.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli/sessions.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/requires.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/setup.cfg +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_advertised_wave4.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_approval_menu_abort.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_async_mode.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_config_command.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_configurable.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_deferred_pass.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_exit_codes.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_generic_interrupt.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_history_persist.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_hitl_approval_preview.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_hitl_resume_warning.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_init.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_interrupt_non_tty.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_legacy_config_home.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_load_error.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_message_boundaries.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_color.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_no_persist_resume.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_persist_env.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_quiet_output.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_reasoning_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_render_markdown.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_session_lifecycle.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_sessions.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_show_config_malformed.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spec_loading_core.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_spinner_reply_survives.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stdin_interactive.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stdin_single_shot.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_streaming_marker.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_terminal_width.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_toml_relative_paths.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_tool_output_escape.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_turn_error_verbose.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_turn_exit_and_render.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_unicode_console.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_verbose_command.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_verify_flag.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/tests/test_workspace.py +0 -0
- {langstage_cli-0.6.43 → langstage_cli-0.6.45}/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.45
|
|
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)
|
|
@@ -1121,20 +1140,31 @@ def _absolutize_file_spec(spec: str) -> str:
|
|
|
1121
1140
|
return f"{Path(path_part).expanduser().resolve()}{suffix}"
|
|
1122
1141
|
|
|
1123
1142
|
|
|
1124
|
-
|
|
1125
|
-
|
|
1143
|
+
# Per-value cap on the tool-call card (default output; ``-v`` shows values in full).
|
|
1144
|
+
_TOOL_ARG_VALUE_MAX = 50
|
|
1145
|
+
|
|
1126
1146
|
|
|
1127
|
-
|
|
1128
|
-
|
|
1147
|
+
def get_tool_arg_preview(args: Dict[str, Any], verbose: bool = False) -> str:
|
|
1148
|
+
"""The arguments a tool was called with, for the tool-call card.
|
|
1149
|
+
|
|
1150
|
+
A lone argument shows as its bare value (``read_file └─ docs/a.md``). Several show
|
|
1151
|
+
as ``name=value`` pairs, every one of them: showing only the first misstated the
|
|
1152
|
+
call, ``add(a=17, b=25)`` read as "add 17" (gh #184). Each value is capped at 50
|
|
1153
|
+
characters unless ``verbose``. Values are escaped before they are truncated, so a
|
|
1154
|
+
tool arg can't move the cursor or retitle the terminal, and a cut can't leave half
|
|
1155
|
+
an escape sequence live (gh #180).
|
|
1129
1156
|
"""
|
|
1130
1157
|
if not args:
|
|
1131
1158
|
return ""
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
if len(
|
|
1136
|
-
|
|
1137
|
-
|
|
1159
|
+
limit = None if verbose else _TOOL_ARG_VALUE_MAX
|
|
1160
|
+
if not isinstance(args, dict):
|
|
1161
|
+
return _approval_text(args, limit)
|
|
1162
|
+
if len(args) == 1:
|
|
1163
|
+
(value,) = args.values()
|
|
1164
|
+
return _approval_text(str(value), limit)
|
|
1165
|
+
return ", ".join(
|
|
1166
|
+
f"{_approval_text(str(k), None)}={_approval_text(v, limit)}" for k, v in args.items()
|
|
1167
|
+
)
|
|
1138
1168
|
|
|
1139
1169
|
|
|
1140
1170
|
# Per-value cap for the HITL approval preview. Values are truncated, never dropped: the
|
|
@@ -1476,7 +1506,7 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
|
|
|
1476
1506
|
for tool_call in chunk["tool_calls"]:
|
|
1477
1507
|
tool_name = _escape_controls(str(tool_call["name"])) # gh #180
|
|
1478
1508
|
args = tool_call.get("args", {})
|
|
1479
|
-
arg_preview = get_tool_arg_preview(args)
|
|
1509
|
+
arg_preview = get_tool_arg_preview(args, verbose)
|
|
1480
1510
|
|
|
1481
1511
|
safe_print(f"\n{GREEN}● {tool_name}{RESET}")
|
|
1482
1512
|
if arg_preview:
|
|
@@ -1633,6 +1663,8 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
|
1633
1663
|
Returns:
|
|
1634
1664
|
Index of selected option (0-based)
|
|
1635
1665
|
"""
|
|
1666
|
+
if _DUMB_TERM:
|
|
1667
|
+
return _select_option_typed(options, prompt)
|
|
1636
1668
|
selected = 0
|
|
1637
1669
|
num_options = len(options)
|
|
1638
1670
|
|
|
@@ -1687,6 +1719,30 @@ def select_option(options: List[str], prompt: str = "Select an option:") -> int:
|
|
|
1687
1719
|
print("\033[?25h", end="")
|
|
1688
1720
|
|
|
1689
1721
|
|
|
1722
|
+
def _select_option_typed(options: List[str], prompt: str) -> int:
|
|
1723
|
+
"""The approval menu for a dumb terminal: a numbered list and a typed choice.
|
|
1724
|
+
|
|
1725
|
+
The arrow-key menu redraws itself with cursor-up and line-clear escapes, which a
|
|
1726
|
+
dumb terminal prints as literal text, and editor shells (Emacs ``M-x shell``) send
|
|
1727
|
+
whole lines, not keypresses. Enter alone picks the first option. Ctrl-C or EOF
|
|
1728
|
+
abandons the prompt the same way the arrow-key menu does: exit 2, paused. (gh #182)
|
|
1729
|
+
"""
|
|
1730
|
+
print(f"\n{prompt}")
|
|
1731
|
+
for i, opt in enumerate(options, 1):
|
|
1732
|
+
print(f" {i}. {opt}")
|
|
1733
|
+
while True:
|
|
1734
|
+
try:
|
|
1735
|
+
raw = input(f"Choose 1-{len(options)} [1]: ").strip()
|
|
1736
|
+
except (KeyboardInterrupt, EOFError):
|
|
1737
|
+
print()
|
|
1738
|
+
sys.exit(EXIT_PAUSED)
|
|
1739
|
+
if not raw:
|
|
1740
|
+
return 0
|
|
1741
|
+
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
|
1742
|
+
return int(raw) - 1
|
|
1743
|
+
print(f"Enter a number from 1 to {len(options)}.")
|
|
1744
|
+
|
|
1745
|
+
|
|
1690
1746
|
def handle_interrupt_input(num_actions: int = 1, is_generic: bool = False) -> Any:
|
|
1691
1747
|
"""
|
|
1692
1748
|
Handle user input for an interrupt using arrow key navigation, returning the
|
|
@@ -3215,13 +3271,17 @@ def main(
|
|
|
3215
3271
|
# stripped whenever stdout is not a TTY, matching well-behaved CLIs, and whenever
|
|
3216
3272
|
# NO_COLOR is set to a non-empty value (https://no-color.org), a live terminal
|
|
3217
3273
|
# included. An empty NO_COLOR doesn't count, per the convention. (gh #172)
|
|
3274
|
+
# TERM=dumb also strips color, and the spinner and approval menu drop their
|
|
3275
|
+
# escape-driven redraws (gh #182).
|
|
3218
3276
|
_is_tty = _is_a_tty(sys.stdout)
|
|
3219
3277
|
_stdin_is_tty = _stdin_is_interactive() # a mintty pty counts; NUL doesn't
|
|
3220
3278
|
global _QUIET
|
|
3221
3279
|
_QUIET = quiet or (
|
|
3222
3280
|
(bool(message or prompt_file) or verify_agent or not _stdin_is_tty) and not _is_tty
|
|
3223
3281
|
)
|
|
3224
|
-
|
|
3282
|
+
global _DUMB_TERM
|
|
3283
|
+
_DUMB_TERM = _is_dumb_term()
|
|
3284
|
+
if _QUIET or not _is_tty or os.environ.get("NO_COLOR") or _DUMB_TERM:
|
|
3225
3285
|
_disable_ansi()
|
|
3226
3286
|
|
|
3227
3287
|
# `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.45
|
|
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
|
|
@@ -58,6 +59,7 @@ tests/test_stream_mode.py
|
|
|
58
59
|
tests/test_streaming_marker.py
|
|
59
60
|
tests/test_terminal_width.py
|
|
60
61
|
tests/test_toml_relative_paths.py
|
|
62
|
+
tests/test_tool_card_args.py
|
|
61
63
|
tests/test_tool_output_escape.py
|
|
62
64
|
tests/test_turn_error_verbose.py
|
|
63
65
|
tests/test_turn_exit_and_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.
|
|
7
|
+
version = "0.6.45"
|
|
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)
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""The tool-call card shows every argument, by name (gh #184).
|
|
2
|
+
|
|
3
|
+
It used to show only the first argument's value, so ``add(a=17, b=25)`` rendered as
|
|
4
|
+
``add └─ 17`` and ``transfer_funds(to, amount, currency)`` as ``└─ alice``, in default
|
|
5
|
+
and ``-v`` output alike. Under ``--no-interactive`` the card is the only record of
|
|
6
|
+
what ran.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import io
|
|
10
|
+
import re
|
|
11
|
+
from contextlib import redirect_stdout
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
pytest.importorskip("ag_ui_langgraph")
|
|
16
|
+
|
|
17
|
+
from langchain_core.messages import AIMessage, ToolMessage # noqa: E402
|
|
18
|
+
from langgraph.graph import END, START, StateGraph # noqa: E402
|
|
19
|
+
from langgraph.graph.message import MessagesState # noqa: E402
|
|
20
|
+
|
|
21
|
+
from langstage_cli import cli # noqa: E402
|
|
22
|
+
from langstage_cli.agui_stream import build_session_agent # noqa: E402
|
|
23
|
+
from langstage_cli.cli import get_tool_arg_preview, run_single_turn_agui # noqa: E402
|
|
24
|
+
|
|
25
|
+
_SGR = re.compile(r"\x1b\[[0-9;]*m")
|
|
26
|
+
TRANSFER = {"to": "alice", "amount": 1000000, "currency": "USD"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_every_argument_is_named():
|
|
30
|
+
assert get_tool_arg_preview({"a": 17, "b": 25}) == "a=17, b=25"
|
|
31
|
+
assert get_tool_arg_preview(TRANSFER) == "to=alice, amount=1000000, currency=USD"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def test_a_single_argument_stays_a_bare_value():
|
|
35
|
+
assert get_tool_arg_preview({"path": "docs/a.md"}) == "docs/a.md"
|
|
36
|
+
assert get_tool_arg_preview({}) == ""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def test_values_are_capped_but_never_dropped():
|
|
40
|
+
long = "x" * 200
|
|
41
|
+
preview = get_tool_arg_preview({"path": "a.md", "content": long, "mode": "w"})
|
|
42
|
+
assert preview == f"path=a.md, content={'x' * 50}..., mode=w"
|
|
43
|
+
# Structured values read as JSON, not Python reprs.
|
|
44
|
+
assert (
|
|
45
|
+
get_tool_arg_preview({"ids": [1, 2], "opts": {"k": "v"}}) == 'ids=[1, 2], opts={"k": "v"}'
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_verbose_shows_values_in_full():
|
|
50
|
+
long = "y" * 200
|
|
51
|
+
assert (
|
|
52
|
+
get_tool_arg_preview({"path": "a.md", "content": long}, verbose=True)
|
|
53
|
+
== f"path=a.md, content={long}"
|
|
54
|
+
)
|
|
55
|
+
assert get_tool_arg_preview({"q": long}, verbose=True) == long
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_names_and_values_are_escaped():
|
|
59
|
+
preview = get_tool_arg_preview({"p\x1b[2K": "a\x1b]0;t\x07", "b": "c\nd"})
|
|
60
|
+
assert "\x1b" not in preview and "\x07" not in preview and "\n" not in preview
|
|
61
|
+
assert preview == r"p\x1b[2K=a\x1b]0;t\x07, b=c\nd"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _transfer_graph():
|
|
65
|
+
def call(state):
|
|
66
|
+
tc = {"id": "t1", "name": "transfer_funds", "args": TRANSFER}
|
|
67
|
+
return {"messages": [AIMessage(content="", tool_calls=[tc])]}
|
|
68
|
+
|
|
69
|
+
def tool(state):
|
|
70
|
+
return {"messages": [ToolMessage(content="ok", tool_call_id="t1")]}
|
|
71
|
+
|
|
72
|
+
def reply(state):
|
|
73
|
+
return {"messages": [AIMessage(content="Transferred.")]}
|
|
74
|
+
|
|
75
|
+
g = StateGraph(MessagesState)
|
|
76
|
+
for n, f in [("call", call), ("tool", tool), ("reply", reply)]:
|
|
77
|
+
g.add_node(n, f)
|
|
78
|
+
g.add_edge(START, "call")
|
|
79
|
+
g.add_edge("call", "tool")
|
|
80
|
+
g.add_edge("tool", "reply")
|
|
81
|
+
g.add_edge("reply", END)
|
|
82
|
+
return g.compile()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.parametrize("verbose", [False, True])
|
|
86
|
+
async def test_card_in_a_real_turn(monkeypatch, verbose):
|
|
87
|
+
"""The issue's keyless repro, through the AG-UI stream the CLI renders."""
|
|
88
|
+
monkeypatch.setattr(cli, "Spinner", lambda *a, **k: None)
|
|
89
|
+
buf = io.StringIO()
|
|
90
|
+
with redirect_stdout(buf):
|
|
91
|
+
await run_single_turn_agui(
|
|
92
|
+
build_session_agent(_transfer_graph()),
|
|
93
|
+
"pay alice",
|
|
94
|
+
"t-184",
|
|
95
|
+
interactive=False,
|
|
96
|
+
verbose=verbose,
|
|
97
|
+
)
|
|
98
|
+
plain = _SGR.sub("", buf.getvalue())
|
|
99
|
+
assert "● transfer_funds" in plain, plain
|
|
100
|
+
assert "└─ to=alice, amount=1000000, currency=USD" in plain, plain
|
|
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
|