langstage-cli 0.6.1__tar.gz → 0.6.2__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 (31) hide show
  1. {langstage_cli-0.6.1/langstage_cli.egg-info → langstage_cli-0.6.2}/PKG-INFO +19 -11
  2. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/README.md +16 -8
  3. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli/cli.py +32 -16
  4. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli/config.py +6 -8
  5. {langstage_cli-0.6.1 → langstage_cli-0.6.2/langstage_cli.egg-info}/PKG-INFO +19 -11
  6. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli.egg-info/SOURCES.txt +1 -0
  7. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli.egg-info/requires.txt +2 -2
  8. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/pyproject.toml +3 -3
  9. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_config.py +7 -4
  10. langstage_cli-0.6.2/tests/test_turn_exit_and_render.py +72 -0
  11. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/LICENSE +0 -0
  12. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/deepagent_code/__init__.py +0 -0
  13. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli/__init__.py +0 -0
  14. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli/agui_stream.py +0 -0
  15. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli.egg-info/dependency_links.txt +0 -0
  16. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli.egg-info/entry_points.txt +0 -0
  17. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/langstage_cli.egg-info/top_level.txt +0 -0
  18. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/setup.cfg +0 -0
  19. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_agui_stream.py +0 -0
  20. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_checkpointer.py +0 -0
  21. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_cli.py +0 -0
  22. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_cli_help.py +0 -0
  23. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_codeconfig.py +0 -0
  24. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_help_render.py +0 -0
  25. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_no_interactive_approve.py +0 -0
  26. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_rename_shim.py +0 -0
  27. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_show_config.py +0 -0
  28. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_spec_resolution.py +0 -0
  29. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_stream_mode.py +0 -0
  30. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/tests/test_streaming_marker.py +0 -0
  31. {langstage_cli-0.6.1 → langstage_cli-0.6.2}/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.6.1
3
+ Version: 0.6.2
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
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: langgraph>=0.2.0
22
- Requires-Dist: langstage-core[agui]>=1.0
22
+ Requires-Dist: langstage-core[agui]>=1.0.4
23
23
  Requires-Dist: click>=8.0.0
24
24
  Requires-Dist: python-dotenv
25
25
  Provides-Extra: dev
@@ -32,7 +32,7 @@ Requires-Dist: deepagents>=0.3; extra == "dev"
32
32
  Requires-Dist: ag-ui-langgraph>=0.0.41; extra == "dev"
33
33
  Requires-Dist: fastapi; extra == "dev"
34
34
  Provides-Extra: agui
35
- Requires-Dist: langstage-core[agui]>=1.0; extra == "agui"
35
+ Requires-Dist: langstage-core[agui]>=1.0.4; extra == "agui"
36
36
  Dynamic: license-file
37
37
 
38
38
  # langstage-cli
@@ -54,16 +54,16 @@ langstage-cli is the terminal stage of the **LangStage family**: write your agen
54
54
  | Terminal | langstage-cli | **you are here** |
