langstage-cli 0.5.7__tar.gz → 0.5.9__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 (25) hide show
  1. {langstage_cli-0.5.7/langstage_cli.egg-info → langstage_cli-0.5.9}/PKG-INFO +4 -4
  2. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/README.md +3 -3
  3. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli/cli.py +29 -8
  4. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli/config.py +1 -1
  5. {langstage_cli-0.5.7 → langstage_cli-0.5.9/langstage_cli.egg-info}/PKG-INFO +4 -4
  6. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli.egg-info/SOURCES.txt +1 -0
  7. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/pyproject.toml +1 -1
  8. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_codeconfig.py +1 -1
  9. langstage_cli-0.5.9/tests/test_help_render.py +35 -0
  10. langstage_cli-0.5.9/tests/test_stream_mode.py +88 -0
  11. langstage_cli-0.5.7/tests/test_stream_mode.py +0 -36
  12. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/LICENSE +0 -0
  13. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/deepagent_code/__init__.py +0 -0
  14. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli/__init__.py +0 -0
  15. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli.egg-info/dependency_links.txt +0 -0
  16. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli.egg-info/entry_points.txt +0 -0
  17. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli.egg-info/requires.txt +0 -0
  18. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/langstage_cli.egg-info/top_level.txt +0 -0
  19. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/setup.cfg +0 -0
  20. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_cli.py +0 -0
  21. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_cli_help.py +0 -0
  22. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_config.py +0 -0
  23. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_rename_shim.py +0 -0
  24. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_show_config.py +0 -0
  25. {langstage_cli-0.5.7 → langstage_cli-0.5.9}/tests/test_unicode_console.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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
@@ -175,7 +175,7 @@ root = "."
175
175
  [ui]
176
176
  verbose = true
177
177
  async_mode = false
178
- stream_mode = "updates"
178
+ stream_mode = "auto" # auto | updates | messages
179
179
 
180
180
  [configurable]
181
181
  # seeds LangGraph RunnableConfig.configurable
@@ -196,8 +196,8 @@ Options:
196
196
  -f, --file PATH Read message from a file (any extension)
197
197
  --interactive/--no-interactive Handle interrupts (default: interactive)
198
198
  --async-mode/--sync-mode Async streaming (default: sync)
199
- --stream-mode [updates|messages]
200
- Stream mode (default: updates)
199
+ --stream-mode [auto|updates|messages]
200
+ Stream mode (default: auto)
201
201
  -v, --verbose Verbose output
202
202
  --demo Run with the built-in keyless demo agent
203
203
  --show-config Print the resolved configuration and exit
@@ -140,7 +140,7 @@ root = "."
140
140
  [ui]
141
141
  verbose = true
142
142
  async_mode = false
143
- stream_mode = "updates"
143
+ stream_mode = "auto" # auto | updates | messages
144
144
 
145
145
  [configurable]
146
146
  # seeds LangGraph RunnableConfig.configurable
@@ -161,8 +161,8 @@ Options:
161
161
  -f, --file PATH Read message from a file (any extension)
162
162
  --interactive/--no-interactive Handle interrupts (default: interactive)
163
163
  --async-mode/--sync-mode Async streaming (default: sync)
164
- --stream-mode [updates|messages]
165
- Stream mode (default: updates)
164
+ --stream-mode [auto|updates|messages]
165
+ Stream mode (default: auto)
166
166
  -v, --verbose Verbose output
167
167
  --demo Run with the built-in keyless demo agent
168
168
  --show-config Print the resolved configuration and exit
@@ -736,6 +736,20 @@ def handle_interrupt_input(num_actions: int = 1) -> List[Dict[str, Any]]:
736
736
  sys.exit(0)
737
737
 
738
738
 
