langstage-cli 0.6.41__tar.gz → 0.6.43__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 (70) hide show
  1. {langstage_cli-0.6.41/langstage_cli.egg-info → langstage_cli-0.6.43}/PKG-INFO +4 -3
  2. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/README.md +3 -2
  3. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/cli.py +96 -11
  4. {langstage_cli-0.6.41 → langstage_cli-0.6.43/langstage_cli.egg-info}/PKG-INFO +4 -3
  5. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/SOURCES.txt +2 -0
  6. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/pyproject.toml +1 -1
  7. langstage_cli-0.6.43/tests/test_reasoning_render.py +153 -0
  8. langstage_cli-0.6.43/tests/test_tool_output_escape.py +224 -0
  9. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/LICENSE +0 -0
  10. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/deepagent_code/__init__.py +0 -0
  11. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/__init__.py +0 -0
  12. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/agui_stream.py +0 -0
  13. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/config.py +0 -0
  14. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/exit_codes.py +0 -0
  15. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/sessions.py +0 -0
  16. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/dependency_links.txt +0 -0
  17. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/entry_points.txt +0 -0
  18. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/requires.txt +0 -0
  19. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/top_level.txt +0 -0
  20. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/setup.cfg +0 -0
  21. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_advertised_wave4.py +0 -0
  22. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_agui_stream.py +0 -0
  23. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_approval_menu_abort.py +0 -0
  24. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_async_mode.py +0 -0
  25. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_checkpointer.py +0 -0
  26. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_cli.py +0 -0
  27. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_cli_help.py +0 -0
  28. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_codeconfig.py +0 -0
  29. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_config.py +0 -0
  30. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_config_command.py +0 -0
  31. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_configurable.py +0 -0
  32. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_deferred_pass.py +0 -0
  33. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_exit_codes.py +0 -0
  34. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_generic_interrupt.py +0 -0
  35. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_help_render.py +0 -0
  36. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_history_persist.py +0 -0
  37. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_hitl_approval_preview.py +0 -0
  38. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_hitl_resume_warning.py +0 -0
  39. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_init.py +0 -0
  40. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_interrupt_non_tty.py +0 -0
  41. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_legacy_config_home.py +0 -0
  42. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_load_error.py +0 -0
  43. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_message_boundaries.py +0 -0
  44. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_color.py +0 -0
  45. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_interactive_approve.py +0 -0
  46. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_persist_resume.py +0 -0
  47. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_persist_env.py +0 -0
  48. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_quiet_output.py +0 -0
  49. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_rename_shim.py +0 -0
  50. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_render_markdown.py +0 -0
  51. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_session_lifecycle.py +0 -0
  52. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_sessions.py +0 -0
  53. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_show_config.py +0 -0
  54. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_show_config_malformed.py +0 -0
  55. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spec_loading_core.py +0 -0
  56. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spec_resolution.py +0 -0
  57. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spinner_reply_survives.py +0 -0
  58. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stdin_interactive.py +0 -0
  59. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stdin_single_shot.py +0 -0
  60. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stream_mode.py +0 -0
  61. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_streaming_marker.py +0 -0
  62. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_terminal_width.py +0 -0
  63. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_toml_relative_paths.py +0 -0
  64. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_turn_error_verbose.py +0 -0
  65. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_turn_exit_and_render.py +0 -0
  66. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_unicode_console.py +0 -0
  67. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_verbose_command.py +0 -0
  68. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_verify_flag.py +0 -0
  69. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_workspace.py +0 -0
  70. {langstage_cli-0.6.41 → langstage_cli-0.6.43}/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.41
3
+ Version: 0.6.43
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
@@ -109,6 +109,7 @@ This launches an interactive conversation loop with your agent.
109
109
  langstage-cli --demo "Hello"
110
110
 
111
111
  # Keyless demo of tool calls, reasoning and HITL: say "use a tool", "think" or "ask me"
112
+ # ("think" streams its chain of thought, dimmed under "✻ Thinking", before the answer)
112
113
  langstage-cli -a langstage_core.demo.tools:graph "use a tool"
113
114
 
114
115
  # Scaffold a runnable starter agent + langstage.toml (then just run it)
@@ -299,8 +300,8 @@ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1
299
300
  ### Scriptable output
300
301
 
301
302
  A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
302
- reply — no header, spinner, tool chatter, timing, or color — as soon as its output
303
- is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
303
+ reply — no header, spinner, tool chatter, reasoning, timing, or color — as soon as its
304
+ output is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
304
305
  anything your agent module `print`s while it is being imported. The process exits
305
306
  non-zero if the turn failed, so a run is safe to capture:
306
307
 
@@ -71,6 +71,7 @@ This launches an interactive conversation loop with your agent.
71
71
  langstage-cli --demo "Hello"
72
72
 
73
73
  # Keyless demo of tool calls, reasoning and HITL: say "use a tool", "think" or "ask me"
74
+ # ("think" streams its chain of thought, dimmed under "✻ Thinking", before the answer)
74
75
  langstage-cli -a langstage_core.demo.tools:graph "use a tool"
75
76
 
