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.
Files changed (74) hide show
  1. {langstage_cli-0.6.45/langstage_cli.egg-info → langstage_cli-0.6.47}/PKG-INFO +1 -1
  2. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/cli.py +54 -10
  3. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/sessions.py +26 -3
  4. {langstage_cli-0.6.45 → langstage_cli-0.6.47/langstage_cli.egg-info}/PKG-INFO +1 -1
  5. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/SOURCES.txt +2 -0
  6. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/pyproject.toml +1 -1
  7. langstage_cli-0.6.47/tests/test_routine_1024.py +122 -0
  8. langstage_cli-0.6.47/tests/test_usage_hint_186.py +52 -0
  9. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/LICENSE +0 -0
  10. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/README.md +0 -0
  11. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/deepagent_code/__init__.py +0 -0
  12. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/__init__.py +0 -0
  13. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/agui_stream.py +0 -0
  14. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/config.py +0 -0
  15. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli/exit_codes.py +0 -0
  16. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/dependency_links.txt +0 -0
  17. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/entry_points.txt +0 -0
  18. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/requires.txt +0 -0
  19. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/langstage_cli.egg-info/top_level.txt +0 -0
  20. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/setup.cfg +0 -0
  21. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_advertised_wave4.py +0 -0
  22. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_agui_stream.py +0 -0
  23. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_approval_menu_abort.py +0 -0
  24. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_async_mode.py +0 -0
  25. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_checkpointer.py +0 -0
  26. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_cli.py +0 -0
  27. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_cli_help.py +0 -0
  28. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_codeconfig.py +0 -0
  29. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_config.py +0 -0
  30. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_config_command.py +0 -0
  31. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_configurable.py +0 -0
  32. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_deferred_pass.py +0 -0
  33. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_dumb_term.py +0 -0
  34. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_exit_codes.py +0 -0
  35. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_generic_interrupt.py +0 -0
  36. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_help_render.py +0 -0
  37. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_history_persist.py +0 -0
  38. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_hitl_approval_preview.py +0 -0
  39. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_hitl_resume_warning.py +0 -0
  40. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_init.py +0 -0
  41. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_interrupt_non_tty.py +0 -0
  42. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_legacy_config_home.py +0 -0
  43. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_load_error.py +0 -0
  44. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_message_boundaries.py +0 -0
  45. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_color.py +0 -0
  46. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_interactive_approve.py +0 -0
  47. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_no_persist_resume.py +0 -0
  48. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_persist_env.py +0 -0
  49. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_quiet_output.py +0 -0
  50. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_reasoning_render.py +0 -0
  51. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_rename_shim.py +0 -0
  52. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_render_markdown.py +0 -0
  53. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_session_lifecycle.py +0 -0
  54. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_sessions.py +0 -0
  55. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_show_config.py +0 -0
  56. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_show_config_malformed.py +0 -0
  57. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spec_loading_core.py +0 -0
  58. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spec_resolution.py +0 -0
  59. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_spinner_reply_survives.py +0 -0
  60. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stdin_interactive.py +0 -0
  61. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stdin_single_shot.py +0 -0
  62. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_stream_mode.py +0 -0
  63. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_streaming_marker.py +0 -0
  64. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_terminal_width.py +0 -0
  65. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_toml_relative_paths.py +0 -0
  66. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_tool_card_args.py +0 -0
  67. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_tool_output_escape.py +0 -0
  68. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_turn_error_verbose.py +0 -0
  69. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_turn_exit_and_render.py +0 -0
  70. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_unicode_console.py +0 -0
  71. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_verbose_command.py +0 -0
  72. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_verify_flag.py +0 -0
  73. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/tests/test_workspace.py +0 -0
  74. {langstage_cli-0.6.45 → langstage_cli-0.6.47}/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.45
3
+ Version: 0.6.47
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
@@ -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 = input(f"Choose 1-{len(options)} [1]: ").strip()
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 input(make_prompt("❯", BLUE))
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
- print(f" {DIM}Thread ID:{RESET} {thread_id[:8]}...")
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
- shown = ", ".join(t[:8] for t in matches[:10])
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[:8]} ran on the agent's own checkpointer; "
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
- print(f" {CYAN}{tid[:8]}{RESET} {DIM}{when}{RESET} {first}{mark}")
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(f"\n{DIM}Or set the LANGSTAGE_AGENT_SPEC environment variable{RESET}")
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[:8]} read-only — "
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 (ids are UUIDs; ``--list-sessions``
189
- shows short forms). Returns the full id, or ``None`` if it matches nothing / is
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)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.45
3
+ Version: 0.6.47
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
@@ -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.45"
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