fruxon 0.5.2__tar.gz → 0.5.3__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.
- {fruxon-0.5.2 → fruxon-0.5.3}/PKG-INFO +1 -1
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/_version.py +2 -2
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/run.py +123 -61
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_cli.py +179 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/.gitignore +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/HISTORY.md +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/LICENSE +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/README.md +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/pyproject.toml +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/__init__.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/__main__.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/__init__.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/agents.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/auth.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/chat.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/config.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/doctor.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/export.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/trace.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/credentials.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/doctor.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/exceptions.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/export.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/fruxon.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/models.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/output.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/params.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/ui.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/update_check.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/__init__.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_client.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_credentials.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_doctor.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_export.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_fruxon.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_output.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_params.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_ui.py +0 -0
- {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_update_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: fruxon
|
|
3
|
-
Version: 0.5.
|
|
3
|
+
Version: 0.5.3
|
|
4
4
|
Summary: The Fruxon SDK is a lightweight Python client for integrating with the Fruxon platform.
|
|
5
5
|
Project-URL: bugs, https://github.com/fruxon-ai/fruxon-sdk/issues
|
|
6
6
|
Project-URL: changelog, https://github.com/fruxon-ai/fruxon-sdk/blob/main/HISTORY.md
|
|
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
|
|
|
18
18
|
commit_id: str | None
|
|
19
19
|
__commit_id__: str | None
|
|
20
20
|
|
|
21
|
-
__version__ = version = '0.5.
|
|
22
|
-
__version_tuple__ = version_tuple = (0, 5,
|
|
21
|
+
__version__ = version = '0.5.3'
|
|
22
|
+
__version_tuple__ = version_tuple = (0, 5, 3)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -333,8 +333,10 @@ def _run_stream(
|
|
|
333
333
|
# Live spinner area at the bottom of stderr for currently-running tools.
|
|
334
334
|
# Disabled when stderr isn't a TTY (pipes, CI logs) — those consumers
|
|
335
335
|
# can't render cursor-movement updates anyway, and our merge-only
|
|
336
|
-
# fallback gives them clean line-per-tool output.
|
|
337
|
-
|
|
336
|
+
# fallback gives them clean line-per-tool output. ``verbose`` flows
|
|
337
|
+
# through so the live rows expand args inline while running, not just
|
|
338
|
+
# once each tool completes.
|
|
339
|
+
spinner_area = _ToolSpinnerArea(enabled=stderr.is_terminal, verbose=verbose)
|
|
338
340
|
|
|
339
341
|
try:
|
|
340
342
|
for event in client.stream(agent, parameters=parameters, session_id=session_id):
|
|
@@ -364,19 +366,22 @@ def _run_stream(
|
|
|
364
366
|
break_text_line()
|
|
365
367
|
tool_id_raw = event.data.get("id")
|
|
366
368
|
tool_id = str(tool_id_raw) if tool_id_raw is not None else ""
|
|
367
|
-
#
|
|
368
|
-
#
|
|
369
|
-
#
|
|
370
|
-
#
|
|
371
|
-
#
|
|
372
|
-
#
|
|
373
|
-
#
|
|
374
|
-
#
|
|
375
|
-
#
|
|
369
|
+
# Order matters for smooth rendering:
|
|
370
|
+
# 1. Drop from running_ids and push the new (smaller)
|
|
371
|
+
# renderable into Live *before* printing anything. If
|
|
372
|
+
# we printed first, Rich's Live would redraw the
|
|
373
|
+
# permanent row above the *old* renderable — leaving
|
|
374
|
+
# the completed tool's spinner row visible for one
|
|
375
|
+
# frame, which reads as a flash.
|
|
376
|
+
# 2. Emit the permanent row as a single console.print of
|
|
377
|
+
# a Group. One print = one Live clear+redraw, instead
|
|
378
|
+
# of one per line (merged line, each args line, each
|
|
379
|
+
# result line). Bundling is what makes verbose /
|
|
380
|
+
# failure expansion stop flickering.
|
|
376
381
|
if tool_id in running_ids:
|
|
377
382
|
running_ids.remove(tool_id)
|
|
378
|
-
_render_tool_result(event.data, tools_state, verbose=verbose)
|
|
379
383
|
spinner_area.refresh(running_ids, tools_state)
|
|
384
|
+
_render_tool_result(event.data, tools_state, verbose=verbose)
|
|
380
385
|
continue
|
|
381
386
|
|
|
382
387
|
if event.event == "error":
|
|
@@ -539,19 +544,26 @@ def _render_tool_result(
|
|
|
539
544
|
args_preview = _format_args(arguments)
|
|
540
545
|
if args_preview:
|
|
541
546
|
line += f" [dim]{args_preview}[/dim]"
|
|
542
|
-
stderr.print(line)
|
|
543
547
|
|
|
544
|
-
#
|
|
545
|
-
#
|
|
548
|
+
# Build the full block (header + optional expanded args + optional
|
|
549
|
+
# expanded result) and emit it as ONE console.print. When a Live
|
|
550
|
+
# spinner area is active above us on the same console, each print
|
|
551
|
+
# call triggers a clear-and-redraw of the live area; one print per
|
|
552
|
+
# completed tool keeps that to a single redraw instead of N.
|
|
546
553
|
expand = verbose or is_failure
|
|
554
|
+
detail_lines: list[str] = []
|
|
547
555
|
if expand:
|
|
548
|
-
# Args were not printed inline (verbose drops the preview; failure
|
|
549
|
-
# gets the full block here regardless). Show the full args + the
|
|
550
|
-
# full result body so the user has everything they need without
|
|
551
|
-
# rerunning.
|
|
552
556
|
if arguments is not None:
|
|
553
|
-
|
|
554
|
-
|
|
557
|
+
detail_lines.extend(_args_detail_lines(arguments))
|
|
558
|
+
detail_lines.extend(_result_detail_lines(data.get("result"), is_failure=is_failure))
|
|
559
|
+
|
|
560
|
+
if detail_lines:
|
|
561
|
+
from rich.console import Group
|
|
562
|
+
from rich.text import Text
|
|
563
|
+
|
|
564
|
+
stderr.print(Group(Text.from_markup(line), *(Text.from_markup(d) for d in detail_lines)))
|
|
565
|
+
else:
|
|
566
|
+
stderr.print(line)
|
|
555
567
|
|
|
556
568
|
|
|
557
569
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
@@ -587,8 +599,15 @@ class _ToolSpinnerArea:
|
|
|
587
599
|
gives them clean line-per-tool output.
|
|
588
600
|
"""
|
|
589
601
|
|
|
590
|
-
def __init__(self, *, enabled: bool) -> None:
|
|
602
|
+
def __init__(self, *, enabled: bool, verbose: bool = False) -> None:
|
|
591
603
|
self._enabled = enabled
|
|
604
|
+
# ``verbose`` matches the ``fruxon run -v`` flag. Without it the
|
|
605
|
+
# spinner row is a single line with truncated args (what fits at
|
|
606
|
+
# a glance). With it, each running tool gets a multi-line block
|
|
607
|
+
# showing every argument keyed-and-aligned — the same expansion
|
|
608
|
+
# the permanent completion row uses, just visible *while* the
|
|
609
|
+
# tool is running instead of only after it finishes.
|
|
610
|
+
self._verbose = verbose
|
|
592
611
|
self._live = None # rich.live.Live | None — lazy start
|
|
593
612
|
|
|
594
613
|
def refresh(self, running_ids: list[str], state: dict[str, dict[str, object]]) -> None:
|
|
@@ -627,6 +646,20 @@ class _ToolSpinnerArea:
|
|
|
627
646
|
lines drawn, but the underlying Live stays alive so the next
|
|
628
647
|
tool dispatch updates in place rather than triggering a fresh
|
|
629
648
|
start (which would flicker).
|
|
649
|
+
|
|
650
|
+
Verbose mode expands each tool into:
|
|
651
|
+
|
|
652
|
+
⠋ #1 list_commits
|
|
653
|
+
owner fruxon-ai
|
|
654
|
+
repo fruxon-backend
|
|
655
|
+
per_page 100
|
|
656
|
+
|
|
657
|
+
Compact mode keeps it to one line with a truncated arg preview:
|
|
658
|
+
|
|
659
|
+
⠋ #1 list_commits owner=fruxon-ai, repo=fruxon-backend, …
|
|
660
|
+
|
|
661
|
+
The spinner only animates on the header line; the args block
|
|
662
|
+
below it is static, which keeps the live area redraw cheap.
|
|
630
663
|
"""
|
|
631
664
|
from rich.console import Group
|
|
632
665
|
from rich.spinner import Spinner
|
|
@@ -640,15 +673,26 @@ class _ToolSpinnerArea:
|
|
|
640
673
|
entry = state.get(tid) or {}
|
|
641
674
|
name = entry.get("name", "tool")
|
|
642
675
|
index = entry.get("index")
|
|
676
|
+
arguments = entry.get("arguments")
|
|
643
677
|
tag = f"[dim]#{index}[/dim] " if isinstance(index, int) else ""
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
678
|
+
|
|
679
|
+
if self._verbose:
|
|
680
|
+
# Header gets the spinner; args block stays static below.
|
|
681
|
+
header = Spinner("dots", text=f"{tag}[bold]{name}[/bold]", style="cyan")
|
|
682
|
+
args_block = _format_args_block(arguments)
|
|
683
|
+
if args_block is not None:
|
|
684
|
+
rows.append(Group(header, args_block))
|
|
685
|
+
else:
|
|
686
|
+
rows.append(header)
|
|
687
|
+
else:
|
|
688
|
+
label = f"{tag}[bold]{name}[/bold]"
|
|
689
|
+
args_preview = _format_args(arguments)
|
|
690
|
+
if args_preview:
|
|
691
|
+
label += f" [dim]{args_preview}[/dim]"
|
|
692
|
+
# ``dots`` matches the spinner used by
|
|
693
|
+
# ``rich.console.status`` — same visual language as the
|
|
694
|
+
# spinners in ``fruxon agents list`` and ``fruxon trace``.
|
|
695
|
+
rows.append(Spinner("dots", text=label, style="cyan"))
|
|
652
696
|
return Group(*rows)
|
|
653
697
|
|
|
654
698
|
|
|
@@ -661,60 +705,78 @@ _DETAIL_INDENT = " " # 4 spaces — visually nests under the #N tag column
|
|
|
661
705
|
_DETAIL_VALUE_MAX = 200 # per-value cap; full payloads still live in `trace`
|
|
662
706
|
|
|
663
707
|
|
|
664
|
-
def
|
|
665
|
-
"""
|
|
708
|
+
def _args_detail_lines(arguments: object) -> list[str]:
|
|
709
|
+
"""Produce the formatted line strings for an args detail block.
|
|
666
710
|
|
|
667
|
-
|
|
711
|
+
Pure function — returns lines, doesn't print. The lines are
|
|
712
|
+
rich-markup-aware (``[dim]…[/dim]`` etc.) and use ``_DETAIL_INDENT``
|
|
713
|
+
so they nest visually under whatever they appear below.
|
|
668
714
|
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
* other (list, scalar, None) — JSON-dump as a single line.
|
|
715
|
+
Shared by the permanent completion row (bundled into a single
|
|
716
|
+
Group print to avoid Live redraw flicker) and the live spinner
|
|
717
|
+
area (wraps the lines inside the Live renderable while a tool is
|
|
718
|
+
still running).
|
|
674
719
|
|
|
675
|
-
|
|
676
|
-
|
|
720
|
+
Three argument shapes:
|
|
721
|
+
``dict`` — ``key value`` rows, key column padded to longest key.
|
|
722
|
+
``str`` — single quoted-ish line, truncated to value cap.
|
|
723
|
+
other — JSON-dump on one line.
|
|
677
724
|
"""
|
|
678
725
|
if arguments is None:
|
|
679
|
-
return
|
|
726
|
+
return []
|
|
680
727
|
if isinstance(arguments, dict):
|
|
681
728
|
if not arguments:
|
|
682
|
-
return
|
|
729
|
+
return []
|
|
683
730
|
# Pad keys to the longest one so values line up — same trick the
|
|
684
731
|
# ``whoami`` and ``agents get`` views use for kv rows.
|
|
685
732
|
key_width = max(len(str(k)) for k in arguments)
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
733
|
+
return [
|
|
734
|
+
f"[dim]{_DETAIL_INDENT}{str(key):<{key_width}}[/dim] {_render_detail_value(value)}"
|
|
735
|
+
for key, value in arguments.items()
|
|
736
|
+
]
|
|
690
737
|
if isinstance(arguments, str):
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
738
|
+
return [f"[dim]{_DETAIL_INDENT}{_truncate(arguments, _DETAIL_VALUE_MAX)}[/dim]"]
|
|
739
|
+
return [f"[dim]{_DETAIL_INDENT}{_render_detail_value(arguments)}[/dim]"]
|
|
740
|
+
|
|
694
741
|
|
|
742
|
+
def _format_args_block(arguments: object):
|
|
743
|
+
"""Return a Rich renderable of the indented args block, or ``None``.
|
|
744
|
+
|
|
745
|
+
Used by the live spinner area to embed full argument details under
|
|
746
|
+
the running tool's header line when ``--verbose`` is on. Returns
|
|
747
|
+
``None`` when there are no args worth rendering (empty dict, ``None``,
|
|
748
|
+
etc.) so the caller can skip the block entirely.
|
|
749
|
+
"""
|
|
750
|
+
lines = _args_detail_lines(arguments)
|
|
751
|
+
if not lines:
|
|
752
|
+
return None
|
|
753
|
+
from rich.console import Group
|
|
754
|
+
from rich.text import Text
|
|
755
|
+
|
|
756
|
+
return Group(*(Text.from_markup(line) for line in lines))
|
|
695
757
|
|
|
696
|
-
def _render_result_detail(result: object, *, is_failure: bool) -> None:
|
|
697
|
-
"""Pretty-print a tool result under the completion line.
|
|
698
758
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
759
|
+
def _result_detail_lines(result: object, *, is_failure: bool) -> list[str]:
|
|
760
|
+
"""Produce formatted line strings for a tool-result detail block.
|
|
761
|
+
|
|
762
|
+
Pure function — returns rich-markup lines. Strings render as-is;
|
|
763
|
+
structured payloads are JSON-dumped one item per line, capped at 20.
|
|
764
|
+
Failures get a red tint so they're scannable in a wall of green
|
|
765
|
+
checks. The caller decides whether to print line-by-line or bundle
|
|
766
|
+
into one renderable (the streaming path bundles to avoid Live
|
|
767
|
+
redraw flicker).
|
|
702
768
|
"""
|
|
703
769
|
if result is None:
|
|
704
|
-
return
|
|
770
|
+
return []
|
|
705
771
|
style = "red" if is_failure else "dim"
|
|
706
772
|
if isinstance(result, str):
|
|
707
773
|
text = _truncate(result, _DETAIL_VALUE_MAX * 3) # results can be longer than args
|
|
708
|
-
for line in text.splitlines() or [text]
|
|
709
|
-
stderr.print(f"[{style}]{_DETAIL_INDENT}{line}[/{style}]")
|
|
710
|
-
return
|
|
711
|
-
# Structured — render as pretty JSON, capped to keep the block scannable.
|
|
774
|
+
return [f"[{style}]{_DETAIL_INDENT}{line}[/{style}]" for line in (text.splitlines() or [text])]
|
|
712
775
|
try:
|
|
713
776
|
rendered = json.dumps(result, indent=2, ensure_ascii=False)
|
|
714
777
|
except (TypeError, ValueError):
|
|
715
778
|
rendered = str(result)
|
|
716
|
-
for line in rendered.splitlines()[:20]
|
|
717
|
-
stderr.print(f"[{style}]{_DETAIL_INDENT}{line}[/{style}]")
|
|
779
|
+
return [f"[{style}]{_DETAIL_INDENT}{line}[/{style}]" for line in rendered.splitlines()[:20]]
|
|
718
780
|
|
|
719
781
|
|
|
720
782
|
def _render_detail_value(value: object) -> str:
|
|
@@ -1637,6 +1637,185 @@ class TestRunStreamPath:
|
|
|
1637
1637
|
assert result.exit_code == 0
|
|
1638
1638
|
assert unique not in result.stderr
|
|
1639
1639
|
|
|
1640
|
+
def test_stream_tool_result_refreshes_spinner_before_printing(self, runner, monkeypatch):
|
|
1641
|
+
"""Live spinner area is updated to drop the completed tool *before*
|
|
1642
|
+
the permanent completion row is printed.
|
|
1643
|
+
|
|
1644
|
+
Why this order matters: Rich's Live re-renders the live area below
|
|
1645
|
+
the cursor on every console.print to the same console. If we print
|
|
1646
|
+
first, Live redraws using the *old* renderable that still contains
|
|
1647
|
+
the just-completed tool's spinner row — a one-frame flash that
|
|
1648
|
+
reads as flicker. Refreshing first means the live area below the
|
|
1649
|
+
printed row already reflects the reduced set.
|
|
1650
|
+
"""
|
|
1651
|
+
credentials.save(credentials.StoredCredentials(api_key="fxn_x", org="acme"))
|
|
1652
|
+
|
|
1653
|
+
from fruxon.cli import run as run_mod
|
|
1654
|
+
from fruxon.fruxon import StreamEvent
|
|
1655
|
+
|
|
1656
|
+
events = [
|
|
1657
|
+
StreamEvent(
|
|
1658
|
+
event="tool_call",
|
|
1659
|
+
data={
|
|
1660
|
+
"id": "1",
|
|
1661
|
+
"toolTrace": {"displayName": "ping"},
|
|
1662
|
+
"arguments": {"host": "example.com"},
|
|
1663
|
+
"startTime": 0,
|
|
1664
|
+
},
|
|
1665
|
+
),
|
|
1666
|
+
StreamEvent(
|
|
1667
|
+
event="tool_result",
|
|
1668
|
+
data={"id": "1", "endTime": 50, "status": "SUCCESS"},
|
|
1669
|
+
),
|
|
1670
|
+
StreamEvent(event="done", data={"trace": {}}),
|
|
1671
|
+
]
|
|
1672
|
+
|
|
1673
|
+
class _StubClient:
|
|
1674
|
+
DEFAULT_BASE_URL = "https://api.fruxon.com"
|
|
1675
|
+
|
|
1676
|
+
def __init__(self, **kwargs):
|
|
1677
|
+
pass
|
|
1678
|
+
|
|
1679
|
+
def stream(self, agent, **kwargs):
|
|
1680
|
+
yield from events
|
|
1681
|
+
|
|
1682
|
+
# Record refreshes and prints into one ordered log. Each entry is
|
|
1683
|
+
# ("refresh", running_ids_snapshot) or ("print",). We only care
|
|
1684
|
+
# about prints that come from the streaming completion path —
|
|
1685
|
+
# everything emitted *during* a tool_result event.
|
|
1686
|
+
log: list[tuple] = []
|
|
1687
|
+
|
|
1688
|
+
real_refresh = run_mod._ToolSpinnerArea.refresh
|
|
1689
|
+
|
|
1690
|
+
def fake_refresh(self, running_ids, state):
|
|
1691
|
+
log.append(("refresh", list(running_ids)))
|
|
1692
|
+
return real_refresh(self, running_ids, state)
|
|
1693
|
+
|
|
1694
|
+
real_print = run_mod.stderr.print
|
|
1695
|
+
|
|
1696
|
+
def fake_print(*args, **kwargs):
|
|
1697
|
+
log.append(("print",))
|
|
1698
|
+
return real_print(*args, **kwargs)
|
|
1699
|
+
|
|
1700
|
+
monkeypatch.setattr(run_mod._ToolSpinnerArea, "refresh", fake_refresh)
|
|
1701
|
+
monkeypatch.setattr(run_mod.stderr, "print", fake_print)
|
|
1702
|
+
monkeypatch.setattr("fruxon.cli.run.FruxonClient", _StubClient)
|
|
1703
|
+
|
|
1704
|
+
result = runner.invoke(app, ["run", "my-agent"])
|
|
1705
|
+
assert result.exit_code == 0
|
|
1706
|
+
|
|
1707
|
+
# Find the refresh that drops the completed tool (running_ids == [])
|
|
1708
|
+
# and the *next* print after it. That print must be a tool-result
|
|
1709
|
+
# row, not preceded by an extra print that would have triggered
|
|
1710
|
+
# a Live redraw with the stale renderable still in place.
|
|
1711
|
+
empty_refresh_idx = next(i for i, entry in enumerate(log) if entry == ("refresh", []))
|
|
1712
|
+
# Everything between the previous refresh (still containing the
|
|
1713
|
+
# tool) and this one must NOT include a print — that's the bug
|
|
1714
|
+
# the ordering fix prevents.
|
|
1715
|
+
prev_refresh_idx = next(i for i, entry in enumerate(log) if entry == ("refresh", ["1"]))
|
|
1716
|
+
between = log[prev_refresh_idx + 1 : empty_refresh_idx]
|
|
1717
|
+
assert ("print",) not in between, (
|
|
1718
|
+
"spinner area must be refreshed (dropping completed tool) "
|
|
1719
|
+
"BEFORE the permanent row is printed — otherwise Live redraws "
|
|
1720
|
+
"with the stale renderable and the row flashes"
|
|
1721
|
+
)
|
|
1722
|
+
|
|
1723
|
+
def test_stream_failure_block_emitted_as_single_print(self, runner, monkeypatch):
|
|
1724
|
+
"""Failure auto-expansion (header + args block + result block) is
|
|
1725
|
+
emitted as ONE ``stderr.print`` call.
|
|
1726
|
+
|
|
1727
|
+
Each print to the Live's console triggers a clear-and-redraw of
|
|
1728
|
+
the spinner area below. Emitting N prints per completed tool —
|
|
1729
|
+
the merged row, plus each indented args line, plus each result
|
|
1730
|
+
line — produces N flickers. Bundling into a single Group keeps
|
|
1731
|
+
it to one redraw.
|
|
1732
|
+
"""
|
|
1733
|
+
credentials.save(credentials.StoredCredentials(api_key="fxn_x", org="acme"))
|
|
1734
|
+
|
|
1735
|
+
from fruxon.cli import run as run_mod
|
|
1736
|
+
from fruxon.fruxon import StreamEvent
|
|
1737
|
+
|
|
1738
|
+
events = [
|
|
1739
|
+
StreamEvent(
|
|
1740
|
+
event="tool_call",
|
|
1741
|
+
data={
|
|
1742
|
+
"id": "1",
|
|
1743
|
+
"toolTrace": {"displayName": "fetch_url"},
|
|
1744
|
+
"arguments": {"url": "https://broken.example", "retries": 3},
|
|
1745
|
+
"startTime": 0,
|
|
1746
|
+
},
|
|
1747
|
+
),
|
|
1748
|
+
StreamEvent(
|
|
1749
|
+
event="tool_result",
|
|
1750
|
+
data={
|
|
1751
|
+
"id": "1",
|
|
1752
|
+
"endTime": 100,
|
|
1753
|
+
"status": "ERROR",
|
|
1754
|
+
"result": "line one\nline two\nline three",
|
|
1755
|
+
},
|
|
1756
|
+
),
|
|
1757
|
+
StreamEvent(event="done", data={"trace": {}}),
|
|
1758
|
+
]
|
|
1759
|
+
|
|
1760
|
+
class _StubClient:
|
|
1761
|
+
DEFAULT_BASE_URL = "https://api.fruxon.com"
|
|
1762
|
+
|
|
1763
|
+
def __init__(self, **kwargs):
|
|
1764
|
+
pass
|
|
1765
|
+
|
|
1766
|
+
def stream(self, agent, **kwargs):
|
|
1767
|
+
yield from events
|
|
1768
|
+
|
|
1769
|
+
# Count prints that occur between the tool_result handling's
|
|
1770
|
+
# spinner refresh and the next refresh (the "done" path's
|
|
1771
|
+
# spinner.close doesn't refresh). One print is the success
|
|
1772
|
+
# criterion; >1 would mean we're back to flickering.
|
|
1773
|
+
prints_during_result: list[object] = []
|
|
1774
|
+
in_result_window = {"on": False}
|
|
1775
|
+
|
|
1776
|
+
real_refresh = run_mod._ToolSpinnerArea.refresh
|
|
1777
|
+
|
|
1778
|
+
def fake_refresh(self, running_ids, state):
|
|
1779
|
+
# The refresh with running_ids=[] runs inside the tool_result
|
|
1780
|
+
# handler *before* the bundled print — open the window then.
|
|
1781
|
+
if not running_ids:
|
|
1782
|
+
in_result_window["on"] = True
|
|
1783
|
+
return real_refresh(self, running_ids, state)
|
|
1784
|
+
|
|
1785
|
+
real_print = run_mod.stderr.print
|
|
1786
|
+
|
|
1787
|
+
def fake_print(*args, **kwargs):
|
|
1788
|
+
if in_result_window["on"]:
|
|
1789
|
+
prints_during_result.append(args[0] if args else None)
|
|
1790
|
+
return real_print(*args, **kwargs)
|
|
1791
|
+
|
|
1792
|
+
monkeypatch.setattr(run_mod._ToolSpinnerArea, "refresh", fake_refresh)
|
|
1793
|
+
monkeypatch.setattr(run_mod.stderr, "print", fake_print)
|
|
1794
|
+
monkeypatch.setattr("fruxon.cli.run.FruxonClient", _StubClient)
|
|
1795
|
+
|
|
1796
|
+
result = runner.invoke(app, ["run", "my-agent"])
|
|
1797
|
+
assert result.exit_code == 0
|
|
1798
|
+
|
|
1799
|
+
# Exactly one print for the whole expanded failure block —
|
|
1800
|
+
# header + args (2 keys) + result (3 lines) would have been
|
|
1801
|
+
# 6 separate prints under the old code. One print here is
|
|
1802
|
+
# the bundling guarantee.
|
|
1803
|
+
assert len(prints_during_result) == 1, (
|
|
1804
|
+
f"expected one bundled print for the completion block, got {len(prints_during_result)} — flicker regression"
|
|
1805
|
+
)
|
|
1806
|
+
# And the bundled renderable carries the full payload — args
|
|
1807
|
+
# keys, the URL, and every result line.
|
|
1808
|
+
from rich.console import Console
|
|
1809
|
+
|
|
1810
|
+
buf = Console(record=True, file=open("/dev/null", "w"))
|
|
1811
|
+
buf.print(prints_during_result[0])
|
|
1812
|
+
rendered = buf.export_text()
|
|
1813
|
+
assert "fetch_url" in rendered
|
|
1814
|
+
assert "https://broken.example" in rendered
|
|
1815
|
+
assert "retries" in rendered
|
|
1816
|
+
assert "line one" in rendered
|
|
1817
|
+
assert "line three" in rendered
|
|
1818
|
+
|
|
1640
1819
|
def test_stream_renders_tool_calls_to_stderr(self, runner, monkeypatch):
|
|
1641
1820
|
credentials.save(credentials.StoredCredentials(api_key="fxn_x", org="acme"))
|
|
1642
1821
|
|
|
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
|