55
55
  | VS Code | [langstage-vscode](https://github.com/dkedar7/langstage-vscode) | chat participant + stdio sidecar |
56
56
  | Reference agent | [langstage-hermes](https://github.com/dkedar7/langstage-hermes) | `LANGSTAGE_AGENT_SPEC=langstage_hermes.agent:graph` on any stage |
57
- | Shared core | [langgraph-stream-parser](https://github.com/dkedar7/langgraph-stream-parser) | typed events + config resolver behind every stage |
57
+ | Shared core | [langstage-core](https://github.com/dkedar7/langstage-core) | typed events + config resolver behind every stage |
58
58
 
59
59
  📖 **Full documentation:** <https://dkedar7.github.io/langstage-docs/>
60
60
 
61
61
  ### Serve over AG-UI
62
62
 
63
- This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langgraph-stream-parser):
63
+ This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langstage-core) as a standalone HTTP endpoint:
64
64
 
65
65
  ```bash
66
- pip install "langgraph-stream-parser[agui]"
66
+ pip install "langstage-core[agui]"
67
67
  langstage-agui --agent my_agent.py:graph
68
68
  ```
69
69
 
@@ -249,14 +249,22 @@ langstage-cli -a my_agent.py:graph # or :agent for the deepagents example
249
249
 
250
250
  ## Programmatic Use
251
251
 
252
+ Since 1.0, streaming runs through the shared core's in-process AG-UI adapter
253
+ (`pip install "langstage-core[agui]"`):
254
+
252
255
  ```python
253
- from langstage_cli import stream_graph_updates, prepare_agent_input
256
+ import asyncio
257
+ from langstage_core import load_agent_spec
258
+ from langstage_core.agui import build_agent, iter_chunk_frames
259
+
260
+ agent = build_agent(load_agent_spec("my_agent.py:graph"))
254
261
 
255
- input_data = prepare_agent_input(message="Hello!")
262
+ async def main():
263
+ async for chunk in iter_chunk_frames(agent, "Hello!", thread_id="s1"):
264
+ if chunk.get("chunk"):
265
+ print(chunk["chunk"], end="")
256
266
 
257
- for chunk in stream_graph_updates(graph, input_data):
258
- if chunk.get("chunk"):
259
- print(chunk["chunk"], end="")
267
+ asyncio.run(main())
260
268
  ```
261
269
 
262
270
  ## License
@@ -17,16 +17,16 @@ langstage-cli is the terminal stage of the **LangStage family**: write your agen
17
17
  | Terminal | langstage-cli | **you are here** |
18
18
  | VS Code | [langstage-vscode](https://github.com/dkedar7/langstage-vscode) | chat participant + stdio sidecar |
19
19
  | Reference agent | [langstage-hermes](https://github.com/dkedar7/langstage-hermes) | `LANGSTAGE_AGENT_SPEC=langstage_hermes.agent:graph` on any stage |
20
- | Shared core | [langgraph-stream-parser](https://github.com/dkedar7/langgraph-stream-parser) | typed events + config resolver behind every stage |
20
+ | Shared core | [langstage-core](https://github.com/dkedar7/langstage-core) | typed events + config resolver behind every stage |
21
21
 
22
22
  📖 **Full documentation:** <https://dkedar7.github.io/langstage-docs/>
23
23
 
24
24
  ### Serve over AG-UI
25
25
 
26
- This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langgraph-stream-parser):
26
+ This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langstage-core) as a standalone HTTP endpoint:
27
27
 
28
28
  ```bash
29
- pip install "langgraph-stream-parser[agui]"
29
+ pip install "langstage-core[agui]"
30
30
  langstage-agui --agent my_agent.py:graph
31
31
  ```
32
32
 
@@ -212,14 +212,22 @@ langstage-cli -a my_agent.py:graph # or :agent for the deepagents example
212
212
 
213
213
  ## Programmatic Use
214
214
 
215
+ Since 1.0, streaming runs through the shared core's in-process AG-UI adapter
216
+ (`pip install "langstage-core[agui]"`):
217
+
215
218
  ```python
216
- from langstage_cli import stream_graph_updates, prepare_agent_input
219
+ import asyncio
220
+ from langstage_core import load_agent_spec
221
+ from langstage_core.agui import build_agent, iter_chunk_frames
222
+
223
+ agent = build_agent(load_agent_spec("my_agent.py:graph"))
217
224
 
218
- input_data = prepare_agent_input(message="Hello!")
225
+ async def main():
226
+ async for chunk in iter_chunk_frames(agent, "Hello!", thread_id="s1"):
227
+ if chunk.get("chunk"):
228
+ print(chunk["chunk"], end="")
219
229
 
220
- for chunk in stream_graph_updates(graph, input_data):
221
- if chunk.get("chunk"):
222
- print(chunk["chunk"], end="")
230
+ asyncio.run(main())
223
231
  ```
224
232
 
225
233
  ## License
@@ -618,15 +618,20 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
618
618
  print_chunk._streaming_text = True
619
619
  print(text, end="")
620
620
  else:
621
- # Print the cyan bullet ONCE at the start of a streamed AI turn,
622
- # then append subsequent tokens with no marker. A token-streaming
623
- # model emits one chunk per token, so prefixing the marker on
624
- # every chunk jammed a `⏺` before every token. (gh #34)
625
- if print_chunk._streaming_text:
626
- print(render_markdown(text), end="")
627
- else:
621
+ # Print the cyan bullet ONCE at the start of a streamed AI turn AND
622
+ # again when the node changes mid-turn, then append subsequent tokens
623
+ # with no marker. A token-streaming model emits one chunk per token, so
624
+ # a per-chunk marker jammed a `⏺` before every token (gh #34); but a
625
+ # per-turn-only marker ran two nodes' messages together on one line with
626
+ # no separator (gh #43). Break + re-mark on a node change.
627
+ if not print_chunk._streaming_text or print_chunk._streaming_node != node:
628
+ if print_chunk._streaming_text:
629
+ print() # node changed mid-turn — break before the new marker
628
630
  print(f"{CYAN}⏺{RESET} {render_markdown(text)}", end="")
631
+ print_chunk._streaming_node = node
629
632
  print_chunk._streaming_text = True
633
+ else:
634
+ print(render_markdown(text), end="")
630
635
 
631
636
  # Handle tool calls - green tool name
632
637
  elif "tool_calls" in chunk:
@@ -1171,8 +1176,10 @@ async def run_single_turn_agui(
1171
1176
  thread_id: str,
1172
1177
  interactive: bool = True,
1173
1178
  verbose: bool = False,
1174
- ) -> float:
1175
- """Experimental ``--agui`` path: stream a turn through the in-process AG-UI
1179
+ ) -> tuple[float, bool]:
1180
+ """Stream a turn through the in-process AG-UI adapter. Returns
1181
+ ``(elapsed_seconds, had_error)`` — ``had_error`` is True if any frame reported
1182
+ ``status == "error"``, so a single-shot caller can exit non-zero (gh #47).
1176
1183
  adapter, rendering with the same ``print_chunk``. Text + tool calls/results
1177
1184
  reach parity with the default path (and tool *results* are also shown).
1178
1185
 
@@ -1185,6 +1192,7 @@ async def run_single_turn_agui(
1185
1192
 
1186
1193
  print_chunk._streaming_text = False # fresh marker state per turn (gh #34)
1187
1194
  start_time = time.time()
1195
+ had_error = False
1188
1196
  resume = None # first pass sends the message; later passes carry the decision
1189
1197
 
1190
1198
  while True:
@@ -1199,6 +1207,8 @@ async def run_single_turn_agui(
1199
1207
  spinner.stop()
1200
1208
  first_chunk = False
1201
1209
  print_chunk(chunk, verbose=verbose)
1210
+ if chunk.get("status") == "error":
1211
+ had_error = True
1202
1212
  if chunk.get("status") == "interrupt":
1203
1213
  has_interrupt = True
1204
1214
  interrupt_data = chunk.get("interrupt", {})
@@ -1222,7 +1232,7 @@ async def run_single_turn_agui(
1222
1232
  else:
1223
1233
  break
1224
1234
 
1225
- return time.time() - start_time
1235
+ return time.time() - start_time, had_error
1226
1236
 
1227
1237
 
1228
1238
  def run_conversation_loop(
@@ -1284,15 +1294,17 @@ def run_conversation_loop(
1284
1294
  print(f"{initial_message}")
1285
1295
  print()
1286
1296
 
1287
- duration = asyncio.run(
1297
+ duration, had_error = asyncio.run(
1288
1298
  run_single_turn_agui(agui_agent, initial_message, thread_id, interactive, verbose)
1289
1299
  )
1290
1300
  print_timing(duration, verbose)
1291
1301
  print()
1292
1302
 
1293
- # Exit after single-shot execution
1303
+ # Exit after single-shot execution. Propagate the turn's error status so
1304
+ # main() can exit non-zero — a single-shot/piped caller must be able to tell
1305
+ # a failed run from a success (gh #47).
1294
1306
  if single_shot:
1295
- return
1307
+ return had_error
1296
1308
 
1297
1309
  # Main conversation loop
1298
1310
  while True:
@@ -1355,7 +1367,7 @@ def run_conversation_loop(
1355
1367
  print() # Space before response
1356
1368
 
1357
1369
  # Run the agent (AG-UI is the only streaming path since langstage-core 1.0)
1358
- duration = asyncio.run(
1370
+ duration, _ = asyncio.run(
1359
1371
  run_single_turn_agui(agui_agent, user_input, thread_id, interactive, verbose)
1360
1372
  )
1361
1373
  print_timing(duration, verbose)
@@ -1622,8 +1634,10 @@ def main(
1622
1634
  agent_description = get_agent_description(graph)
1623
1635
 
1624
1636
  # Run the conversation loop
1625
- # Single-shot mode: exit after processing if message was provided via CLI
1626
- run_conversation_loop(
1637
+ # Single-shot mode: exit after processing if message was provided via CLI.
1638
+ # In single-shot mode it returns whether the agent turn errored, so a piped /
1639
+ # scripted caller can tell a failed run from a success (gh #47).
1640
+ turn_had_error = run_conversation_loop(
1627
1641
  graph=graph,
1628
1642
  config=config_dict,
1629
1643
  agent_name=agent_name,
@@ -1636,6 +1650,8 @@ def main(
1636
1650
  initial_message=message,
1637
1651
  single_shot=bool(message),
1638
1652
  )
1653
+ if turn_had_error:
1654
+ sys.exit(1)
1639
1655
 
1640
1656
  except FileNotFoundError as e:
1641
1657
  print(f"{RED}⏺ Error: {e}{RESET}")
@@ -11,7 +11,6 @@ lookups.
11
11
  """
12
12
 
13
13
  import os
14
- import tomllib
15
14
  import warnings
16
15
  from dataclasses import dataclass
17
16
  from pathlib import Path
@@ -25,15 +24,14 @@ class ConfigError(Exception):
25
24
 
26
25
 
27
26
  def load_config(start: Optional[Path] = None) -> Tuple[dict, List[Path]]:
28
- """Load global + project ``deepagents.toml``, merged (project wins).
27
+ """Load global + project ``langstage.toml`` (legacy ``deepagents.toml``), merged.
29
28
 
30
- Delegates to the shared loader so every deep-agent tool reads the same
31
- files with the same precedence. Returns ``(config, sources_used)``.
29
+ Delegates to the shared loader so every LangStage tool reads the same files with
30
+ the same precedence. Since langstage-core 1.0.3 a malformed config file is skipped
31
+ with a stderr notice rather than raising — a typo in a config file must not crash
32
+ the CLI (gh langstage-jupyter#42). Returns ``(config, sources_used)``.
32
33
  """
33
- try:
34
- return load_toml_config(start)
35
- except tomllib.TOMLDecodeError as e:
36
- raise ConfigError(f"Invalid TOML: {e}") from e
34
+ return load_toml_config(start)
37
35
 
38
36
 
39
37
  def get(config: dict, dotted_key: str, default: Any = None) -> Any:
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
@@ -19,7 +19,7 @@ Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
20
  License-File: LICENSE
21
21
  Requires-Dist: langgraph>=0.2.0
22
- Requires-Dist: langstage-core[agui]>=1.0
22
+ Requires-Dist: langstage-core[agui]>=1.0.4
23
23
  Requires-Dist: click>=8.0.0
24
24
  Requires-Dist: python-dotenv
25
25
  Provides-Extra: dev
@@ -32,7 +32,7 @@ Requires-Dist: deepagents>=0.3; extra == "dev"
32
32
  Requires-Dist: ag-ui-langgraph>=0.0.41; extra == "dev"
33
33
  Requires-Dist: fastapi; extra == "dev"
34
34
  Provides-Extra: agui
35
- Requires-Dist: langstage-core[agui]>=1.0; extra == "agui"
35
+ Requires-Dist: langstage-core[agui]>=1.0.4; extra == "agui"
36
36
  Dynamic: license-file
37
37
 
38
38
  # langstage-cli
@@ -54,16 +54,16 @@ langstage-cli is the terminal stage of the **LangStage family**: write your agen
54
54
  | Terminal | langstage-cli | **you are here** |
55
55
  | VS Code | [langstage-vscode](https://github.com/dkedar7/langstage-vscode) | chat participant + stdio sidecar |
56
56
  | Reference agent | [langstage-hermes](https://github.com/dkedar7/langstage-hermes) | `LANGSTAGE_AGENT_SPEC=langstage_hermes.agent:graph` on any stage |
57
- | Shared core | [langgraph-stream-parser](https://github.com/dkedar7/langgraph-stream-parser) | typed events + config resolver behind every stage |
57
+ | Shared core | [langstage-core](https://github.com/dkedar7/langstage-core) | typed events + config resolver behind every stage |
58
58
 
59
59
  📖 **Full documentation:** <https://dkedar7.github.io/langstage-docs/>
60
60
 
61
61
  ### Serve over AG-UI
62
62
 
63
- This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langgraph-stream-parser):
63
+ This surface's agent — any LangGraph `CompiledGraph` — can also be served over the [AG-UI protocol](https://github.com/dkedar7/langstage-core) as a standalone HTTP endpoint:
64
64
 
65
65
  ```bash
66
- pip install "langgraph-stream-parser[agui]"
66
+ pip install "langstage-core[agui]"
67
67
  langstage-agui --agent my_agent.py:graph
68
68
  ```
69
69
 
@@ -249,14 +249,22 @@ langstage-cli -a my_agent.py:graph # or :agent for the deepagents example
249
249
 
250
250
  ## Programmatic Use
251
251
 
252
+ Since 1.0, streaming runs through the shared core's in-process AG-UI adapter
253
+ (`pip install "langstage-core[agui]"`):
254
+
252
255
  ```python
253
- from langstage_cli import stream_graph_updates, prepare_agent_input
256
+ import asyncio
257
+ from langstage_core import load_agent_spec
258
+ from langstage_core.agui import build_agent, iter_chunk_frames
259
+
260
+ agent = build_agent(load_agent_spec("my_agent.py:graph"))
254
261
 
255
- input_data = prepare_agent_input(message="Hello!")
262
+ async def main():
263
+ async for chunk in iter_chunk_frames(agent, "Hello!", thread_id="s1"):
264
+ if chunk.get("chunk"):
265
+ print(chunk["chunk"], end="")
256
266
 
257
- for chunk in stream_graph_updates(graph, input_data):
258
- if chunk.get("chunk"):
259
- print(chunk["chunk"], end="")
267
+ asyncio.run(main())
260
268
  ```
261
269
 
262
270
  ## License
@@ -25,4 +25,5 @@ tests/test_show_config.py
25
25
  tests/test_spec_resolution.py
26
26
  tests/test_stream_mode.py
27
27
  tests/test_streaming_marker.py
28
+ tests/test_turn_exit_and_render.py
28
29
  tests/test_unicode_console.py
@@ -1,10 +1,10 @@
1
1
  langgraph>=0.2.0
2
- langstage-core[agui]>=1.0
2
+ langstage-core[agui]>=1.0.4
3
3
  click>=8.0.0
4
4
  python-dotenv
5
5
 
6
6
  [agui]
7
- langstage-core[agui]>=1.0
7
+ langstage-core[agui]>=1.0.4
8
8
 
9
9
  [dev]
10
10
  pytest>=7.0.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.1"
7
+ version = "0.6.2"
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"
@@ -29,7 +29,7 @@ dependencies = [
29
29
  # in-process AG-UI adapter — there is no built-in parser fallback — so the
30
30
  # AG-UI runtime (ag-ui-langgraph[fastapi] + uvicorn, via core's [agui] extra)
31
31
  # is a HARD dep. A bare `pip install langstage-cli` must be able to run a turn.
32
- "langstage-core[agui]>=1.0",
32
+ "langstage-core[agui]>=1.0.4",
33
33
  "click>=8.0.0",
34
34
  "python-dotenv",
35
35
  ]
@@ -53,7 +53,7 @@ dev = [
53
53
  ]
54
54
  # Redundant since AG-UI moved into base deps (core 1.0); kept as a no-op alias so
55
55
  # existing `pip install langstage-cli[agui]` scripts/READMEs still resolve.
56
- agui = ["langstage-core[agui]>=1.0"]
56
+ agui = ["langstage-core[agui]>=1.0.4"]
57
57
 
58
58
  [project.scripts]
59
59
  langstage-cli = "langstage_cli.cli:main"
@@ -4,7 +4,6 @@ from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
6
 
7
- import pytest
8
7
 
9
8
  from langstage_cli import config
10
9
 
@@ -70,13 +69,17 @@ def test_walks_up_for_project_config(tmp_path, monkeypatch):
70
69
  assert sources == [project / "deepagents.toml"]
71
70
 
72
71
 
73
- def test_invalid_toml_raises(tmp_path, monkeypatch):
72
+ def test_invalid_toml_degrades_gracefully(tmp_path, monkeypatch):
73
+ # Since langstage-core 1.0.3 (gh langstage-jupyter#42) a malformed config file is
74
+ # skipped with a stderr notice, NOT raised — a typo in a config must not crash the
75
+ # CLI (config resolves at import time on sibling surfaces). load_config() returns
76
+ # usable (empty) config instead of raising.
74
77
  home = tmp_path / "home"
75
78
  _write(home / "config.toml", "this is = not = valid toml\n")
76
79
  monkeypatch.setenv("DEEPAGENTS_CONFIG_HOME", str(home))
77
80
  monkeypatch.chdir(tmp_path)
78
- with pytest.raises(config.ConfigError):
79
- config.load_config()
81
+ cfg, _sources = config.load_config() # must NOT raise
82
+ assert cfg == {} # the malformed file was skipped, no garbage loaded
80
83
 
81
84
 
82
85
  def test_get_dotted_path():
@@ -0,0 +1,72 @@
1
+ """Regressions for gh #47 (error exit code) and gh #43 (multi-node run-on).
2
+
3
+ #47 — a runtime error inside the agent used to exit 0, so a single-shot / piped
4
+ caller couldn't tell a failed run from a success. run_single_turn_agui now reports
5
+ whether the turn errored so main() can exit non-zero.
6
+
7
+ #43 — non-verbose streaming printed one `⏺` marker per turn and appended every
8
+ chunk, so two nodes' messages ran together on one line. It now starts a fresh
9
+ marker on a node change (the node is carried on each chunk since langstage-core 1.0.4).
10
+ """
11
+
12
+ import io
13
+ from contextlib import redirect_stdout
14
+
15
+ import pytest
16
+
17
+ pytest.importorskip("ag_ui_langgraph")
18
+ pytest.importorskip("fastapi")
19
+
20
+ from langstage_cli.agui_stream import build_session_agent # noqa: E402
21
+ from langstage_cli.cli import print_chunk, run_single_turn_agui # noqa: E402
22
+
23
+
24
+ async def test_turn_reports_error_frame_for_nonzero_exit(monkeypatch):
25
+ # gh #47: an error FRAME (e.g. a RunErrorEvent — a recursion-limit failure) is
26
+ # displayed but the run still "completes", so before the fix the CLI exited 0
27
+ # and a single-shot caller couldn't tell a failed run from a success. (A raised
28
+ # exception is separately caught by main() -> exit 1; this is the framed case.)
29
+ async def fake_stream(agent, message, thread_id, resume=None):
30
+ yield {"status": "streaming", "chunk": "partial output", "node": "agent"}
31
+ yield {"status": "error", "error": "Recursion limit of 25 reached"}
32
+ yield {"status": "complete"}
33
+
34
+ import langstage_cli.agui_stream as agui_mod
35
+
36
+ monkeypatch.setattr(agui_mod, "agui_stream_updates", fake_stream)
37
+ _elapsed, had_error = await run_single_turn_agui(object(), "hi", "t-err", interactive=False)
38
+ assert had_error is True
39
+
40
+
41
+ async def test_successful_turn_reports_no_error():
42
+ from langstage_core import load_agent_spec
43
+
44
+ agent = build_session_agent(load_agent_spec("langstage_core.demo.stub:graph"))
45
+ _elapsed, had_error = await run_single_turn_agui(agent, "hi", "t-ok", interactive=False)
46
+ assert had_error is False
47
+
48
+
49
+ def test_print_chunk_breaks_on_node_change_nonverbose():
50
+ # gh #43: two nodes' output must not render as one run-on line.
51
+ print_chunk._streaming_text = False
52
+ print_chunk._streaming_node = None
53
+ buf = io.StringIO()
54
+ with redirect_stdout(buf):
55
+ print_chunk({"status": "streaming", "chunk": "first out", "node": "first"})
56
+ print_chunk({"status": "streaming", "chunk": "second out", "node": "second"})
57
+ out = buf.getvalue()
58
+ assert "first out" in out and "second out" in out
59
+ assert "first outsecond out" not in out # the two nodes are separated, not a run-on
60
+ assert out.count("⏺") == 2 # a fresh marker per node
61
+
62
+
63
+ def test_print_chunk_same_node_stays_on_one_marker():
64
+ # A token-streamed reply from ONE node keeps a single marker (gh #34 not undone).
65
+ print_chunk._streaming_text = False
66
+ print_chunk._streaming_node = None
67
+ buf = io.StringIO()
68
+ with redirect_stdout(buf):
69
+ print_chunk({"status": "streaming", "chunk": "hel", "node": "agent"})
70
+ print_chunk({"status": "streaming", "chunk": "lo", "node": "agent"})
71
+ out = buf.getvalue()
72
+ assert out.count("⏺") == 1
File without changes
File without changes