aru-code 0.47.0__tar.gz → 0.48.0__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.
- {aru_code-0.47.0/aru_code.egg-info → aru_code-0.48.0}/PKG-INFO +1 -1
- aru_code-0.48.0/aru/__init__.py +1 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/runner.py +8 -2
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/session.py +13 -1
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/sinks.py +5 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/streaming.py +8 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/app.py +77 -8
- aru_code-0.48.0/aru/tui/log_bridge.py +128 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/sinks.py +10 -0
- {aru_code-0.47.0 → aru_code-0.48.0/aru_code.egg-info}/PKG-INFO +1 -1
- {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/SOURCES.txt +4 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/pyproject.toml +1 -1
- aru_code-0.48.0/tests/test_session_free_cost.py +59 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_streaming_sink.py +3 -0
- aru_code-0.48.0/tests/test_tui_error_display.py +205 -0
- aru_code-0.48.0/tests/test_tui_slash_model.py +141 -0
- aru_code-0.47.0/aru/__init__.py +0 -1
- {aru_code-0.47.0 → aru_code-0.48.0}/LICENSE +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/README.md +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/agent_factory.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/catalog.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/agents/planner.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/cache_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/checkpoints.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/commands.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/completers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/config.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/context.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/display.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/events.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/format/runner.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/history_blocks.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/lsp/protocol.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/extractor.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/loader.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/memory/store.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/permissions.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugin_cache.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/custom_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/hooks.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/manager.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/plugins/tool_api.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/providers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/runtime.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/select.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tool_policy.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/_diff.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/_shared.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/apply_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/apply_patch_prompt.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/ast_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/codebase.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/delegate.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/delegate_prompt.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/file_ops.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/gitignore.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/lsp.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/mcp_client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/memory_tool.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/plan_mode.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/ranker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/registry.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/search.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/shell.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/skill.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/tasklist.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/web.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tools/worktree.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/sanitize.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/choice.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/confirm.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/search.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/screens/text_input.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/slash_bridge.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/ui.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/__init__.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/chat.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/completer.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/context_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/header.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/inline_choice.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/loaded_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/status.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/thinking.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/tui/widgets/tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru/ui.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/dependency_links.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/entry_points.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/requires.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/aru_code.egg-info/top_level.txt +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/setup.cfg +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_agents_base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_agents_md_coverage.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_apply_patch.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_async_tool_permission.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cache_patch_metrics.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cache_patch_stop_reason.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_catalog.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_chat_scrollable.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_checkpoints.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_advanced.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_base.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_completers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_new.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_run_cli.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_session.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cli_shell.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_codebase.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_confabulation_regression.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_config.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_context.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_context_pane.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_cwd_awareness.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_delegate.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_events_backward_compat.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_events_schema.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_fork_ctx_concurrency.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_format.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_gitignore.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_guardrails_scenarios.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_invoke_skill.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_invoked_skills.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_loaded_pane_path.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_lsp.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_lsp_rename.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_main.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_markdown_to_text.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_mcp_client.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_mcp_health.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_memory.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_memory_tool.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_microcompact.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_permissions.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plan_mode_refactor.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_cache.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_errors.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugin_hooks_v2.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_plugins.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_providers.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_ranker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_reasoning.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runner_interrupt.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runner_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_runtime.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_select.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_skill_disallowed_tools.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_status_breakdown.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_status_cost.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tasklist.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_thread_tool_timeout.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tool_policy.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_truncation_marker.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_app_boot.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_bindings.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_bus_flow.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_chat.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_chat_adversarial.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_completer.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_completer_dynamic.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_copy.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_input_behaviour.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_layer12_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_layer13_recovery.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_mention_expand.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_modals.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_mode_cycle.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_native_selection.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_permission_flow.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_plan_task_render.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_shell_bang.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_sidebar_toggle.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_slash_bridge.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_snapshot_smoke.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_thinking_and_boot.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_tui_widgets_visual.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_ui_adapter.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_worktree.py +0 -0
- {aru_code-0.47.0 → aru_code-0.48.0}/tests/test_worktree_session_restore.py +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.48.0"
|
|
@@ -695,8 +695,14 @@ async def run_agent_capture(agent, message: str, session=None, lightweight: bool
|
|
|
695
695
|
sink.exit()
|
|
696
696
|
except Exception:
|
|
697
697
|
pass
|
|
698
|
-
|
|
699
|
-
console
|
|
698
|
+
# Route via the sink so the message reaches the right surface:
|
|
699
|
+
# REPL → Rich console; TUI → ChatPane system message (Textual
|
|
700
|
+
# hijacks stderr/stdout, so ``console.print`` would be invisible).
|
|
701
|
+
try:
|
|
702
|
+
sink.on_error(str(e))
|
|
703
|
+
except Exception:
|
|
704
|
+
from rich.markup import escape
|
|
705
|
+
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
700
706
|
|
|
701
707
|
# Final guard: if a plan is active and the agent ended its turn with
|
|
702
708
|
# pending steps (without stalling), mark the session so the next turn's
|
|
@@ -565,7 +565,19 @@ class Session:
|
|
|
565
565
|
self._live_cache_write_added = 0
|
|
566
566
|
|
|
567
567
|
def _get_pricing(self) -> tuple[float, float, float, float]:
|
|
568
|
-
"""Get per-million-token pricing for the current model.
|
|
568
|
+
"""Get per-million-token pricing for the current model.
|
|
569
|
+
|
|
570
|
+
Free models (e.g. OpenRouter `:free` variants like
|
|
571
|
+
`openrouter/minimax/minimax-m2.5:free`) report no cost. Detected by
|
|
572
|
+
the literal token "free" in the model ref or id — covers the `:free`
|
|
573
|
+
suffix convention plus any future provider that adopts the same
|
|
574
|
+
naming. None of the major paid models contain "free" in their id,
|
|
575
|
+
so false positives are negligible.
|
|
576
|
+
"""
|
|
577
|
+
ref = (self.model_ref or "").lower()
|
|
578
|
+
mid = (self.model_id or "").lower()
|
|
579
|
+
if "free" in ref or "free" in mid:
|
|
580
|
+
return (0.0, 0.0, 0.0, 0.0)
|
|
569
581
|
model_id = self.model_id
|
|
570
582
|
# Try exact match, then prefix match, then fallback
|
|
571
583
|
for prefix, pricing in MODEL_PRICING.items():
|
|
@@ -238,6 +238,11 @@ class RichLiveSink:
|
|
|
238
238
|
else:
|
|
239
239
|
target.print(message)
|
|
240
240
|
|
|
241
|
+
def on_error(self, message: str) -> None:
|
|
242
|
+
from rich.markup import escape
|
|
243
|
+
target = self._live.console if self._live is not None else self.console
|
|
244
|
+
target.print(f"[red]Error: {escape(message)}[/red]")
|
|
245
|
+
|
|
241
246
|
def on_stream_finished(self, *, final_content: str) -> None:
|
|
242
247
|
# Nothing to do here — runner flushes trailing markdown after exit()
|
|
243
248
|
# using the accumulated content + display._flushed_len.
|
|
@@ -121,6 +121,14 @@ class StreamSink(Protocol):
|
|
|
121
121
|
"""Best-effort sideband user message (warnings etc.)."""
|
|
122
122
|
...
|
|
123
123
|
|
|
124
|
+
def on_error(self, message: str) -> None:
|
|
125
|
+
"""Terminal error — runner caught an exception from the agent run.
|
|
126
|
+
|
|
127
|
+
REPL renders via Rich console; TUI must route to the ChatPane so
|
|
128
|
+
the user actually sees it (Textual hijacks stderr/stdout).
|
|
129
|
+
"""
|
|
130
|
+
...
|
|
131
|
+
|
|
124
132
|
def on_stream_finished(self, *, final_content: str) -> None:
|
|
125
133
|
"""Run finished — sink may render any trailing markdown."""
|
|
126
134
|
...
|
|
@@ -877,22 +877,76 @@ class AruApp(App):
|
|
|
877
877
|
if session is None:
|
|
878
878
|
self._push_chat("No session.", "model")
|
|
879
879
|
return
|
|
880
|
+
from aru.providers import (
|
|
881
|
+
MODEL_ALIASES,
|
|
882
|
+
get_provider,
|
|
883
|
+
list_providers,
|
|
884
|
+
resolve_model_ref,
|
|
885
|
+
)
|
|
886
|
+
|
|
887
|
+
config = self.config
|
|
888
|
+
config_aliases = (getattr(config, "model_aliases", None) or {}) if config else {}
|
|
889
|
+
|
|
880
890
|
body = body.strip()
|
|
881
891
|
if not body:
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
892
|
+
lines = [
|
|
893
|
+
f"Current model: {session.model_display} ({session.model_id})",
|
|
894
|
+
"",
|
|
895
|
+
]
|
|
896
|
+
if config_aliases:
|
|
897
|
+
lines.append("Model aliases (aru.json):")
|
|
898
|
+
for alias, ref in config_aliases.items():
|
|
899
|
+
lines.append(f" {alias} → {ref}")
|
|
900
|
+
lines.append("")
|
|
901
|
+
lines.append("Built-in aliases:")
|
|
902
|
+
for alias, ref in MODEL_ALIASES.items():
|
|
903
|
+
lines.append(f" {alias} → {ref}")
|
|
904
|
+
lines.append("")
|
|
905
|
+
lines.append("Providers:")
|
|
906
|
+
for pkey, pconfig in list_providers().items():
|
|
907
|
+
dflt = pconfig.default_model or "—"
|
|
908
|
+
lines.append(f" {pkey} ({pconfig.name}) — default: {dflt}")
|
|
909
|
+
lines.append("")
|
|
910
|
+
lines.append("Usage: /model <provider/name> (e.g. /model anthropic/claude-sonnet-4-5, /model minimax)")
|
|
911
|
+
self._push_chat("\n".join(lines), "model")
|
|
888
912
|
return
|
|
913
|
+
|
|
889
914
|
try:
|
|
890
|
-
|
|
915
|
+
arg_lower = body.lower()
|
|
916
|
+
resolved_ref = config_aliases.get(arg_lower, arg_lower) if config_aliases else arg_lower
|
|
917
|
+
provider_key, _ = resolve_model_ref(resolved_ref)
|
|
918
|
+
if get_provider(provider_key) is None:
|
|
919
|
+
available = ", ".join(sorted(list_providers().keys()))
|
|
920
|
+
self._push_chat(
|
|
921
|
+
f"Unknown provider '{provider_key}'. Available: {available}",
|
|
922
|
+
"model",
|
|
923
|
+
)
|
|
924
|
+
return
|
|
925
|
+
# Normalize to the fully-qualified ref so model_display + create_model
|
|
926
|
+
# see the right provider/model pair.
|
|
927
|
+
session.model_ref = resolved_ref if "/" in resolved_ref else (
|
|
928
|
+
MODEL_ALIASES.get(resolved_ref, resolved_ref)
|
|
929
|
+
)
|
|
891
930
|
if self.ctx is not None:
|
|
892
931
|
self.ctx.model_id = session.model_id
|
|
932
|
+
small_ref = config_aliases.get("small")
|
|
933
|
+
if not small_ref:
|
|
934
|
+
sp_key, _ = resolve_model_ref(session.model_ref)
|
|
935
|
+
_small_defaults = {
|
|
936
|
+
"anthropic": "anthropic/claude-haiku-4-5",
|
|
937
|
+
"openai": "openai/gpt-4o-mini",
|
|
938
|
+
"groq": "groq/llama-3.1-8b-instant",
|
|
939
|
+
"deepseek": "deepseek/deepseek-chat",
|
|
940
|
+
"ollama": "ollama/llama3.1",
|
|
941
|
+
}
|
|
942
|
+
small_ref = _small_defaults.get(sp_key, session.model_ref)
|
|
943
|
+
self.ctx.small_model_ref = small_ref
|
|
893
944
|
status = self.query_one(StatusPane)
|
|
894
945
|
status._refresh_from_session()
|
|
895
|
-
self._push_chat(
|
|
946
|
+
self._push_chat(
|
|
947
|
+
f"Switched to {session.model_display} ({session.model_id})",
|
|
948
|
+
"model",
|
|
949
|
+
)
|
|
896
950
|
except Exception as exc:
|
|
897
951
|
self._push_chat(f"model switch failed: {exc}", "model")
|
|
898
952
|
|
|
@@ -2051,9 +2105,24 @@ async def run_tui(
|
|
|
2051
2105
|
ctx.tui_app = app
|
|
2052
2106
|
ctx.ui = TuiUI(app)
|
|
2053
2107
|
|
|
2108
|
+
# Bridge logging → ChatPane so Agno's ERROR records (rate limits,
|
|
2109
|
+
# provider errors, etc.) are visible to the user instead of vanishing
|
|
2110
|
+
# into Textual's captured stderr. Detached in the finally block.
|
|
2111
|
+
log_bridge_handlers: list = []
|
|
2112
|
+
try:
|
|
2113
|
+
from aru.tui.log_bridge import install_chat_log_bridge
|
|
2114
|
+
log_bridge_handlers = install_chat_log_bridge(app)
|
|
2115
|
+
except Exception:
|
|
2116
|
+
pass
|
|
2117
|
+
|
|
2054
2118
|
try:
|
|
2055
2119
|
await app.run_async()
|
|
2056
2120
|
finally:
|
|
2121
|
+
try:
|
|
2122
|
+
from aru.tui.log_bridge import uninstall_chat_log_bridge
|
|
2123
|
+
uninstall_chat_log_bridge(log_bridge_handlers)
|
|
2124
|
+
except Exception:
|
|
2125
|
+
pass
|
|
2057
2126
|
ctx.tui_app = None
|
|
2058
2127
|
ctx.ui = None
|
|
2059
2128
|
try:
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
"""Logging → ChatPane bridge for TUI mode.
|
|
2
|
+
|
|
3
|
+
Background
|
|
4
|
+
----------
|
|
5
|
+
Agno (and other libraries) report API errors via Python's ``logging``
|
|
6
|
+
module — for example, an OpenRouter rate-limit looks like::
|
|
7
|
+
|
|
8
|
+
ERROR Rate limit error from OpenAI API: Error code: 429 ...
|
|
9
|
+
ERROR Error in Agent run: Provider returned error
|
|
10
|
+
|
|
11
|
+
In REPL mode these records reach the user because Python's default log
|
|
12
|
+
handler writes to ``sys.stderr`` and the terminal renders it directly.
|
|
13
|
+
In TUI mode Textual takes over the alternate screen and captures stdout
|
|
14
|
+
/ stderr — those ERROR lines vanish, leaving the user staring at a
|
|
15
|
+
spinner that eventually stops without any message.
|
|
16
|
+
|
|
17
|
+
This module installs a ``logging.Handler`` that forwards qualifying
|
|
18
|
+
records into the running ``AruApp``'s ``ChatPane`` as system messages
|
|
19
|
+
via ``app.call_from_thread``. The handler is idempotent (a marker
|
|
20
|
+
attribute on each target logger prevents double-attachment when
|
|
21
|
+
``run_tui`` is invoked twice in the same process, e.g. tests).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import logging
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
# Loggers we forward to chat. Keep this tight — capturing the root logger
|
|
30
|
+
# would also pick up debug noise from libraries that log at WARNING for
|
|
31
|
+
# routine state. We only want clearly-actionable messages.
|
|
32
|
+
_BRIDGE_LOGGERS: tuple[str, ...] = ("agno", "aru")
|
|
33
|
+
|
|
34
|
+
# Records below this level are dropped. ERROR is the right floor:
|
|
35
|
+
# WARNING from Agno is often non-actionable (e.g. "tool call schema
|
|
36
|
+
# coerced"). The user explicitly asked for transparency about *errors*.
|
|
37
|
+
_BRIDGE_LEVEL = logging.ERROR
|
|
38
|
+
|
|
39
|
+
# Sentinel attribute name set on a logger after we've attached our
|
|
40
|
+
# handler, so re-running the install is a no-op.
|
|
41
|
+
_INSTALLED_FLAG = "_aru_chat_bridge_installed"
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class _ChatPaneLogHandler(logging.Handler):
|
|
45
|
+
"""Forward logging records into the TUI ChatPane.
|
|
46
|
+
|
|
47
|
+
Holds a weak reference semantically (we let the App outlive the
|
|
48
|
+
handler — the handler is detached in ``uninstall_chat_log_bridge``).
|
|
49
|
+
Failures inside ``emit`` are swallowed: a logging handler must never
|
|
50
|
+
raise, and the user can still see the error via Textual's own dev
|
|
51
|
+
log if they really need it.
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
def __init__(self, app: Any) -> None:
|
|
55
|
+
super().__init__(level=_BRIDGE_LEVEL)
|
|
56
|
+
self._app = app
|
|
57
|
+
self.setFormatter(logging.Formatter("%(name)s: %(message)s"))
|
|
58
|
+
|
|
59
|
+
def emit(self, record: logging.LogRecord) -> None:
|
|
60
|
+
try:
|
|
61
|
+
text = self.format(record)
|
|
62
|
+
except Exception:
|
|
63
|
+
try:
|
|
64
|
+
text = record.getMessage()
|
|
65
|
+
except Exception:
|
|
66
|
+
return
|
|
67
|
+
try:
|
|
68
|
+
from aru.tui.widgets.chat import ChatPane
|
|
69
|
+
chat = self._app.query_one(ChatPane)
|
|
70
|
+
except Exception:
|
|
71
|
+
return
|
|
72
|
+
try:
|
|
73
|
+
self._app.call_from_thread(
|
|
74
|
+
chat.add_system_message, f"Error: {text}"
|
|
75
|
+
)
|
|
76
|
+
except Exception:
|
|
77
|
+
# Last-resort direct call — safe when already on the loop
|
|
78
|
+
# and the App is still running. If even this raises, drop
|
|
79
|
+
# the record silently rather than crashing the producer.
|
|
80
|
+
try:
|
|
81
|
+
chat.add_system_message(f"Error: {text}")
|
|
82
|
+
except Exception:
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def install_chat_log_bridge(app: Any) -> list[logging.Handler]:
|
|
87
|
+
"""Attach a ChatPane bridge to each target logger.
|
|
88
|
+
|
|
89
|
+
Returns the list of installed handlers so ``uninstall_chat_log_bridge``
|
|
90
|
+
can remove them on teardown. Idempotent per logger — if a previous
|
|
91
|
+
bridge from the same process is still attached, that logger is
|
|
92
|
+
skipped and only loggers without a bridge get a new handler.
|
|
93
|
+
"""
|
|
94
|
+
installed: list[logging.Handler] = []
|
|
95
|
+
for name in _BRIDGE_LOGGERS:
|
|
96
|
+
logger = logging.getLogger(name)
|
|
97
|
+
if getattr(logger, _INSTALLED_FLAG, False):
|
|
98
|
+
continue
|
|
99
|
+
handler = _ChatPaneLogHandler(app)
|
|
100
|
+
logger.addHandler(handler)
|
|
101
|
+
# Make sure ERROR records actually fire — Agno ships at WARNING
|
|
102
|
+
# by default in cli.py, but a downstream user could lower it.
|
|
103
|
+
if logger.level == logging.NOTSET or logger.level > _BRIDGE_LEVEL:
|
|
104
|
+
logger.setLevel(_BRIDGE_LEVEL)
|
|
105
|
+
setattr(logger, _INSTALLED_FLAG, True)
|
|
106
|
+
installed.append(handler)
|
|
107
|
+
return installed
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def uninstall_chat_log_bridge(handlers: list[logging.Handler]) -> None:
|
|
111
|
+
"""Detach the bridge handlers and clear the per-logger marker."""
|
|
112
|
+
for name in _BRIDGE_LOGGERS:
|
|
113
|
+
logger = logging.getLogger(name)
|
|
114
|
+
for h in list(logger.handlers):
|
|
115
|
+
if isinstance(h, _ChatPaneLogHandler):
|
|
116
|
+
logger.removeHandler(h)
|
|
117
|
+
if getattr(logger, _INSTALLED_FLAG, False):
|
|
118
|
+
try:
|
|
119
|
+
delattr(logger, _INSTALLED_FLAG)
|
|
120
|
+
except AttributeError:
|
|
121
|
+
pass
|
|
122
|
+
# Best-effort close so file descriptors etc. don't leak (we don't
|
|
123
|
+
# use any, but a custom subclass might).
|
|
124
|
+
for h in handlers:
|
|
125
|
+
try:
|
|
126
|
+
h.close()
|
|
127
|
+
except Exception:
|
|
128
|
+
pass
|
|
@@ -130,6 +130,16 @@ class TextualBusSink:
|
|
|
130
130
|
except Exception:
|
|
131
131
|
pass
|
|
132
132
|
|
|
133
|
+
def on_error(self, message: str) -> None:
|
|
134
|
+
# Errors are routed to the ChatPane (persistent) instead of a
|
|
135
|
+
# Textual toast — under Textual, Agno's ERROR log lines never
|
|
136
|
+
# reach the terminal, so this is the user's only visible signal
|
|
137
|
+
# that something went wrong. Closing the active assistant bubble
|
|
138
|
+
# first prevents the streamed-but-empty assistant message from
|
|
139
|
+
# swallowing the error widget.
|
|
140
|
+
self._call(self.chat.finalize_assistant_message)
|
|
141
|
+
self._call(self.chat.add_system_message, f"Error: {message}")
|
|
142
|
+
|
|
133
143
|
def on_stream_finished(self, *, final_content: str) -> None:
|
|
134
144
|
self._call(self.chat.finalize_assistant_message, final_content or None)
|
|
135
145
|
|
|
@@ -69,6 +69,7 @@ aru/tools/web.py
|
|
|
69
69
|
aru/tools/worktree.py
|
|
70
70
|
aru/tui/__init__.py
|
|
71
71
|
aru/tui/app.py
|
|
72
|
+
aru/tui/log_bridge.py
|
|
72
73
|
aru/tui/sanitize.py
|
|
73
74
|
aru/tui/sinks.py
|
|
74
75
|
aru/tui/slash_bridge.py
|
|
@@ -149,6 +150,7 @@ tests/test_runner_interrupt.py
|
|
|
149
150
|
tests/test_runner_recovery.py
|
|
150
151
|
tests/test_runtime.py
|
|
151
152
|
tests/test_select.py
|
|
153
|
+
tests/test_session_free_cost.py
|
|
152
154
|
tests/test_skill_disallowed_tools.py
|
|
153
155
|
tests/test_status_breakdown.py
|
|
154
156
|
tests/test_status_cost.py
|
|
@@ -165,6 +167,7 @@ tests/test_tui_chat_adversarial.py
|
|
|
165
167
|
tests/test_tui_completer.py
|
|
166
168
|
tests/test_tui_completer_dynamic.py
|
|
167
169
|
tests/test_tui_copy.py
|
|
170
|
+
tests/test_tui_error_display.py
|
|
168
171
|
tests/test_tui_input_behaviour.py
|
|
169
172
|
tests/test_tui_layer12_recovery.py
|
|
170
173
|
tests/test_tui_layer13_recovery.py
|
|
@@ -177,6 +180,7 @@ tests/test_tui_plan_task_render.py
|
|
|
177
180
|
tests/test_tui_shell_bang.py
|
|
178
181
|
tests/test_tui_sidebar_toggle.py
|
|
179
182
|
tests/test_tui_slash_bridge.py
|
|
183
|
+
tests/test_tui_slash_model.py
|
|
180
184
|
tests/test_tui_snapshot_smoke.py
|
|
181
185
|
tests/test_tui_thinking_and_boot.py
|
|
182
186
|
tests/test_tui_widgets_visual.py
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Free models must not accumulate dollar cost.
|
|
2
|
+
|
|
3
|
+
Regression: ``Session._get_pricing`` previously fell through to
|
|
4
|
+
``MODEL_PRICING["default"]`` (Sonnet-4.5 pricing) for any model id not
|
|
5
|
+
present in the table. OpenRouter's `:free` variants like
|
|
6
|
+
``openrouter/minimax/minimax-m2.5:free`` were therefore reported as
|
|
7
|
+
quite expensive in the status bar and `/cost` panel even though the
|
|
8
|
+
provider charges nothing.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import pytest
|
|
14
|
+
|
|
15
|
+
from aru.session import Session
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _consume(session: Session, *, inp: int, out: int) -> None:
|
|
19
|
+
session.total_input_tokens = inp
|
|
20
|
+
session.total_output_tokens = out
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_openrouter_free_variant_costs_zero():
|
|
24
|
+
session = Session()
|
|
25
|
+
session.model_ref = "openrouter/minimax/minimax-m2.5:free"
|
|
26
|
+
_consume(session, inp=100_000, out=50_000)
|
|
27
|
+
assert session.estimated_cost == 0.0
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_openrouter_free_nemotron_costs_zero():
|
|
31
|
+
session = Session()
|
|
32
|
+
session.model_ref = "openrouter/nvidia/nemotron-3-super-120b-a12b:free"
|
|
33
|
+
_consume(session, inp=200_000, out=10_000)
|
|
34
|
+
assert session.estimated_cost == 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_paid_anthropic_still_costs_real_money():
|
|
38
|
+
session = Session()
|
|
39
|
+
session.model_ref = "anthropic/claude-sonnet-4-5"
|
|
40
|
+
_consume(session, inp=100_000, out=50_000)
|
|
41
|
+
assert session.estimated_cost > 0.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_free_match_is_case_insensitive():
|
|
45
|
+
session = Session()
|
|
46
|
+
session.model_ref = "openrouter/minimax/minimax-m2.5:FREE"
|
|
47
|
+
_consume(session, inp=10_000, out=5_000)
|
|
48
|
+
assert session.estimated_cost == 0.0
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_free_with_cache_tokens_still_zero():
|
|
52
|
+
"""All four price components must be zeroed, not just input/output."""
|
|
53
|
+
session = Session()
|
|
54
|
+
session.model_ref = "openrouter/minimax/minimax-m2.5:free"
|
|
55
|
+
session.total_input_tokens = 100_000
|
|
56
|
+
session.total_output_tokens = 50_000
|
|
57
|
+
session.total_cache_read_tokens = 30_000
|
|
58
|
+
session.total_cache_write_tokens = 20_000
|
|
59
|
+
assert session.estimated_cost == 0.0
|
|
@@ -46,6 +46,9 @@ class RecordingSink:
|
|
|
46
46
|
def notify(self, message: str, style: str = "") -> None:
|
|
47
47
|
self.events.append(("notify", {"msg": message}))
|
|
48
48
|
|
|
49
|
+
def on_error(self, message: str) -> None:
|
|
50
|
+
self.events.append(("error", {"msg": message}))
|
|
51
|
+
|
|
49
52
|
def on_stream_finished(self, *, final_content: str) -> None:
|
|
50
53
|
self.events.append(("stream_finished", {"final": final_content}))
|
|
51
54
|
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""TUI must surface LLM/runner errors instead of silencing them.
|
|
2
|
+
|
|
3
|
+
Two layers of coverage:
|
|
4
|
+
|
|
5
|
+
1. **Sink-routed errors.** When the runner's catch-all (``runner.py:693``)
|
|
6
|
+
fires, it now calls ``sink.on_error(...)``. The TUI sink must mount
|
|
7
|
+
a system message in the ChatPane so the user sees "Error: ...".
|
|
8
|
+
|
|
9
|
+
2. **Logging bridge.** Agno frequently catches API errors (e.g. OpenAI
|
|
10
|
+
429), logs them at ERROR level, and continues without re-raising.
|
|
11
|
+
In REPL the records reach the terminal via stderr; in TUI Textual
|
|
12
|
+
captures stderr and the user sees nothing. The bridge installed by
|
|
13
|
+
``install_chat_log_bridge`` must convert those records into ChatPane
|
|
14
|
+
system messages.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
|
|
21
|
+
import pytest
|
|
22
|
+
|
|
23
|
+
pytest.importorskip("textual")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
# ── Sink-routed errors ───────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@pytest.mark.asyncio
|
|
30
|
+
async def test_textual_bus_sink_on_error_lands_in_chat_pane():
|
|
31
|
+
"""``TextualBusSink.on_error`` must mount a system message."""
|
|
32
|
+
from aru.tui.app import AruApp
|
|
33
|
+
from aru.tui.sinks import TextualBusSink
|
|
34
|
+
from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
|
|
35
|
+
|
|
36
|
+
app = AruApp()
|
|
37
|
+
async with app.run_test() as pilot:
|
|
38
|
+
await pilot.pause()
|
|
39
|
+
chat = app.query_one(ChatPane)
|
|
40
|
+
sink = TextualBusSink(app=app, chat_pane=chat)
|
|
41
|
+
sink.on_error("Provider returned error")
|
|
42
|
+
await pilot.pause()
|
|
43
|
+
msgs = list(chat.query(ChatMessageWidget))
|
|
44
|
+
joined = " ".join(m.buffer for m in msgs)
|
|
45
|
+
|
|
46
|
+
assert "Provider returned error" in joined
|
|
47
|
+
assert "Error" in joined
|
|
48
|
+
# Must be a system-role message, not silently dropped.
|
|
49
|
+
assert any(m.role == "system" for m in msgs)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── Logging bridge ───────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@pytest.mark.asyncio
|
|
56
|
+
async def test_agno_error_log_reaches_chat_via_bridge():
|
|
57
|
+
"""An Agno ``logger.error(...)`` call must appear in the chat."""
|
|
58
|
+
from aru.tui.app import AruApp
|
|
59
|
+
from aru.tui.log_bridge import (
|
|
60
|
+
install_chat_log_bridge,
|
|
61
|
+
uninstall_chat_log_bridge,
|
|
62
|
+
)
|
|
63
|
+
from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
|
|
64
|
+
|
|
65
|
+
app = AruApp()
|
|
66
|
+
handlers: list = []
|
|
67
|
+
async with app.run_test() as pilot:
|
|
68
|
+
await pilot.pause()
|
|
69
|
+
handlers = install_chat_log_bridge(app)
|
|
70
|
+
try:
|
|
71
|
+
logging.getLogger("agno").error(
|
|
72
|
+
"Rate limit error from OpenAI API: Error code: 429"
|
|
73
|
+
)
|
|
74
|
+
await pilot.pause()
|
|
75
|
+
chat = app.query_one(ChatPane)
|
|
76
|
+
msgs = list(chat.query(ChatMessageWidget))
|
|
77
|
+
joined = " ".join(m.buffer for m in msgs)
|
|
78
|
+
finally:
|
|
79
|
+
uninstall_chat_log_bridge(handlers)
|
|
80
|
+
|
|
81
|
+
assert "429" in joined
|
|
82
|
+
assert "Rate limit error" in joined
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.mark.asyncio
|
|
86
|
+
async def test_aru_logger_error_also_bridged():
|
|
87
|
+
"""Aru's own loggers should also surface errors in the chat."""
|
|
88
|
+
from aru.tui.app import AruApp
|
|
89
|
+
from aru.tui.log_bridge import (
|
|
90
|
+
install_chat_log_bridge,
|
|
91
|
+
uninstall_chat_log_bridge,
|
|
92
|
+
)
|
|
93
|
+
from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
|
|
94
|
+
|
|
95
|
+
app = AruApp()
|
|
96
|
+
async with app.run_test() as pilot:
|
|
97
|
+
await pilot.pause()
|
|
98
|
+
handlers = install_chat_log_bridge(app)
|
|
99
|
+
try:
|
|
100
|
+
logging.getLogger("aru.runner").error("runner blew up: %s", "boom")
|
|
101
|
+
await pilot.pause()
|
|
102
|
+
chat = app.query_one(ChatPane)
|
|
103
|
+
joined = " ".join(
|
|
104
|
+
m.buffer for m in chat.query(ChatMessageWidget)
|
|
105
|
+
)
|
|
106
|
+
finally:
|
|
107
|
+
uninstall_chat_log_bridge(handlers)
|
|
108
|
+
|
|
109
|
+
assert "boom" in joined
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@pytest.mark.asyncio
|
|
113
|
+
async def test_warning_level_records_are_filtered_out():
|
|
114
|
+
"""Bridge floor is ERROR; routine WARNINGs should not pollute chat."""
|
|
115
|
+
from aru.tui.app import AruApp
|
|
116
|
+
from aru.tui.log_bridge import (
|
|
117
|
+
install_chat_log_bridge,
|
|
118
|
+
uninstall_chat_log_bridge,
|
|
119
|
+
)
|
|
120
|
+
from aru.tui.widgets.chat import ChatMessageWidget, ChatPane
|
|
121
|
+
|
|
122
|
+
app = AruApp()
|
|
123
|
+
async with app.run_test() as pilot:
|
|
124
|
+
await pilot.pause()
|
|
125
|
+
handlers = install_chat_log_bridge(app)
|
|
126
|
+
try:
|
|
127
|
+
logging.getLogger("agno").warning("schema coerced to dict")
|
|
128
|
+
await pilot.pause()
|
|
129
|
+
chat = app.query_one(ChatPane)
|
|
130
|
+
joined = " ".join(
|
|
131
|
+
m.buffer for m in chat.query(ChatMessageWidget)
|
|
132
|
+
)
|
|
133
|
+
finally:
|
|
134
|
+
uninstall_chat_log_bridge(handlers)
|
|
135
|
+
|
|
136
|
+
assert "schema coerced" not in joined
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.mark.asyncio
|
|
140
|
+
async def test_install_is_idempotent():
|
|
141
|
+
"""Re-installing the bridge must not double-attach handlers."""
|
|
142
|
+
from aru.tui.app import AruApp
|
|
143
|
+
from aru.tui.log_bridge import (
|
|
144
|
+
_ChatPaneLogHandler,
|
|
145
|
+
install_chat_log_bridge,
|
|
146
|
+
uninstall_chat_log_bridge,
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
app = AruApp()
|
|
150
|
+
async with app.run_test() as pilot:
|
|
151
|
+
await pilot.pause()
|
|
152
|
+
first = install_chat_log_bridge(app)
|
|
153
|
+
second = install_chat_log_bridge(app)
|
|
154
|
+
try:
|
|
155
|
+
agno_handlers = [
|
|
156
|
+
h for h in logging.getLogger("agno").handlers
|
|
157
|
+
if isinstance(h, _ChatPaneLogHandler)
|
|
158
|
+
]
|
|
159
|
+
# Exactly one bridge handler attached, regardless of install count.
|
|
160
|
+
assert len(agno_handlers) == 1
|
|
161
|
+
assert second == [] # second call returned no new handlers
|
|
162
|
+
finally:
|
|
163
|
+
uninstall_chat_log_bridge(first)
|
|
164
|
+
uninstall_chat_log_bridge(second)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@pytest.mark.asyncio
|
|
168
|
+
async def test_uninstall_removes_handlers():
|
|
169
|
+
"""After uninstall, ERROR records must not leak into chat anymore."""
|
|
170
|
+
from aru.tui.app import AruApp
|
|
171
|
+
from aru.tui.log_bridge import (
|
|
172
|
+
_ChatPaneLogHandler,
|
|
173
|
+
install_chat_log_bridge,
|
|
174
|
+
uninstall_chat_log_bridge,
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
app = AruApp()
|
|
178
|
+
async with app.run_test() as pilot:
|
|
179
|
+
await pilot.pause()
|
|
180
|
+
handlers = install_chat_log_bridge(app)
|
|
181
|
+
uninstall_chat_log_bridge(handlers)
|
|
182
|
+
# No bridge handler should remain.
|
|
183
|
+
for name in ("agno", "aru"):
|
|
184
|
+
for h in logging.getLogger(name).handlers:
|
|
185
|
+
assert not isinstance(h, _ChatPaneLogHandler)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
# ── Rich sink continues to work for REPL ─────────────────────────────
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_rich_live_sink_on_error_prints_via_console():
|
|
192
|
+
"""REPL path: the existing console.print behavior is preserved."""
|
|
193
|
+
from io import StringIO
|
|
194
|
+
|
|
195
|
+
from rich.console import Console
|
|
196
|
+
|
|
197
|
+
from aru.sinks import RichLiveSink
|
|
198
|
+
|
|
199
|
+
buf = StringIO()
|
|
200
|
+
console = Console(file=buf, force_terminal=False, color_system=None, width=120)
|
|
201
|
+
sink = RichLiveSink(console=console)
|
|
202
|
+
sink.on_error("Provider returned error")
|
|
203
|
+
out = buf.getvalue()
|
|
204
|
+
assert "Provider returned error" in out
|
|
205
|
+
assert "Error" in out
|