langstage-cli 0.6.45__tar.gz → 0.6.47__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.45/langstage_cli.egg-info → langstage_cli-0.6.47}/PKG-INFO +1 -1
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/cli.py +54 -10
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/sessions.py +26 -3
- {langstage_cli-0.6.45 → langstage_cli-0.6.47/langstage_cli.egg-info}/PKG-INFO +1 -1
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/SOURCES.txt +2 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/pyproject.toml +1 -1
- langstage_cli-0.6.47/tests/test_routine_1024.py +122 -0
- langstage_cli-0.6.47/tests/test_usage_hint_186.py +52 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/LICENSE +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/README.md +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/exit_codes.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/requires.txt +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/setup.cfg +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_advertised_wave4.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_approval_menu_abort.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_async_mode.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_config.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_config_command.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_configurable.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_deferred_pass.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_dumb_term.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_exit_codes.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_generic_interrupt.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_history_persist.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_hitl_approval_preview.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_hitl_resume_warning.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_init.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_interrupt_non_tty.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_legacy_config_home.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_load_error.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_message_boundaries.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_color.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_persist_resume.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_persist_env.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_quiet_output.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_reasoning_render.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_render_markdown.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_session_lifecycle.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_sessions.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_show_config_malformed.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spec_loading_core.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spinner_reply_survives.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stdin_interactive.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stdin_single_shot.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_streaming_marker.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_terminal_width.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_toml_relative_paths.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_tool_card_args.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_tool_output_escape.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_turn_error_verbose.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_turn_exit_and_render.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_unicode_console.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_verbose_command.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_verify_flag.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_workspace.py +0 -0
- {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_wrong_type_graph.py +0 -0
|
@@ -8,6 +8,7 @@ import copy
|
|
|
8
8
|
import json
|
|
9
9
|
import os
|
|
10
10
|
import re
|
|
11
|
+
import signal
|
|
11
12
|
import subprocess
|
|
12
13
|
import sys
|
|
13
14
|
import threading
|
|
@@ -1001,6 +1002,15 @@ def _default_agent_path() -> Optional[Path]:
|
|
|
1001
1002
|
return path if path.exists() else None
|
|
1002
1003
|
|
|
1003
1004
|
|
|
1005
|
+
# A MESSAGE that is really an agent spec: ``my_agent.py:graph`` or ``pkg.module:agent``.
|
|
1006
|
+
_SPEC_LIKE = re.compile(r"^(?:(?:[A-Za-z]:)?[^\s:]+\.py|[A-Za-z_][\w.]*):[A-Za-z_]\w*$")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
def _looks_like_agent_spec(message: Optional[str]) -> bool:
|
|
1010
|
+
"""Whether a positional MESSAGE looks like an agent spec meant for ``-a`` (gh #186)."""
|
|
1011
|
+
return bool(message) and bool(_SPEC_LIKE.match(message.strip()))
|
|
1012
|
+
|
|
1013
|
+
|
|
1004
1014
|
def _no_agent_toml_hint(cfg: Any, toml_sources: list) -> List[str]:
|
|
1005
1015
|
"""Lines explaining why a ``langstage.toml`` that was read supplied no spec (gh #148).
|
|
1006
1016
|
|
|
@@ -1732,7 +1742,7 @@ def _select_option_typed(options: List[str], prompt: str) -> int:
|
|
|
1732
1742
|
print(f" {i}. {opt}")
|
|
1733
1743
|
while True:
|
|
1734
1744
|
try:
|
|
1735
|
-
raw =
|
|
1745
|
+
raw = _prompt_line(f"Choose 1-{len(options)} [1]: ").strip()
|
|
1736
1746
|
except (KeyboardInterrupt, EOFError):
|
|
1737
1747
|
print()
|
|
1738
1748
|
sys.exit(EXIT_PAUSED)
|
|
@@ -1743,6 +1753,25 @@ def _select_option_typed(options: List[str], prompt: str) -> int:
|
|
|
1743
1753
|
print(f"Enter a number from 1 to {len(options)}.")
|
|
1744
1754
|
|
|
1745
1755
|
|
|
1756
|
+
def _prompt_line(prompt: str) -> str:
|
|
1757
|
+
"""``input()`` at an approval prompt, where one Ctrl-C raises ``KeyboardInterrupt``.
|
|
1758
|
+
|
|
1759
|
+
The approval prompts run inside the turn's ``asyncio.run``, whose SIGINT handler
|
|
1760
|
+
(Python 3.11+) only schedules a task cancel on the first Ctrl-C. A blocking
|
|
1761
|
+
``input()`` never yields to the loop, so that press was swallowed and only a
|
|
1762
|
+
second one exited (gh #189). The default handler is put back for the duration of
|
|
1763
|
+
the read, so the first press reaches the caller's ``except KeyboardInterrupt``.
|
|
1764
|
+
"""
|
|
1765
|
+
try:
|
|
1766
|
+
previous = signal.signal(signal.SIGINT, signal.default_int_handler)
|
|
1767
|
+
except ValueError: # not the main thread: signal handlers can't be changed here
|
|
1768
|
+
return input(prompt)
|
|
1769
|
+
try:
|
|
1770
|
+
return input(prompt)
|
|
1771
|
+
finally:
|
|
1772
|
+
signal.signal(signal.SIGINT, previous)
|
|
1773
|
+
|
|
1774
|
+
|
|
1746
1775
|
def handle_interrupt_input(num_actions: int = 1, is_generic: bool = False) -> Any:
|
|
1747
1776
|
"""
|
|
1748
1777
|
Handle user input for an interrupt using arrow key navigation, returning the
|
|
@@ -1834,7 +1863,7 @@ def _approval_input() -> str:
|
|
|
1834
1863
|
turn stays paused on the interrupt, so exit 2 (ADR 0007, gh #174).
|
|
1835
1864
|
"""
|
|
1836
1865
|
try:
|
|
1837
|
-
return
|
|
1866
|
+
return _prompt_line(make_prompt("❯", BLUE))
|
|
1838
1867
|
except (KeyboardInterrupt, EOFError):
|
|
1839
1868
|
print()
|
|
1840
1869
|
sys.exit(EXIT_PAUSED)
|
|
@@ -1963,7 +1992,8 @@ def cmd_status(args: str, context: Dict[str, Any]) -> Optional[str]:
|
|
|
1963
1992
|
print(f"\n{BOLD}{BRIGHT_CYAN}Session Status{RESET}")
|
|
1964
1993
|
print(f"{DIM}{'─' * 30}{RESET}")
|
|
1965
1994
|
print(f" {DIM}Agent:{RESET} {agent_name}")
|
|
1966
|
-
|
|
1995
|
+
shown_id = sessions.display_id(thread_id)
|
|
1996
|
+
print(f" {DIM}Thread ID:{RESET} {shown_id}{'...' if shown_id != thread_id else ''}")
|
|
1967
1997
|
# No "Mode: async/sync" line: since ADR 0003 there is exactly one (async) path,
|
|
1968
1998
|
# so the value was reporting a distinction the runtime does not have (gh #88).
|
|
1969
1999
|
print(f" {DIM}Verbose:{RESET} {'on' if verbose else 'off'}")
|
|
@@ -2835,7 +2865,8 @@ def _resolve_resume_thread(
|
|
|
2835
2865
|
matches = sessions.match_threads(workspace, resume_id)
|
|
2836
2866
|
if len(matches) > 1:
|
|
2837
2867
|
# An ambiguous prefix is not "no match" (gh #124): say so and list them.
|
|
2838
|
-
|
|
2868
|
+
# Each match in a form --resume accepts and that tells them apart (gh #188).
|
|
2869
|
+
shown = ", ".join(sessions.display_id(t, matches) for t in matches[:10])
|
|
2839
2870
|
more = f", +{len(matches) - 10} more" if len(matches) > 10 else ""
|
|
2840
2871
|
_status(
|
|
2841
2872
|
f"{RED}⏺ Error: prefix '{resume_id}' is ambiguous — it matches "
|
|
@@ -2855,7 +2886,7 @@ def _resolve_resume_thread(
|
|
|
2855
2886
|
# The session's state lives in the agent's own checkpointer (gh #128): the CLI
|
|
2856
2887
|
# holds no history for it, so the resume restores only what that checkpointer kept.
|
|
2857
2888
|
_status(
|
|
2858
|
-
f"{YELLOW}note: session {thread
|
|
2889
|
+
f"{YELLOW}note: session {sessions.display_id(thread)} ran on the agent's own checkpointer; "
|
|
2859
2890
|
f"the CLI stored no history for it, so this resume restores only what that "
|
|
2860
2891
|
f"checkpointer kept (nothing, if it is in-memory).{RESET}"
|
|
2861
2892
|
)
|
|
@@ -3039,6 +3070,9 @@ def _print_sessions(workspace: Path) -> None:
|
|
|
3039
3070
|
return
|
|
3040
3071
|
print(f"\n{BOLD}{BRIGHT_CYAN}Sessions{RESET} {DIM}({workspace}){RESET}")
|
|
3041
3072
|
print(f"{DIM}{'─' * 40}{RESET}")
|
|
3073
|
+
all_ids = [tid for tid, _ in rows]
|
|
3074
|
+
shown = {tid: sessions.display_id(tid, all_ids) for tid in all_ids}
|
|
3075
|
+
width = max(len(s) for s in shown.values())
|
|
3042
3076
|
for tid, entry in rows:
|
|
3043
3077
|
when = _format_ts(entry.get("updated"))
|
|
3044
3078
|
first = entry.get("first_message") or f"{DIM}(no message yet){RESET}"
|
|
@@ -3049,7 +3083,9 @@ def _print_sessions(workspace: Path) -> None:
|
|
|
3049
3083
|
if entry.get("checkpointer") == "agent"
|
|
3050
3084
|
else ""
|
|
3051
3085
|
)
|
|
3052
|
-
|
|
3086
|
+
# A pinned id is shown in full and a minted UUID by its shortest unique prefix, so
|
|
3087
|
+
# two sessions never look alike and each shown id works with --resume (gh #188).
|
|
3088
|
+
print(f" {CYAN}{shown[tid]:<{width}}{RESET} {DIM}{when}{RESET} {first}{mark}")
|
|
3053
3089
|
print(f"\n{DIM}Resume with: langstage-cli --resume <id> (or -c for the most recent){RESET}\n")
|
|
3054
3090
|
|
|
3055
3091
|
|
|
@@ -3468,10 +3504,18 @@ def main(
|
|
|
3468
3504
|
# of only pointing at the env var (gh #148).
|
|
3469
3505
|
for line in _no_agent_toml_hint(cfg, toml_sources):
|
|
3470
3506
|
_status(f"{YELLOW}{line}{RESET}")
|
|
3507
|
+
# The spec went in as the MESSAGE: name the command that works (gh #186).
|
|
3508
|
+
if _looks_like_agent_spec(message):
|
|
3509
|
+
_status(
|
|
3510
|
+
f"{YELLOW}The agent spec goes after -a: langstage-cli -a {message.strip()}{RESET}"
|
|
3511
|
+
)
|
|
3471
3512
|
_status(f"\n{DIM}Usage:{RESET}")
|
|
3472
|
-
_status(" langstage-cli path/to/agent.py:graph")
|
|
3473
|
-
_status(" langstage-cli mypackage.module:agent")
|
|
3474
|
-
_status(
|
|
3513
|
+
_status(" langstage-cli -a path/to/agent.py:graph")
|
|
3514
|
+
_status(" langstage-cli -a mypackage.module:agent")
|
|
3515
|
+
_status(
|
|
3516
|
+
f"\n{DIM}Or set LANGSTAGE_AGENT_SPEC, put the spec under [agent] spec in "
|
|
3517
|
+
f"langstage.toml, or run `langstage-cli init` for a starter agent{RESET}"
|
|
3518
|
+
)
|
|
3475
3519
|
sys.exit(1)
|
|
3476
3520
|
|
|
3477
3521
|
# The BASE directory a relative spec resolves against depends on WHERE it came
|
|
@@ -3585,7 +3629,7 @@ def main(
|
|
|
3585
3629
|
if use_durable:
|
|
3586
3630
|
persist_sqlite_path = _snapshot_session_store(workspace)
|
|
3587
3631
|
_status(
|
|
3588
|
-
f"{DIM}⏺ --no-persist: resuming session {session_thread
|
|
3632
|
+
f"{DIM}⏺ --no-persist: resuming session {sessions.display_id(session_thread)} read-only — "
|
|
3589
3633
|
f"this run will not be saved.{RESET}"
|
|
3590
3634
|
)
|
|
3591
3635
|
if persist_sqlite_path is None and use_durable:
|
|
@@ -26,9 +26,10 @@ Two pieces live here:
|
|
|
26
26
|
import hashlib
|
|
27
27
|
import json
|
|
28
28
|
import os
|
|
29
|
+
import re
|
|
29
30
|
import time
|
|
30
31
|
from pathlib import Path
|
|
31
|
-
from typing import Dict, List, Optional, Tuple
|
|
32
|
+
from typing import Dict, Iterable, List, Optional, Tuple
|
|
32
33
|
|
|
33
34
|
from langstage_core.host.config import _global_toml_path
|
|
34
35
|
|
|
@@ -40,6 +41,10 @@ _SESSIONS_DIR_ENV = "LANGSTAGE_CLI_SESSIONS_DIR"
|
|
|
40
41
|
|
|
41
42
|
_SNIPPET_MAX = 60
|
|
42
43
|
|
|
44
|
+
# The ids the CLI mints itself (uuid4). Only these are shortened for display.
|
|
45
|
+
_UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
46
|
+
_SHORT_ID_MIN = 8
|
|
47
|
+
|
|
43
48
|
|
|
44
49
|
def _config_home() -> Path:
|
|
45
50
|
"""The LangStage config home — ``LANGSTAGE_CONFIG_HOME`` (then legacy
|
|
@@ -185,8 +190,8 @@ def most_recent_thread(workspace: Path) -> Optional[str]:
|
|
|
185
190
|
def resolve_thread(workspace: Path, ref: str) -> Optional[str]:
|
|
186
191
|
"""Resolve a ``--resume`` reference to a full thread id.
|
|
187
192
|
|
|
188
|
-
Accepts an exact id or an unambiguous prefix (
|
|
189
|
-
|
|
193
|
+
Accepts an exact id or an unambiguous prefix (``--list-sessions`` shows CLI-minted
|
|
194
|
+
UUIDs in a short form, see ``display_id``). Returns the full id, or ``None`` if it matches nothing / is
|
|
190
195
|
ambiguous.
|
|
191
196
|
"""
|
|
192
197
|
matches = match_threads(workspace, ref)
|
|
@@ -208,6 +213,24 @@ def match_threads(workspace: Path, ref: str) -> List[str]:
|
|
|
208
213
|
return [tid for tid, _ in matches]
|
|
209
214
|
|
|
210
215
|
|
|
216
|
+
def display_id(thread_id: str, others: Iterable[str] = ()) -> str:
|
|
217
|
+
"""How a session id is printed (gh #188).
|
|
218
|
+
|
|
219
|
+
A CLI-minted UUID is shortened to its shortest prefix of at least 8 characters
|
|
220
|
+
that no id in ``others`` shares, like ``git log --abbrev``. Any other id (a pinned
|
|
221
|
+
``[configurable] thread_id`` the user named, e.g. ``project-alpha``) is printed in
|
|
222
|
+
full: such names often share a prefix, and the user has to type one back to
|
|
223
|
+
``--resume``. Either form is accepted by ``--resume``.
|
|
224
|
+
"""
|
|
225
|
+
if not _UUID_RE.match(thread_id):
|
|
226
|
+
return thread_id
|
|
227
|
+
rivals = [o for o in others if o != thread_id]
|
|
228
|
+
n = _SHORT_ID_MIN
|
|
229
|
+
while n < len(thread_id) and any(o.startswith(thread_id[:n]) for o in rivals):
|
|
230
|
+
n += 1
|
|
231
|
+
return thread_id[:n]
|
|
232
|
+
|
|
233
|
+
|
|
211
234
|
def list_sessions(workspace: Path) -> List[Tuple[str, dict]]:
|
|
212
235
|
"""All sessions for this workspace, most-recently-updated first."""
|
|
213
236
|
sessions = load_index(workspace)
|
|
@@ -46,6 +46,7 @@ tests/test_quiet_output.py
|
|
|
46
46
|
tests/test_reasoning_render.py
|
|
47
47
|
tests/test_rename_shim.py
|
|
48
48
|
tests/test_render_markdown.py
|
|
49
|
+
tests/test_routine_1024.py
|
|
49
50
|
tests/test_session_lifecycle.py
|
|
50
51
|
tests/test_sessions.py
|
|
51
52
|
tests/test_show_config.py
|
|
@@ -64,6 +65,7 @@ tests/test_tool_output_escape.py
|
|
|
64
65
|
tests/test_turn_error_verbose.py
|
|
65
66
|
tests/test_turn_exit_and_render.py
|
|
66
67
|
tests/test_unicode_console.py
|
|
68
|
+
tests/test_usage_hint_186.py
|
|
67
69
|
tests/test_verbose_command.py
|
|
68
70
|
tests/test_verify_flag.py
|
|
69
71
|
tests/test_workspace.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.47"
|
|
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,122 @@
|
|
|
1
|
+
"""gh #188: session ids are printed so they can be told apart and typed back.
|
|
2
|
+
gh #189: one Ctrl-C at a typed approval prompt exits 2.
|
|
3
|
+
|
|
4
|
+
- ``--list-sessions`` and the ambiguous-``--resume`` error cut every id to 8
|
|
5
|
+
characters, so pinned ``[configurable] thread_id``s sharing a prefix
|
|
6
|
+
(``project-alpha`` / ``project-beta``) both showed as ``project-``. A pinned id is
|
|
7
|
+
now shown in full and a CLI-minted UUID by its shortest unique prefix.
|
|
8
|
+
- The typed prompts (``TERM=dumb`` menu, free-text reply, custom JSON) block in
|
|
9
|
+
``input()`` inside the turn's ``asyncio.run``, whose SIGINT handler only schedules a
|
|
10
|
+
cancel on the first press, so that press was swallowed.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import signal
|
|
15
|
+
import uuid
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
import pytest
|
|
19
|
+
from click.testing import CliRunner
|
|
20
|
+
|
|
21
|
+
from langstage_cli import cli, sessions
|
|
22
|
+
from langstage_cli.cli import main
|
|
23
|
+
from langstage_cli.exit_codes import EXIT_PAUSED
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _ws(tmp_path, monkeypatch) -> Path:
|
|
27
|
+
ws = tmp_path / "ws"
|
|
28
|
+
ws.mkdir()
|
|
29
|
+
monkeypatch.setenv("LANGSTAGE_CONFIG_HOME", str(tmp_path / "empty_home"))
|
|
30
|
+
monkeypatch.chdir(ws)
|
|
31
|
+
return ws.resolve()
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# --- gh #188 ---
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_display_id_keeps_pinned_names_whole():
|
|
38
|
+
assert sessions.display_id("project-alpha", ["project-beta"]) == "project-alpha"
|
|
39
|
+
assert sessions.display_id("x") == "x"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_display_id_shortens_minted_uuids_to_a_unique_prefix():
|
|
43
|
+
a = "abcdef12-0000-4000-8000-000000000001"
|
|
44
|
+
b = "abcdef12-3000-4000-8000-000000000002"
|
|
45
|
+
assert sessions.display_id(a) == "abcdef12"
|
|
46
|
+
assert sessions.display_id(a, [a]) == "abcdef12" # itself is not a rival
|
|
47
|
+
assert sessions.display_id(a, [a, b]) == "abcdef12-0"
|
|
48
|
+
assert sessions.display_id(b, [a, b]) == "abcdef12-3"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_list_sessions_shows_pinned_ids_in_full(tmp_path, monkeypatch):
|
|
52
|
+
ws = _ws(tmp_path, monkeypatch)
|
|
53
|
+
sessions.touch_session(ws, "project-alpha", first_message="alpha msg")
|
|
54
|
+
sessions.touch_session(ws, "project-beta", first_message="beta msg")
|
|
55
|
+
minted = str(uuid.uuid4())
|
|
56
|
+
sessions.touch_session(ws, minted, first_message="minted msg")
|
|
57
|
+
r = CliRunner().invoke(main, ["--demo", "--list-sessions"])
|
|
58
|
+
assert r.exit_code == 0, r.output
|
|
59
|
+
assert "project-alpha" in r.output and "project-beta" in r.output, r.output
|
|
60
|
+
assert minted[:8] in r.output and minted not in r.output, r.output
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_ambiguous_resume_lists_ids_resume_accepts(tmp_path, monkeypatch):
|
|
64
|
+
ws = _ws(tmp_path, monkeypatch)
|
|
65
|
+
sessions.touch_session(ws, "project-alpha", first_message="one")
|
|
66
|
+
sessions.touch_session(ws, "project-beta", first_message="two")
|
|
67
|
+
r = CliRunner().invoke(main, ["--demo", "--resume", "project-", "go"])
|
|
68
|
+
assert r.exit_code == 1, r.output
|
|
69
|
+
assert "project-alpha" in r.output and "project-beta" in r.output, r.output
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --- gh #189 ---
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _ctrl_c_then_answer(answer: str):
|
|
76
|
+
"""An ``input()`` that gets one Ctrl-C mid-read, and would answer if it survived."""
|
|
77
|
+
|
|
78
|
+
def fake_input(prompt=""):
|
|
79
|
+
signal.raise_signal(signal.SIGINT)
|
|
80
|
+
return answer
|
|
81
|
+
|
|
82
|
+
return fake_input
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
async def _in_loop(fn, *args):
|
|
86
|
+
return fn(*args)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.mark.parametrize(
|
|
90
|
+
"call",
|
|
91
|
+
[
|
|
92
|
+
lambda: cli._select_option_typed(["Approve", "Reject"], "Proceed?"),
|
|
93
|
+
lambda: cli._approval_input(),
|
|
94
|
+
],
|
|
95
|
+
ids=["dumb-term-menu", "reply-prompt"],
|
|
96
|
+
)
|
|
97
|
+
def test_one_ctrl_c_inside_asyncio_run_exits_paused(monkeypatch, call):
|
|
98
|
+
monkeypatch.setattr("builtins.input", _ctrl_c_then_answer("1"))
|
|
99
|
+
before = signal.getsignal(signal.SIGINT)
|
|
100
|
+
with pytest.raises(SystemExit) as e:
|
|
101
|
+
asyncio.run(_in_loop(call))
|
|
102
|
+
assert e.value.code == EXIT_PAUSED
|
|
103
|
+
assert signal.getsignal(signal.SIGINT) is before # handler restored after the read
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_prompt_line_restores_the_loops_handler(monkeypatch):
|
|
107
|
+
seen = {}
|
|
108
|
+
|
|
109
|
+
def fake_input(prompt=""):
|
|
110
|
+
seen["during"] = signal.getsignal(signal.SIGINT)
|
|
111
|
+
return "ok"
|
|
112
|
+
|
|
113
|
+
monkeypatch.setattr("builtins.input", fake_input)
|
|
114
|
+
|
|
115
|
+
async def body():
|
|
116
|
+
outer = signal.getsignal(signal.SIGINT)
|
|
117
|
+
assert cli._prompt_line("> ") == "ok"
|
|
118
|
+
return outer, signal.getsignal(signal.SIGINT)
|
|
119
|
+
|
|
120
|
+
outer, after = asyncio.run(body())
|
|
121
|
+
assert seen["during"] is signal.default_int_handler
|
|
122
|
+
assert after is outer
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""gh #186: the "No agent specified" hint shows commands that work (with -a)."""
|
|
2
|
+
|
|
3
|
+
import pytest
|
|
4
|
+
from click.testing import CliRunner
|
|
5
|
+
|
|
6
|
+
from langstage_cli import cli
|
|
7
|
+
from langstage_cli.cli import _looks_like_agent_spec, main
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@pytest.fixture
|
|
11
|
+
def no_agent(tmp_path, monkeypatch):
|
|
12
|
+
monkeypatch.chdir(tmp_path)
|
|
13
|
+
for var in ("LANGSTAGE_AGENT_SPEC", "DEEPAGENT_AGENT_SPEC", "DEEPAGENT_SPEC"):
|
|
14
|
+
monkeypatch.delenv(var, raising=False)
|
|
15
|
+
monkeypatch.setattr(cli, "_default_agent_path", lambda: None)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def test_usage_hint_uses_dash_a(no_agent):
|
|
19
|
+
r = CliRunner().invoke(main, ["hi"])
|
|
20
|
+
assert r.exit_code == 1, r.output
|
|
21
|
+
assert "No agent specified" in r.output
|
|
22
|
+
assert "langstage-cli -a path/to/agent.py:graph" in r.output
|
|
23
|
+
assert "langstage-cli -a mypackage.module:agent" in r.output
|
|
24
|
+
assert "[agent] spec" in r.output and "langstage-cli init" in r.output
|
|
25
|
+
# Not a spec-looking message: no "goes after -a" line.
|
|
26
|
+
assert "goes after -a" not in r.output
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.parametrize("spec", ["my_agent.py:graph", "mypackage.module:agent"])
|
|
30
|
+
def test_spec_passed_as_message_names_the_working_command(no_agent, spec):
|
|
31
|
+
r = CliRunner().invoke(main, [spec])
|
|
32
|
+
assert r.exit_code == 1, r.output
|
|
33
|
+
assert f"The agent spec goes after -a: langstage-cli -a {spec}" in r.output
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@pytest.mark.parametrize(
|
|
37
|
+
"message,expected",
|
|
38
|
+
[
|
|
39
|
+
("my_agent.py:graph", True),
|
|
40
|
+
("./agents/a.py:graph", True),
|
|
41
|
+
(r"C:\work\agent.py:graph", True),
|
|
42
|
+
("pkg.module:agent", True),
|
|
43
|
+
("hello", False),
|
|
44
|
+
("what is 2:3", False),
|
|
45
|
+
("ratio 3:1 please", False),
|
|
46
|
+
("http://example.org:80", False),
|
|
47
|
+
("", False),
|
|
48
|
+
(None, False),
|
|
49
|
+
],
|
|
50
|
+
)
|
|
51
|
+
def test_looks_like_agent_spec(message, expected):
|
|
52
|
+
assert _looks_like_agent_spec(message) is expected
|
|
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
|
|
File without changes
|
|
File without changes
|