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.
Files changed (39) hide show
  1. {fruxon-0.5.2 → fruxon-0.5.3}/PKG-INFO +1 -1
  2. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/_version.py +2 -2
  3. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/run.py +123 -61
  4. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_cli.py +179 -0
  5. {fruxon-0.5.2 → fruxon-0.5.3}/.gitignore +0 -0
  6. {fruxon-0.5.2 → fruxon-0.5.3}/HISTORY.md +0 -0
  7. {fruxon-0.5.2 → fruxon-0.5.3}/LICENSE +0 -0
  8. {fruxon-0.5.2 → fruxon-0.5.3}/README.md +0 -0
  9. {fruxon-0.5.2 → fruxon-0.5.3}/pyproject.toml +0 -0
  10. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/__init__.py +0 -0
  11. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/__main__.py +0 -0
  12. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/__init__.py +0 -0
  13. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/agents.py +0 -0
  14. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/auth.py +0 -0
  15. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/chat.py +0 -0
  16. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/config.py +0 -0
  17. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/doctor.py +0 -0
  18. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/export.py +0 -0
  19. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/cli/trace.py +0 -0
  20. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/credentials.py +0 -0
  21. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/doctor.py +0 -0
  22. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/exceptions.py +0 -0
  23. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/export.py +0 -0
  24. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/fruxon.py +0 -0
  25. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/models.py +0 -0
  26. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/output.py +0 -0
  27. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/params.py +0 -0
  28. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/ui.py +0 -0
  29. {fruxon-0.5.2 → fruxon-0.5.3}/src/fruxon/update_check.py +0 -0
  30. {fruxon-0.5.2 → fruxon-0.5.3}/tests/__init__.py +0 -0
  31. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_client.py +0 -0
  32. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_credentials.py +0 -0
  33. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_doctor.py +0 -0
  34. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_export.py +0 -0
  35. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_fruxon.py +0 -0
  36. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_output.py +0 -0
  37. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_params.py +0 -0
  38. {fruxon-0.5.2 → fruxon-0.5.3}/tests/test_ui.py +0 -0
  39. {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.2
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.2'
22
- __version_tuple__ = version_tuple = (0, 5, 2)
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
- spinner_area = _ToolSpinnerArea(enabled=stderr.is_terminal)
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
- # 1. Drop this id from the running set *first* so the next
368
- # Live refresh-tick redraws without its spinner row.
369
- # 2. ``_render_tool_result`` prints the permanent merged row
370
- # via the same stderr console Live is attached to —
371
- # Rich automatically routes that print *above* the live
372
- # area instead of stopping/restarting it. No flicker.
373
- # 3. Refresh updates the Live's renderable to the reduced
374
- # set; if empty, the renderable becomes a zero-line
375
- # placeholder (still no stop, no flicker).
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
- # Failure auto-expand: show the full args + result body. Same rationale
545
- # as before a failed tool with only a one-liner is unactionable.
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
- _render_args_detail(arguments)
554
- _render_result_detail(data.get("result"), is_failure=is_failure)
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
- label = f"{tag}[bold]{name}[/bold]"
645
- args_preview = _format_args(entry.get("arguments"))
646
- if args_preview:
647
- label += f" [dim]{args_preview}[/dim]"
648
- # ``dots`` matches the spinner used by ``rich.console.status``
649
- # the same visual language as the spinners in
650
- # ``fruxon agents list`` and ``fruxon trace`` fetches.
651
- rows.append(Spinner("dots", text=label, style="cyan"))
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 _render_args_detail(arguments: object) -> None:
665
- """Pretty-print tool-call arguments as an indented detail block.
708
+ def _args_detail_lines(arguments: object) -> list[str]:
709
+ """Produce the formatted line strings for an args detail block.
666
710
 
667
- Three shapes the backend emits in practice:
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
- * ``dict`` render as ``key value`` rows with the key column padded
670
- so values line up vertically. Most tool calls are dicts.
671
- * ``str`` — render as a quoted line. Common for tools that take a
672
- single freeform string (search queries, prompts).
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
- Long individual values are truncated to ``_DETAIL_VALUE_MAX`` chars.
676
- Anyone needing the full payload can pull the run via ``fruxon trace``.
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
- for key, value in arguments.items():
687
- rendered_value = _render_detail_value(value)
688
- stderr.print(f"[dim]{_DETAIL_INDENT}{str(key):<{key_width}}[/dim] {rendered_value}")
689
- return
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
- stderr.print(f"[dim]{_DETAIL_INDENT}{_truncate(arguments, _DETAIL_VALUE_MAX)}[/dim]")
692
- return
693
- stderr.print(f"[dim]{_DETAIL_INDENT}{_render_detail_value(arguments)}[/dim]")
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
- Strings render as-is (most tool results are unstructured text);
700
- structured payloads are JSON-dumped one item per line. Failures get
701
- a red tint so they're scannable in a wall of green checks.
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]: # cap at 20 lines; full payload via `trace`
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