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.
- {langstage_cli-0.6.41/langstage_cli.egg-info → langstage_cli-0.6.43}/PKG-INFO +4 -3
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/README.md +3 -2
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/cli.py +96 -11
- {langstage_cli-0.6.41 → langstage_cli-0.6.43/langstage_cli.egg-info}/PKG-INFO +4 -3
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/SOURCES.txt +2 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/pyproject.toml +1 -1
- langstage_cli-0.6.43/tests/test_reasoning_render.py +153 -0
- langstage_cli-0.6.43/tests/test_tool_output_escape.py +224 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/LICENSE +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/deepagent_code/__init__.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/__init__.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/agui_stream.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/config.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/exit_codes.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli/sessions.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/dependency_links.txt +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/entry_points.txt +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/requires.txt +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/langstage_cli.egg-info/top_level.txt +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/setup.cfg +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_advertised_wave4.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_agui_stream.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_approval_menu_abort.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_async_mode.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_checkpointer.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_cli.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_cli_help.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_codeconfig.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_config.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_config_command.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_configurable.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_deferred_pass.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_exit_codes.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_generic_interrupt.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_help_render.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_history_persist.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_hitl_approval_preview.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_hitl_resume_warning.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_init.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_interrupt_non_tty.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_legacy_config_home.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_load_error.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_message_boundaries.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_color.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_interactive_approve.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_no_persist_resume.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_persist_env.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_quiet_output.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_rename_shim.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_render_markdown.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_session_lifecycle.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_sessions.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_show_config.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_show_config_malformed.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spec_loading_core.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spec_resolution.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_spinner_reply_survives.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stdin_interactive.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stdin_single_shot.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_stream_mode.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_streaming_marker.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_terminal_width.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_toml_relative_paths.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_turn_error_verbose.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_turn_exit_and_render.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_unicode_console.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_verbose_command.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_verify_flag.py +0 -0
- {langstage_cli-0.6.41 → langstage_cli-0.6.43}/tests/test_workspace.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
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.
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|