76
77
  # Scaffold a runnable starter agent + langstage.toml (then just run it)
@@ -261,8 +262,8 @@ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1
261
262
  ### Scriptable output
262
263
 
263
264
  A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
264
- reply — no header, spinner, tool chatter, timing, or color — as soon as its output
265
- is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
265
+ reply — no header, spinner, tool chatter, reasoning, timing, or color — as soon as its
266
+ output is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
266
267
  anything your agent module `print`s while it is being imported. The process exits
267
268
  non-zero if the turn failed, so a run is safe to capture:
268
269
 
@@ -1122,11 +1122,15 @@ def _absolutize_file_spec(spec: str) -> str:
1122
1122
 
1123
1123
 
1124
1124
  def get_tool_arg_preview(args: Dict[str, Any]) -> str:
1125
- """Get a preview of the first argument value (nanocode style)."""
1125
+ """Get a preview of the first argument value (nanocode style).
1126
+
1127
+ The value is escaped before it is truncated, so a tool arg can't move the cursor
1128
+ or retitle the terminal, and a cut can't leave half an escape sequence live (gh #180).
1129
+ """
1126
1130
  if not args:
1127
1131
  return ""
1128
1132
  # Get first value
1129
- first_val = str(list(args.values())[0])
1133
+ first_val = _escape_controls(str(list(args.values())[0]))
1130
1134
  # Truncate if needed
1131
1135
  if len(first_val) > 50:
1132
1136
  return first_val[:50] + "..."
@@ -1142,6 +1146,15 @@ _APPROVAL_LINE_MAX = 100
1142
1146
  _INTERRUPT_LABEL_KEYS = ("description", "question", "message", "prompt")
1143
1147
 
1144
1148
 
1149
+ def _escape_controls(text: str, keep: str = "") -> str:
1150
+ """Show every non-printable character escaped (``ESC`` -> ``\\x1b``), except ``keep``.
1151
+
1152
+ Agent-supplied text that the CLI frames itself (an approval preview, a reasoning
1153
+ block) must not move the cursor, recolor the terminal or start a line of its own.
1154
+ """
1155
+ return "".join(ch if ch.isprintable() or ch in keep else repr(ch)[1:-1] for ch in text)
1156
+
1157
+
1145
1158
  def _approval_text(value: Any, limit: Optional[int] = _APPROVAL_VALUE_MAX) -> str:
1146
1159
  """One field of the approval preview as a single, escaped, length-capped line.
1147
1160
 
@@ -1157,7 +1170,7 @@ def _approval_text(value: Any, limit: Optional[int] = _APPROVAL_VALUE_MAX) -> st
1157
1170
  text = str(value)
1158
1171
  else:
1159
1172
  text = str(value)
1160
- text = "".join(ch if ch.isprintable() else repr(ch)[1:-1] for ch in text)
1173
+ text = _escape_controls(text)
1161
1174
  if limit is not None and len(text) > limit:
1162
1175
  return text[:limit] + "..."
1163
1176
  return text
@@ -1285,11 +1298,60 @@ def _is_generic_interrupt(action_requests: List[Any]) -> bool:
1285
1298
  return not all(_tool_key(a) for a in action_requests)
1286
1299
 
1287
1300
 
1301
+ # Indent of a reasoning block's lines, so they sit under its label (gh #178).
1302
+ _REASONING_INDENT = " "
1303
+
1304
+
1305
+ def _reasoning_text(delta: str) -> str:
1306
+ """One reasoning delta, escaped and indented for the terminal (gh #178).
1307
+
1308
+ Reasoning gets the approval preview's escaping: a carriage return, ANSI escape or
1309
+ other control character is shown escaped, so it can't move the cursor, end the dim
1310
+ style or overwrite a line. Line breaks (and tabs) are kept, since a chain of thought
1311
+ runs over many lines, but every new line is indented under the block, so no line
1312
+ of reasoning starts at column 0 the way a prompt or a ``⏺`` reply does.
1313
+ """
1314
+ text = _escape_controls(delta.replace("\r\n", "\n"), keep="\n\t")
1315
+ return text.replace("\n", "\n" + _REASONING_INDENT)
1316
+
1317
+
1318
+ def _print_reasoning(chunk: Dict[str, Any], verbose: bool) -> None:
1319
+ """Render a ``reasoning`` frame: a reasoning model's chain of thought (gh #178).
1320
+
1321
+ It is not the answer, so it is shown dim, under its own label, ahead of the answer
1322
+ it leads to: ``✻ Thinking`` with the text indented below it, or under ``-v`` a
1323
+ ``[node:reasoning]`` prefix in the style of the ``[node]`` reply label. Consecutive
1324
+ deltas join into one block. Text, a tool call or a node change ends the block, so a
1325
+ later stretch of reasoning gets a label of its own.
1326
+ """
1327
+ text = chunk.get("reasoning") or ""
1328
+ if not text:
1329
+ return
1330
+ node = chunk.get("node") or "unknown"
1331
+ if not print_chunk._streaming_reasoning or print_chunk._reasoning_node != node:
1332
+ _flush_markdown()
1333
+ if print_chunk._streaming_text or print_chunk._streaming_reasoning:
1334
+ print() # end the open line; the block starts on its own
1335
+ print_chunk._streaming_text = False # the answer after it gets a fresh marker
1336
+ if verbose:
1337
+ safe_write(f"{DIM}[{node}:reasoning]{RESET} ")
1338
+ else:
1339
+ safe_write(f"{DIM}✻ Thinking{RESET}\n{_REASONING_INDENT}")
1340
+ print_chunk._streaming_reasoning = True
1341
+ print_chunk._reasoning_node = node
1342
+ safe_write(f"{DIM}{_reasoning_text(text)}{RESET}", flush=True)
1343
+
1344
+
1288
1345
  def format_result_preview(result: str) -> str:
1289
- """Format a result preview with line count indicator."""
1346
+ """Format a result preview with line count indicator.
1347
+
1348
+ The first line is escaped before it is cut, so a tool result (a fetched page, a
1349
+ file) can't clear the screen or forge a line the CLI printed (gh #180).
1350
+ """
1290
1351
  if not result:
1291
1352
  return "(empty)"
1292
- lines = result.split("\n")
1353
+ lines = result.replace("\r\n", "\n").split("\n")
1354
+ lines[0] = _escape_controls(lines[0])
1293
1355
  preview = lines[0][:60]
1294
1356
  if len(lines) > 1:
1295
1357
  preview += f" ... +{len(lines) - 1} lines"
@@ -1362,6 +1424,9 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1362
1424
  print_chunk._streaming_node = node
1363
1425
  print_chunk._streaming_text = True
1364
1426
  return
1427
+ if print_chunk._streaming_reasoning:
1428
+ print() # end the reasoning block's line; the answer starts its own (gh #178)
1429
+ print_chunk._streaming_reasoning = False
1365
1430
  if verbose:
1366
1431
  # Print the [node] label ONCE per streamed run — when a text run
1367
1432
  # starts, or the node changes mid-stream — then append subsequent
@@ -1395,14 +1460,21 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1395
1460
  else:
1396
1461
  safe_write(_md_stream.feed(text))
1397
1462
 
1463
+ # Reasoning-model chain of thought, dim and labelled ahead of the answer (gh #178)
1464
+ elif "reasoning" in chunk:
1465
+ if _QUIET:
1466
+ return # not the reply: scriptable output omits it, like tool chatter
1467
+ _print_reasoning(chunk, verbose)
1468
+
1398
1469
  # Handle tool calls - green tool name
1399
1470
  elif "tool_calls" in chunk:
1400
1471
  if _QUIET:
1401
1472
  return # tool chatter is decoration; scriptable output omits it
1402
1473
  _flush_markdown()
1403
1474
  print_chunk._streaming_text = False # a non-text event ends the text run
1475
+ print_chunk._streaming_reasoning = False # the "\n● name" below ends its line
1404
1476
  for tool_call in chunk["tool_calls"]:
1405
- tool_name = tool_call["name"]
1477
+ tool_name = _escape_controls(str(tool_call["name"])) # gh #180
1406
1478
  args = tool_call.get("args", {})
1407
1479
  arg_preview = get_tool_arg_preview(args)
1408
1480
 
@@ -1415,9 +1487,10 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1415
1487
  if _QUIET:
1416
1488
  return # tool chatter is decoration; scriptable output omits it
1417
1489
  _flush_markdown()
1418
- if print_chunk._streaming_text:
1490
+ if print_chunk._streaming_text or print_chunk._streaming_reasoning:
1419
1491
  print() # never glue a result onto the end of a text line (gh #119)
1420
1492
  print_chunk._streaming_text = False
1493
+ print_chunk._streaming_reasoning = False
1421
1494
  result = chunk.get("tool_result", "")
1422
1495
  preview = format_result_preview(str(result))
1423
1496
  safe_print(f" {DIM} ↳ {preview}{RESET}")
@@ -1425,6 +1498,7 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1425
1498
  elif status == "interrupt":
1426
1499
  _flush_markdown()
1427
1500
  print_chunk._streaming_text = False
1501
+ print_chunk._streaming_reasoning = False
1428
1502
  if _QUIET:
1429
1503
  # The `⚠ Action Required` banner is human-facing decoration — like the
1430
1504
  # tool-call / tool-result chatter above, the scriptable path omits it so
@@ -1466,11 +1540,14 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1466
1540
  elif status == "complete":
1467
1541
  _flush_markdown()
1468
1542
  print_chunk._streaming_text = False # turn over; next turn starts a fresh marker
1543
+ print_chunk._streaming_reasoning = False
1469
1544
 
1470
1545
  elif status == "error":
1471
1546
  _flush_markdown()
1472
1547
  print_chunk._streaming_text = False
1473
- error_msg = chunk.get("error", "Unknown error")
1548
+ print_chunk._streaming_reasoning = False
1549
+ # An error can quote tool output; show its control bytes escaped (gh #180).
1550
+ error_msg = _escape_controls(str(chunk.get("error", "Unknown error")), keep="\n\t")
1474
1551
  # Keep stdout clean for the pipe; errors go to stderr. (gh #53)
1475
1552
  out = sys.stderr if _QUIET else sys.stdout
1476
1553
  if _QUIET:
@@ -1482,7 +1559,8 @@ def print_chunk(chunk: Dict[str, Any], verbose: bool = False):
1482
1559
  # otherwise point at -v.
1483
1560
  tb = chunk.get("traceback")
1484
1561
  if verbose and tb:
1485
- safe_print(f"{DIM}{tb.rstrip()}{RESET}", file=out)
1562
+ tb = _escape_controls(str(tb).rstrip(), keep="\n\t")
1563
+ safe_print(f"{DIM}{tb}{RESET}", file=out)
1486
1564
  elif not verbose:
1487
1565
  safe_print(f"{DIM}Re-run with -v for the full traceback.{RESET}", file=out)
1488
1566
 
@@ -1496,6 +1574,10 @@ print_chunk._streaming_text = False
1496
1574
  print_chunk._streaming_node = None
1497
1575
  # The message_id of the text run in progress: a change is a message boundary (gh #119).
1498
1576
  print_chunk._streaming_message_id = None
1577
+ # Whether a reasoning block is open (its label printed, its line not yet ended), and the
1578
+ # node it belongs to: a change of node starts a new block (gh #178).
1579
+ print_chunk._streaming_reasoning = False
1580
+ print_chunk._reasoning_node = None
1499
1581
 
1500
1582
 
1501
1583
  def get_key() -> str:
@@ -2045,7 +2127,9 @@ def cmd_history(args: str, context: Dict[str, Any]) -> Optional[str]:
2045
2127
  else:
2046
2128
  print(f"\n {DIM}{role}:{RESET}")
2047
2129
 
2048
- # Truncate long content
2130
+ # A stored tool result is outside content: escape it, then
2131
+ # truncate (gh #180).
2132
+ content = _escape_controls(str(content), keep="\n\t")
2049
2133
  if len(content) > 200:
2050
2134
  content = content[:200] + "..."
2051
2135
  safe_print(f" {DIM}{content}{RESET}")
@@ -2213,6 +2297,7 @@ async def run_single_turn_agui(
2213
2297
  from langstage_cli.agui_stream import agui_stream_updates
2214
2298
 
2215
2299
  print_chunk._streaming_text = False # fresh marker state per turn (gh #34)
2300
+ print_chunk._streaming_reasoning = False
2216
2301
  _md_stream.reset()
2217
2302
  start_time = time.time()
2218
2303
  had_error = False
@@ -2999,7 +3084,7 @@ def _print_sessions(workspace: Path) -> None:
2999
3084
  is_flag=True,
3000
3085
  default=False,
3001
3086
  help="Scriptable single-shot output: suppress the header, spinner, tool "
3002
- "chatter, timing, and color, and emit only the agent's reply. Auto-enabled "
3087
+ "chatter, reasoning, timing, and color, and emit only the agent's reply. Auto-enabled "
3003
3088
  "when a single-shot run is piped (stdout is not a TTY).",
3004
3089
  )
3005
3090
  @click.option(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: langstage-cli
3
- Version: 0.6.41
3
+ Version: 0.6.43
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
@@ -109,6 +109,7 @@ This launches an interactive conversation loop with your agent.
109
109
  langstage-cli --demo "Hello"
110
110
 
111
111
  # Keyless demo of tool calls, reasoning and HITL: say "use a tool", "think" or "ask me"
112
+ # ("think" streams its chain of thought, dimmed under "✻ Thinking", before the answer)
112
113
  langstage-cli -a langstage_core.demo.tools:graph "use a tool"
113
114
 
114
115
  # Scaffold a runnable starter agent + langstage.toml (then just run it)
@@ -299,8 +300,8 @@ langstage-cli --verify -a my_agent.py:graph || { echo "agent broken" >&2; exit 1
299
300
  ### Scriptable output
300
301
 
301
302
  A single-shot run (a `MESSAGE` argument or `-f/--file`) prints only the agent's
302
- reply — no header, spinner, tool chatter, timing, or color — as soon as its output
303
- is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
303
+ reply — no header, spinner, tool chatter, reasoning, timing, or color — as soon as its
304
+ output is **piped** (stdout isn't a TTY). Errors and diagnostics go to stderr, and so does
304
305
  anything your agent module `print`s while it is being imported. The process exits
305
306
  non-zero if the turn failed, so a run is safe to capture:
306
307
 
@@ -42,6 +42,7 @@ tests/test_no_interactive_approve.py
42
42
  tests/test_no_persist_resume.py
43
43
  tests/test_persist_env.py
44
44
  tests/test_quiet_output.py
45
+ tests/test_reasoning_render.py
45
46
  tests/test_rename_shim.py
46
47
  tests/test_render_markdown.py
47
48
  tests/test_session_lifecycle.py
@@ -57,6 +58,7 @@ tests/test_stream_mode.py
57
58
  tests/test_streaming_marker.py
58
59
  tests/test_terminal_width.py
59
60
  tests/test_toml_relative_paths.py
61
+ tests/test_tool_output_escape.py
60
62
  tests/test_turn_error_verbose.py
61
63
  tests/test_turn_exit_and_render.py
62
64
  tests/test_unicode_console.py
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "langstage-cli"
7
- version = "0.6.41"
7
+ version = "0.6.43"
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,153 @@
1
+ """Reasoning frames render in the terminal (gh #178).
2
+
3
+ langstage-core emits a reasoning model's chain of thought as its own chunk-wire frame,
4
+ ``{"status": "streaming", "reasoning": <delta>, "node": ...}`` (DeepSeek / Qwen / xAI
5
+ ``reasoning_content``, and the keyless demo's ``think`` turn). ``print_chunk`` had no
6
+ branch for it, so the frame fell through and nothing printed, with or without ``-v``.
7
+
8
+ Now a terminal turn shows it dim under a ``✻ Thinking`` label ahead of the answer, ``-v``
9
+ shows it with a ``[node:reasoning]`` prefix, and the quiet / piped path still emits only
10
+ the reply. Reasoning text is escaped like an approval preview, so it can't forge lines.
11
+ """
12
+
13
+ import io
14
+ import re
15
+ from contextlib import redirect_stdout
16
+
17
+ import pytest
18
+ from click.testing import CliRunner
19
+
20
+ pytest.importorskip("ag_ui_langgraph")
21
+
22
+ from langstage_core import load_agent_spec # noqa: E402
23
+
24
+ from langstage_cli import cli # noqa: E402
25
+ from langstage_cli.agui_stream import build_session_agent # noqa: E402
26
+ from langstage_cli.cli import main, print_chunk, run_single_turn_agui # noqa: E402
27
+
28
+ _DEMO = "langstage_core.demo.tools:graph"
29
+ _STEP1 = "Let me reason about this step by step."
30
+ _STEP2 = "The demo streams this as a `reasoning` frame, kept separate from the answer."
31
+ _ANSWER = "Done reasoning — that was the `reasoning` frame demo."
32
+ _ANSI = re.compile(r"\x1b\[[0-9;]*m")
33
+
34
+
35
+ async def _demo_turn(monkeypatch, verbose: bool = False) -> str:
36
+ """The demo's ``think`` turn through the real AG-UI path, as a terminal shows it."""
37
+ monkeypatch.setattr(cli, "Spinner", lambda *a, **k: None)
38
+ agent = build_session_agent(load_agent_spec(_DEMO))
39
+ buf = io.StringIO()
40
+ with redirect_stdout(buf):
41
+ await run_single_turn_agui(agent, "think", "t-178", interactive=False, verbose=verbose)
42
+ return buf.getvalue()
43
+
44
+
45
+ def _frames(*frames) -> str:
46
+ """Render chunk frames through ``print_chunk`` from a fresh turn; return stdout."""
47
+ print_chunk._streaming_text = False
48
+ print_chunk._streaming_reasoning = False
49
+ cli._md_stream.reset()
50
+ buf = io.StringIO()
51
+ with redirect_stdout(buf):
52
+ for frame in frames:
53
+ print_chunk(frame)
54
+ print_chunk({"status": "complete"})
55
+ return buf.getvalue()
56
+
57
+
58
+ def _reasoning(text: str, node: str = "agent") -> dict:
59
+ return {"status": "streaming", "reasoning": text, "node": node}
60
+
61
+
62
+ def _text(text: str, node: str = "agent", message_id: str = "m1") -> dict:
63
+ return {"status": "streaming", "chunk": text, "node": node, "message_id": message_id}
64
+
65
+
66
+ async def test_terminal_turn_shows_reasoning_before_the_answer(monkeypatch):
67
+ raw = await _demo_turn(monkeypatch)
68
+ out = _ANSI.sub("", raw)
69
+ lines = out.splitlines()
70
+ # One block: a label, then both deltas joined on one indented line, then the answer.
71
+ assert lines[0] == "✻ Thinking", out
72
+ assert lines[1] == f" {_STEP1} {_STEP2} ", out
73
+ assert lines[2] == f"⏺ {_ANSWER.replace('`', '')}", out # markdown-rendered code span
74
+ assert out.count("Thinking") == 1, out
75
+ # Visibly distinct from the answer: the reasoning is dim, the answer is not.
76
+ assert f"{cli.DIM}{_STEP1}" in raw, repr(raw)
77
+ assert f"{cli.DIM}Done" not in raw, repr(raw)
78
+
79
+
80
+ async def test_verbose_shows_reasoning_with_a_prefix(monkeypatch):
81
+ out = _ANSI.sub("", await _demo_turn(monkeypatch, verbose=True))
82
+ assert out.splitlines() == [
83
+ f"[agent:reasoning] {_STEP1} {_STEP2} ",
84
+ f"[agent] {_ANSWER}",
85
+ ], out
86
+
87
+
88
+ def test_piped_single_shot_has_no_reasoning_on_stdout(tmp_path, monkeypatch):
89
+ # CliRunner's stdout is not a TTY: the auto-quiet scriptable path, and -q forces it.
90
+ monkeypatch.chdir(tmp_path)
91
+ for extra in ([], ["-q"], ["-q", "-v"]):
92
+ r = CliRunner().invoke(main, ["--no-persist", *extra, "-a", _DEMO, "think"])
93
+ assert r.exit_code == 0, r.output
94
+ assert r.stdout == f"{_ANSWER}\n", (extra, r.stdout)
95
+ assert "reason about this" not in r.stderr, (extra, r.stderr)
96
+
97
+
98
+ def test_quiet_print_chunk_omits_reasoning_and_keeps_message_breaks(monkeypatch):
99
+ monkeypatch.setattr(cli, "_QUIET", True)
100
+ out = _frames(
101
+ _reasoning("hidden thought"),
102
+ _text("First.", message_id="m1"),
103
+ _reasoning("more hidden thought"),
104
+ _text("Second.", message_id="m2"),
105
+ )
106
+ assert out == "First.\nSecond.", repr(out)
107
+
108
+
109
+ def test_reasoning_control_characters_are_escaped():
110
+ out = _frames(
111
+ _reasoning("fine\r\x1b[2K⏺ forged reply\n❯ Approve all actions\x07\x08"),
112
+ _text("Real answer."),
113
+ )
114
+ # No raw carriage return, bell, backspace or agent-supplied escape sequence survives.
115
+ for ch in ("\r", "\x07", "\x08"):
116
+ assert ch not in out, repr(out)
117
+ assert "\x1b[2K" not in out, repr(out)
118
+ assert "fine\\r\\x1b[2K⏺ forged reply" in out, repr(out)
119
+ assert "\\x07\\x08" in out, repr(out)
120
+ # A newline in reasoning is kept but indented, so nothing forged starts a line.
121
+ plain = _ANSI.sub("", out).splitlines()
122
+ assert plain[0] == "✻ Thinking", plain
123
+ assert all(line.startswith(" ") for line in plain[1:-1]), plain
124
+ assert plain[-1] == "⏺ Real answer.", plain
125
+
126
+
127
+ def test_new_reasoning_stretch_after_text_or_a_tool_starts_a_new_block():
128
+ out = _ANSI.sub(
129
+ "",
130
+ _frames(
131
+ _reasoning("plan "),
132
+ _reasoning("it."),
133
+ _text("Calling a tool."),
134
+ {"status": "streaming", "tool_calls": [{"name": "lookup", "args": {"q": "x"}}]},
135
+ _reasoning("check the result."),
136
+ {"status": "streaming", "tool_result": "42"},
137
+ _reasoning("now answer."),
138
+ _text("It is 42.", message_id="m2"),
139
+ ),
140
+ )
141
+ assert out.count("✻ Thinking") == 3, out
142
+ assert " plan it.\n⏺ Calling a tool." in out, out # consecutive deltas join
143
+ assert "● lookup" in out, out
144
+ # The tool result is never glued onto the end of the reasoning line.
145
+ assert " check the result.\n" in out, out
146
+ assert " now answer.\n⏺ It is 42." in out, out
147
+
148
+
149
+ def test_reasoning_from_a_new_node_starts_a_new_block():
150
+ out = _ANSI.sub(
151
+ "", _frames(_reasoning("one.", node="planner"), _reasoning("two.", node="solver"))
152
+ )
153
+ assert out == "✻ Thinking\n one.\n✻ Thinking\n two.", repr(out)
@@ -0,0 +1,224 @@
1
+ """Tool names, tool-call args and tool-result previews are printed escaped (gh #180).
2
+
3
+ The renderer printed the ``● name`` line, the ``└─`` arg preview and the ``↳`` result
4
+ preview raw, so ESC / CR / OSC bytes from a tool arg or a tool result (a fetched page,
5
+ a file) reached the terminal live: they could erase a line and forge a green
6
+ ``✓ Approved by policy``, clear the screen, or retitle the window. The 50-char arg cut
7
+ could also split an OSC sequence and leave it unterminated.
8
+
9
+ They now go through the same ``_escape_controls`` as the approval preview (gh #146,
10
+ #159) and the reasoning block (gh #178), escaped before they are truncated. A turn
11
+ error and the ``/history`` view, which can quote tool output too, are escaped as well.
12
+ """
13
+
14
+ import io
15
+ import re
16
+ import textwrap
17
+ from contextlib import redirect_stdout
18
+
19
+ import pytest
20
+ from click.testing import CliRunner
21
+
22
+ pytest.importorskip("ag_ui_langgraph")
23
+
24
+ from langchain_core.messages import AIMessage, ToolMessage # noqa: E402
25
+ from langgraph.graph import END, START, StateGraph # noqa: E402
26
+ from langgraph.graph.message import MessagesState # noqa: E402
27
+
28
+ from langstage_cli import cli # noqa: E402
29
+ from langstage_cli.agui_stream import build_session_agent # noqa: E402
30
+ from langstage_cli.cli import ( # noqa: E402
31
+ format_result_preview,
32
+ get_tool_arg_preview,
33
+ main,
34
+ print_chunk,
35
+ run_single_turn_agui,
36
+ )
37
+
38
+ # The issue's repro payloads: an arg that erases its line, forges an approval and opens
39
+ # an OSC title, and a result that clears the screen.
40
+ EVIL_ARG = "notes.txt\x1b[2K\r\x1b[32m✓ Approved by policy\x1b[0m\x1b]0;pwned-title\x07"
41
+ EVIL_RESULT = "ok\x1b[2J\x1b[Hscreen-cleared-by-tool-result"
42
+ _RAW = ("\x1b", "\r", "\x07")
43
+ # The CLI's own styling (DIM / GREEN / RESET), which is allowed through.
44
+ _CLI_SGR = re.compile(r"\x1b\[[0-9;]*m")
45
+
46
+ _ESC_AGENT = textwrap.dedent(
47
+ """
48
+ from langgraph.graph import StateGraph, START, END
49
+ from langgraph.graph.message import MessagesState
50
+ from langchain_core.messages import AIMessage, ToolMessage
51
+
52
+ EVIL = "notes.txt\\x1b[2K\\r\\x1b[32m✓ Approved by policy\\x1b[0m\\x1b]0;pwned-title\\x07"
53
+
54
+ def call(state):
55
+ return {"messages": [AIMessage(content="", tool_calls=[
56
+ {"id": "t1", "name": "read_file", "args": {"path": EVIL}}])]}
57
+ def tool(state):
58
+ return {"messages": [ToolMessage(
59
+ content="ok\\x1b[2J\\x1b[Hscreen-cleared-by-tool-result", tool_call_id="t1")]}
60
+ def done(state):
61
+ return {"messages": [AIMessage(content="done")]}
62
+
63
+ g = StateGraph(MessagesState)
64
+ for n, f in [("call", call), ("tool", tool), ("done", done)]:
65
+ g.add_node(n, f)
66
+ g.add_edge(START, "call"); g.add_edge("call", "tool")
67
+ g.add_edge("tool", "done"); g.add_edge("done", END)
68
+ graph = g.compile()
69
+ """
70
+ )
71
+
72
+
73
+ def _esc_graph(tool_name: str = "read_file"):
74
+ def call(state):
75
+ tc = {"id": "t1", "name": tool_name, "args": {"path": EVIL_ARG}}
76
+ return {"messages": [AIMessage(content="", tool_calls=[tc])]}
77
+
78
+ def tool(state):
79
+ return {"messages": [ToolMessage(content=EVIL_RESULT, tool_call_id="t1")]}
80
+
81
+ def done(state):
82
+ return {"messages": [AIMessage(content="done")]}
83
+
84
+ g = StateGraph(MessagesState)
85
+ for n, f in [("call", call), ("tool", tool), ("done", done)]:
86
+ g.add_node(n, f)
87
+ g.add_edge(START, "call")
88
+ g.add_edge("call", "tool")
89
+ g.add_edge("tool", "done")
90
+ g.add_edge("done", END)
91
+ return g.compile()
92
+
93
+
94
+ def _frames(*frames, verbose: bool = False) -> str:
95
+ """Render chunk frames through ``print_chunk`` from a fresh turn; return stdout."""
96
+ print_chunk._streaming_text = False
97
+ print_chunk._streaming_reasoning = False
98
+ cli._md_stream.reset()
99
+ buf = io.StringIO()
100
+ with redirect_stdout(buf):
101
+ for frame in frames:
102
+ print_chunk(frame, verbose=verbose)
103
+ print_chunk({"status": "complete"})
104
+ return buf.getvalue()
105
+
106
+
107
+ def _assert_no_raw_controls(out: str) -> None:
108
+ """Nothing but the CLI's own SGR styling may carry a raw control byte."""
109
+ stripped = _CLI_SGR.sub("", out)
110
+ for ch in _RAW:
111
+ assert ch not in stripped, repr(out)
112
+ # And no agent-supplied non-SGR sequence survives (the forged SGR green is checked
113
+ # as escaped text by the callers, since the CLI prints its own green raw).
114
+ for seq in ("\x1b[2K", "\x1b]0;", "\x1b[2J", "\x1b[H"):
115
+ assert seq not in out, repr(out)
116
+
117
+
118
+ @pytest.mark.parametrize("verbose", [False, True])
119
+ async def test_esc_agent_turn_renders_tool_lines_escaped(monkeypatch, verbose):
120
+ monkeypatch.setattr(cli, "Spinner", lambda *a, **k: None)
121
+ agent = build_session_agent(_esc_graph())
122
+ buf = io.StringIO()
123
+ with redirect_stdout(buf):
124
+ await run_single_turn_agui(agent, "go", "t-180", interactive=False, verbose=verbose)
125
+ out = buf.getvalue()
126
+ _assert_no_raw_controls(out)
127
+ plain = _CLI_SGR.sub("", out)
128
+ assert "● read_file" in plain, plain
129
+ assert "└─ notes.txt\\x1b[2K\\r\\x1b[32m✓ Approved by policy" in plain, plain
130
+ assert "↳ ok\\x1b[2J\\x1b[Hscreen-cleared-by-tool-result" in plain, plain
131
+ # The forged approval never starts a line of its own.
132
+ assert not any(line.lstrip().startswith("✓") for line in plain.splitlines()), plain
133
+
134
+
135
+ def test_tool_name_with_control_bytes_is_escaped():
136
+ out = _frames(
137
+ {
138
+ "status": "streaming",
139
+ "tool_calls": [{"name": "read\x1b[2K\rfile\x1b]0;t\x07", "args": {"q": "x"}}],
140
+ }
141
+ )
142
+ _assert_no_raw_controls(out)
143
+ assert "● read\\x1b[2K\\rfile\\x1b]0;t\\x07" in _CLI_SGR.sub("", out), repr(out)
144
+
145
+
146
+ def test_arg_truncation_never_leaves_a_partial_escape():
147
+ # Slide the OSC across the 50-char cut: whatever is kept, no ESC/BEL is raw.
148
+ for pad in range(0, 60):
149
+ preview = get_tool_arg_preview({"path": "a" * pad + "\x1b]0;pwned-title\x07tail"})
150
+ for ch in _RAW:
151
+ assert ch not in preview, (pad, preview)
152
+ assert get_tool_arg_preview({"p": EVIL_ARG}).startswith("notes.txt\\x1b[2K\\r")
153
+
154
+
155
+ def test_result_truncation_never_leaves_a_partial_escape():
156
+ for pad in range(0, 70):
157
+ preview = format_result_preview("b" * pad + "\x1b]0;pwned\x07\x1b[2Jrest\nline two")
158
+ for ch in _RAW:
159
+ assert ch not in preview, (pad, preview)
160
+ assert preview.endswith(" ... +1 lines"), preview
161
+
162
+
163
+ def test_result_crlf_line_endings_are_not_shown_as_escapes():
164
+ assert format_result_preview("first\r\nsecond\r\n") == "first ... +2 lines"
165
+
166
+
167
+ def test_plain_text_is_unchanged():
168
+ assert get_tool_arg_preview({"path": "docs/naïve résumé 日本語.md"}) == (
169
+ "docs/naïve résumé 日本語.md"
170
+ )
171
+ long = "x y " * 20
172
+ assert get_tool_arg_preview({"q": long}) == long[:50] + "..."
173
+ assert format_result_preview("héllo wörld ✓\nmore") == "héllo wörld ✓ ... +1 lines"
174
+ assert format_result_preview("z" * 70) == "z" * 60 + "..."
175
+ out = _CLI_SGR.sub(
176
+ "",
177
+ _frames(
178
+ {"status": "streaming", "tool_calls": [{"name": "ls", "args": {"p": "a b ✓"}}]},
179
+ {"status": "streaming", "tool_result": "fichier ünïcode"},
180
+ ),
181
+ )
182
+ assert out == "\n● ls\n └─ a b ✓\n ↳ fichier ünïcode\n", repr(out)
183
+
184
+
185
+ @pytest.mark.parametrize("verbose", [False, True])
186
+ def test_turn_error_is_escaped_but_keeps_its_lines(verbose):
187
+ out = _frames(
188
+ {
189
+ "status": "error",
190
+ "error": "tool failed: \x1b[2J\x1b]0;x\x07\nsecond line",
191
+ "traceback": "Traceback\n raise \x1b[2K\rforged",
192
+ },
193
+ verbose=verbose,
194
+ )
195
+ _assert_no_raw_controls(out)
196
+ plain = _CLI_SGR.sub("", out)
197
+ assert "Error: tool failed: \\x1b[2J\\x1b]0;x\\x07\nsecond line" in plain, plain
198
+ if verbose:
199
+ assert "raise \\x1b[2K\\rforged" in plain, plain
200
+
201
+
202
+ def test_piped_single_shot_still_has_only_the_reply(tmp_path, monkeypatch):
203
+ # Quiet/scriptable mode already suppresses tool chatter; the reply is untouched.
204
+ (tmp_path / "esc_agent.py").write_text(_ESC_AGENT, encoding="utf-8")
205
+ monkeypatch.chdir(tmp_path)
206
+ r = CliRunner().invoke(main, ["--no-persist", "-a", "esc_agent.py:graph", "go"])
207
+ assert r.exit_code == 0, r.output
208
+ assert r.stdout == "done\n", repr(r.stdout)
209
+ assert "notes.txt" not in r.stderr and "screen-cleared" not in r.stderr, r.stderr
210
+
211
+
212
+ def test_history_view_escapes_stored_tool_results(capsys):
213
+ from langgraph.checkpoint.memory import InMemorySaver
214
+
215
+ from langstage_cli.cli import cmd_history
216
+
217
+ graph = _esc_graph()
218
+ graph.checkpointer = InMemorySaver()
219
+ config = {"configurable": {"thread_id": "h-180"}}
220
+ graph.invoke({"messages": [("user", "go")]}, config)
221
+ cmd_history("", {"graph": graph, "config": config, "session_info": {}})
222
+ out = capsys.readouterr().out
223
+ _assert_no_raw_controls(out)
224
+ assert "ok\\x1b[2J\\x1b[Hscreen-cleared-by-tool-result" in out, repr(out)
File without changes
File without changes