739
+ def _resolve_stream_mode(stream_mode: str):
740
+ """Map the user-facing stream mode to what LangGraph actually streams.
741
+
742
+ 'auto' = dual-channel ``["updates", "messages"]``: the parser renders LLM
743
+ tokens live when the agent streams them, and falls back to the finished
744
+ message content otherwise — so a node that returns a complete (non-token-
745
+ streamed) ``AIMessage`` still renders instead of an empty turn. (Bare
746
+ 'messages' only carries LLM token streams, which is exactly why it was a
747
+ silent-blank trap — gh #-dogfood.) 'updates'/'messages' pass through as the
748
+ explicit single-mode power-user choices.
749
+ """
750
+ return ["updates", "messages"] if stream_mode == "auto" else stream_mode
751
+
752
+
739
753
  async def run_single_turn_async(
740
754
  graph,
741
755
  message: str,
@@ -746,6 +760,7 @@ async def run_single_turn_async(
746
760
  ) -> float:
747
761
  """Run a single turn of an async LangGraph graph. Returns total duration in seconds."""
748
762
  input_data = prepare_agent_input(message=message)
763
+ lg_stream_mode = _resolve_stream_mode(stream_mode)
749
764
  start_time = time.time()
750
765
 
751
766
  while True:
@@ -757,7 +772,7 @@ async def run_single_turn_async(
757
772
 
758
773
  try:
759
774
  async for chunk in astream_graph_updates(
760
- graph, input_data, config=config, stream_mode=stream_mode
775
+ graph, input_data, config=config, stream_mode=lg_stream_mode
761
776
  ):
762
777
  # Stop spinner on first chunk
763
778
  if first_chunk:
@@ -796,6 +811,7 @@ def run_single_turn_sync(
796
811
  ) -> float:
797
812
  """Run a single turn of a sync LangGraph graph. Returns total duration in seconds."""
798
813
  input_data = prepare_agent_input(message=message)
814
+ lg_stream_mode = _resolve_stream_mode(stream_mode)
799
815
  start_time = time.time()
800
816
 
801
817
  while True:
@@ -807,7 +823,7 @@ def run_single_turn_sync(
807
823
 
808
824
  try:
809
825
  for chunk in stream_graph_updates(
810
- graph, input_data, config=config, stream_mode=stream_mode
826
+ graph, input_data, config=config, stream_mode=lg_stream_mode
811
827
  ):
812
828
  # Stop spinner on first chunk
813
829
  if first_chunk:
@@ -847,7 +863,10 @@ def print_help():
847
863
  for cmd in sorted(commands, key=lambda c: c.name):
848
864
  aliases_str = ""
849
865
  if cmd.aliases:
850
- aliases_str = f", {CYAN}/{RESET}, {CYAN}/".join([""] + cmd.aliases)[4:]
866
+ # Each alias as its own cyan "/x" token. The old
867
+ # `…join([""] + aliases)[4:]` sliced into the leading ANSI escape,
868
+ # leaking a literal "36m" and bleeding color (gh #-dogfood).
869
+ aliases_str = "".join(f", {CYAN}/{alias}{RESET}" for alias in cmd.aliases)
851
870
  print(f" {CYAN}/{cmd.name}{RESET}{aliases_str}")
852
871
  print(f" {DIM}{cmd.description}{RESET}")
853
872
 
@@ -1342,8 +1361,10 @@ def run_conversation_loop(
1342
1361
  )
1343
1362
  @click.option(
1344
1363
  "--stream-mode",
1345
- type=click.Choice(["updates", "messages"]),
1346
- help="Stream mode: 'updates' (default) or 'messages' (token-level).",
1364
+ type=click.Choice(["auto", "updates", "messages"]),
1365
+ help="Stream mode: 'auto' (default; token streaming when the agent emits "
1366
+ "tokens, full-message fallback otherwise), 'updates' (whole-message), or "
1367
+ "'messages' (token-level only — a finished AIMessage renders nothing here).",
1347
1368
  )
1348
1369
  @click.option(
1349
1370
  "--verbose",
@@ -1395,7 +1416,7 @@ def main(
1395
1416
  \b
1396
1417
  - LANGSTAGE_AGENT_SPEC: Agent location (same formats as above).
1397
1418
  - LANGSTAGE_WORKSPACE_ROOT: Working directory for the agent
1398
- - LANGSTAGE_STREAM_MODE: Stream mode for LangGraph (updates or messages)
1419
+ - LANGSTAGE_STREAM_MODE: Stream mode for LangGraph (auto, updates, or messages)
1399
1420
 
1400
1421
  Reads ~/.langstage/config.toml (global) and langstage.toml (project,
1401
1422
  walks up from cwd). Precedence: CLI args > env vars > project TOML >
@@ -1488,10 +1509,10 @@ def main(
1488
1509
  # The CLI's render path only supports 'updates' / 'messages'. Reject anything
1489
1510
  # else (e.g. LANGSTAGE_STREAM_MODE=values, which bypasses the flag's Choice)
1490
1511
  # with a clean error instead of a fatal crash deep in the stream worker.
1491
- if final_stream_mode not in ("updates", "messages"):
1512
+ if final_stream_mode not in ("auto", "updates", "messages"):
1492
1513
  print(
1493
1514
  f"{RED}⏺ Error: unsupported stream mode {final_stream_mode!r} "
1494
- f"(use 'updates' or 'messages'){RESET}"
1515
+ f"(use 'auto', 'updates', or 'messages'){RESET}"
1495
1516
  )
1496
1517
  sys.exit(2)
1497
1518
  use_async = cfg.async_mode
@@ -86,7 +86,7 @@ class CodeConfig(HostConfig):
86
86
  resolver); the even-older ``DEEPAGENT_SPEC`` alias is reconciled below.
87
87
  """
88
88
 
89
- stream_mode: str = "updates"
89
+ stream_mode: str = "auto"
90
90
  graph_name: str = "graph"
91
91
  verbose: bool = False
92
92
  async_mode: bool = False
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.5.7
3
+ Version: 0.5.9
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
@@ -175,7 +175,7 @@ root = "."
175
175
  [ui]
176
176
  verbose = true
177
177
  async_mode = false
178
- stream_mode = "updates"
178
+ stream_mode = "auto" # auto | updates | messages
179
179
 
180
180
  [configurable]
181
181
  # seeds LangGraph RunnableConfig.configurable
@@ -196,8 +196,8 @@ Options:
196
196
  -f, --file PATH Read message from a file (any extension)
197
197
  --interactive/--no-interactive Handle interrupts (default: interactive)
198
198
  --async-mode/--sync-mode Async streaming (default: sync)
199
- --stream-mode [updates|messages]
200
- Stream mode (default: updates)
199
+ --stream-mode [auto|updates|messages]
200
+ Stream mode (default: auto)
201
201
  -v, --verbose Verbose output
202
202
  --demo Run with the built-in keyless demo agent
203
203
  --show-config Print the resolved configuration and exit
@@ -15,6 +15,7 @@ tests/test_cli.py
15
15
  tests/test_cli_help.py
16
16
  tests/test_codeconfig.py
17
17
  tests/test_config.py
18
+ tests/test_help_render.py
18
19
  tests/test_rename_shim.py
19
20
  tests/test_show_config.py
20
21
  tests/test_stream_mode.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.5.7"
7
+ version = "0.5.9"
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"
@@ -21,7 +21,7 @@ def _toml(d: Path, body: str) -> None:
21
21
 
22
22
  def test_defaults(isolated, tmp_path):
23
23
  cfg = CodeConfig.resolve(env={}, toml_start=tmp_path)
24
- assert cfg.stream_mode == "updates"
24
+ assert cfg.stream_mode == "auto"
25
25
  assert cfg.graph_name == "graph"
26
26
  assert cfg.verbose is False
27
27
  assert cfg.async_mode is False
@@ -0,0 +1,35 @@
1
+ """`/help` must not leak ANSI fragments (gh #-dogfood).
2
+
3
+ The Commands block built alias strings with `…join([""] + aliases)[4:]`, whose
4
+ slice cut into the leading `\x1b[36m` escape — leaking a literal "36m" and bleeding
5
+ color. Each alias is now its own cyan token.
6
+ """
7
+
8
+ import io
9
+ import re
10
+ from contextlib import redirect_stdout
11
+
12
+ from langstage_cli.cli import print_help
13
+
14
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
15
+
16
+
17
+ def _rendered_help() -> str:
18
+ buf = io.StringIO()
19
+ with redirect_stdout(buf):
20
+ print_help()
21
+ return buf.getvalue()
22
+
23
+
24
+ def test_help_has_no_leaked_ansi_fragment():
25
+ plain = _ANSI.sub("", _rendered_help())
26
+ # No bare color-code residue once real escapes are stripped.
27
+ assert "36m" not in plain
28
+ assert "[0m" not in plain and "[36" not in plain
29
+
30
+
31
+ def test_help_renders_aliases_cleanly():
32
+ plain = _ANSI.sub("", _rendered_help())
33
+ # A multi-alias command renders as comma-separated /tokens.
34
+ assert "/quit" in plain
35
+ assert re.search(r"/quit(, /\w+)+", plain), plain
@@ -0,0 +1,88 @@
1
+ """--stream-mode validation + the 'auto' default (gh #-dogfood).
2
+
3
+ `values` was advertised but unsupported by the CLI's render path; passing it
4
+ crashed with a fatal interpreter-shutdown error (a ValueError surfaced while the
5
+ spinner daemon thread held stdout). Now: the flag is a Choice {auto,updates,
6
+ messages}, and the resolved value (incl. LANGSTAGE_STREAM_MODE, which bypasses
7
+ the flag) is validated up front with a clean error.
8
+
9
+ Separately, single 'messages' mode only carries LLM *token* streams, so a node
10
+ that returns a finished (non-token-streamed) AIMessage rendered an empty turn.
11
+ The default is now 'auto' (dual updates+messages) so that agent shape — the one
12
+ the README's own "Creating Your Own Agent" example produces — still renders.
13
+ """
14
+
15
+ import textwrap
16
+
17
+ from click.testing import CliRunner
18
+
19
+ from langstage_cli.cli import main
20
+
21
+ # A graph whose node returns a *finished* AIMessage (no token streaming) — the
22
+ # shape that was a silent blank turn under bare 'messages'.
23
+ FINISHED_AIMESSAGE_AGENT = textwrap.dedent(
24
+ """
25
+ from langchain_core.messages import AIMessage
26
+ from langgraph.graph import START, END, StateGraph, MessagesState
27
+
28
+ def _respond(state):
29
+ return {"messages": [AIMessage(content="FINISHED_MARKER_42")]}
30
+
31
+ builder = StateGraph(MessagesState)
32
+ builder.add_node("respond", _respond)
33
+ builder.add_edge(START, "respond")
34
+ builder.add_edge("respond", END)
35
+ graph = builder.compile()
36
+ """
37
+ )
38
+
39
+
40
+ def test_default_auto_renders_finished_aimessage(tmp_path, monkeypatch):
41
+ """Regression: the default mode must render a finished AIMessage's content."""
42
+ (tmp_path / "fin_agent.py").write_text(FINISHED_AIMESSAGE_AGENT)
43
+ monkeypatch.chdir(tmp_path)
44
+ r = CliRunner().invoke(main, ["-a", "fin_agent.py:graph", "--no-interactive", "ping"])
45
+ assert r.exit_code == 0, r.output
46
+ assert "FINISHED_MARKER_42" in r.output
47
+
48
+
49
+ def test_messages_mode_alone_is_token_only(tmp_path, monkeypatch):
50
+ """Documents why 'auto' is the default: bare 'messages' carries only LLM
51
+ token streams, so a finished AIMessage yields no content there."""
52
+ (tmp_path / "fin_agent.py").write_text(FINISHED_AIMESSAGE_AGENT)
53
+ monkeypatch.chdir(tmp_path)
54
+ r = CliRunner().invoke(
55
+ main, ["-a", "fin_agent.py:graph", "--no-interactive", "--stream-mode", "messages", "ping"]
56
+ )
57
+ assert r.exit_code == 0, r.output
58
+ assert "FINISHED_MARKER_42" not in r.output
59
+
60
+
61
+ def test_default_stream_mode_is_auto():
62
+ r = CliRunner().invoke(main, ["--show-config"])
63
+ assert r.exit_code == 0, r.output
64
+ assert "auto" in r.output
65
+
66
+
67
+ def test_stream_mode_values_flag_rejected_cleanly():
68
+ r = CliRunner().invoke(main, ["--demo", "--no-interactive", "--stream-mode", "values", "hi"])
69
+ assert r.exit_code != 0
70
+ assert "values" in r.output
71
+ assert "Fatal" not in r.output # no interpreter-shutdown crash
72
+
73
+
74
+ def test_stream_mode_messages_flag_accepted():
75
+ # A valid mode must still be accepted by the Choice (parses, doesn't error on the flag).
76
+ r = CliRunner().invoke(main, ["--stream-mode", "messages", "--show-config"])
77
+ assert r.exit_code == 0, r.output
78
+ assert "messages" in r.output
79
+
80
+
81
+ def test_stream_mode_values_via_env_rejected():
82
+ r = CliRunner().invoke(
83
+ main,
84
+ ["--demo", "--no-interactive", "hi"],
85
+ env={"LANGSTAGE_STREAM_MODE": "values"},
86
+ )
87
+ assert r.exit_code == 2, r.output
88
+ assert "unsupported stream mode" in r.output.lower()
@@ -1,36 +0,0 @@
1
- """--stream-mode validation (gh #-dogfood).
2
-
3
- `values` was advertised but unsupported by the CLI's render path; passing it
4
- crashed with a fatal interpreter-shutdown error (a ValueError surfaced while the
5
- spinner daemon thread held stdout). Now: the flag is a Choice {updates,messages},
6
- and the resolved value (incl. LANGSTAGE_STREAM_MODE, which bypasses the flag) is
7
- validated up front with a clean error.
8
- """
9
-
10
- from click.testing import CliRunner
11
-
12
- from langstage_cli.cli import main
13
-
14
-
15
- def test_stream_mode_values_flag_rejected_cleanly():
16
- r = CliRunner().invoke(main, ["--demo", "--no-interactive", "--stream-mode", "values", "hi"])
17
- assert r.exit_code != 0
18
- assert "values" in r.output
19
- assert "Fatal" not in r.output # no interpreter-shutdown crash
20
-
21
-
22
- def test_stream_mode_messages_flag_accepted():
23
- # A valid mode must still be accepted by the Choice (parses, doesn't error on the flag).
24
- r = CliRunner().invoke(main, ["--stream-mode", "messages", "--show-config"])
25
- assert r.exit_code == 0, r.output
26
- assert "messages" in r.output
27
-
28
-
29
- def test_stream_mode_values_via_env_rejected():
30
- r = CliRunner().invoke(
31
- main,
32
- ["--demo", "--no-interactive", "hi"],
33
- env={"LANGSTAGE_STREAM_MODE": "values"},
34
- )
35
- assert r.exit_code == 2, r.output
36
- assert "unsupported stream mode" in r.output.lower()
File without changes
File without changes