"""Tests for dashboard tool confirmation flow using native Agno RunOutput.""" from unittest.mock import AsyncMock, MagicMock, patch def _mock_completed_run(content="Just a reply."): """Create a mock RunOutput for a completed (no tool) run.""" run = MagicMock() run.content = content run.status = "COMPLETED" run.active_requirements = [] return run def _mock_paused_run(tool_name="shell", tool_args=None, content="Sure, I can do that."): """Create a mock RunOutput for a paused run needing tool confirmation.""" tool_args = tool_args or {"command": "echo hello"} te = MagicMock() te.tool_name = tool_name te.tool_args = tool_args req = MagicMock() req.needs_confirmation = True req.tool_execution = te run = MagicMock() run.content = content run.status = "PAUSED" run.active_requirements = [req] return run, req def _mock_approval_item(item_id="test-approval-123"): """Create a mock ApprovalItem.""" item = MagicMock() item.id = item_id return item # ── Chat returns tool actions ──────────────────────────────────────────────── def test_chat_with_tool_call_shows_approval_card(client): """When Agno pauses for tool confirmation, the response includes an approval card.""" run, _req = _mock_paused_run() item = _mock_approval_item() with ( patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run), patch("timmy.approvals.create_item", return_value=item), ): response = client.post("/agents/default/chat", data={"message": "run echo hello"}) assert response.status_code == 200 assert "APPROVE" in response.text assert "REJECT" in response.text assert "shell" in response.text def test_chat_without_tool_call_has_no_approval_card(client): """Normal responses without tool calls should not show approval buttons.""" run = _mock_completed_run() with patch("dashboard.routes.agents.chat_with_tools", return_value=run): response = client.post("/agents/default/chat", data={"message": "hello"}) assert response.status_code == 200 assert "APPROVE" not in response.text assert "REJECT" not in response.text def test_chat_tool_card_contains_impact_badge(client): """Tool approval cards should show impact level (shell = high).""" run, _req = _mock_paused_run() item = _mock_approval_item() with ( patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run), patch("timmy.approvals.create_item", return_value=item), ): response = client.post("/agents/default/chat", data={"message": "run it"}) assert "impact-high" in response.text def test_chat_tool_card_has_htmx_approve_endpoint(client): """Approval card buttons should target the correct HTMX endpoints.""" run, _req = _mock_paused_run() item = _mock_approval_item() with ( patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run), patch("timmy.approvals.create_item", return_value=item), ): response = client.post("/agents/default/chat", data={"message": "run it"}) assert 'hx-post="/agents/default/tool/' in response.text assert '/approve"' in response.text assert '/reject"' in response.text # ── Approve endpoint ───────────────────────────────────────────────────────── def _create_pending_tool(client, approval_id="test-approval-123"): """Helper: send a chat that creates a pending tool, return the approval_id.""" run, _req = _mock_paused_run() item = _mock_approval_item(approval_id) with ( patch("dashboard.routes.agents.chat_with_tools", new_callable=AsyncMock, return_value=run), patch("timmy.approvals.create_item", return_value=item), ): response = client.post("/agents/default/chat", data={"message": "run it"}) assert 'hx-post="/agents/default/tool/' in response.text return approval_id def test_approve_executes_tool_and_returns_result(client): """Approving a tool should resume via Agno and return the result card.""" approval_id = _create_pending_tool(client) # Mock the resume returning a completed run with tool result result_run = MagicMock() result_tool = MagicMock() result_tool.tool_name = "shell" result_tool.result = "hello\n" result_run.tools = [result_tool] result_run.content = "Done." with ( patch( "dashboard.routes.agents.continue_chat", new_callable=AsyncMock, return_value=result_run ), patch("timmy.approvals.approve"), ): response = client.post(f"/agents/default/tool/{approval_id}/approve") assert response.status_code == 200 assert "hello" in response.text def test_approve_unknown_id_returns_404(client): """Approving a non-existent tool action should return 404.""" response = client.post("/agents/default/tool/nonexistent-id/approve") assert response.status_code == 404 def test_approve_same_id_twice_returns_404(client): """Each approval can only be used once.""" approval_id = _create_pending_tool(client) result_run = _mock_completed_run("ok") with ( patch( "dashboard.routes.agents.continue_chat", new_callable=AsyncMock, return_value=result_run ), patch("timmy.approvals.approve"), ): client.post(f"/agents/default/tool/{approval_id}/approve") # Second attempt should 404 response = client.post(f"/agents/default/tool/{approval_id}/approve") assert response.status_code == 404 # ── Reject endpoint ────────────────────────────────────────────────────────── def test_reject_returns_rejected_card(client): """Rejecting a tool should return a rejected status card.""" approval_id = _create_pending_tool(client) with ( patch( "dashboard.routes.agents.continue_chat", new_callable=AsyncMock, return_value=_mock_completed_run(), ), patch("timmy.approvals.reject"), ): response = client.post(f"/agents/default/tool/{approval_id}/reject") assert response.status_code == 200 assert "Rejected" in response.text def test_reject_unknown_id_still_returns_200(client): """Rejecting a non-existent ID is idempotent (no crash).""" with patch("timmy.approvals.reject"): response = client.post("/agents/default/tool/nonexistent-id/reject") assert response.status_code == 200 # ── Safe tools skip confirmation ───────────────────────────────────────────── def test_safe_tool_does_not_show_approval(client): """Completed runs (safe tools auto-executed) should not show approval cards.""" run = _mock_completed_run("Here is the file content.") with patch("dashboard.routes.agents.chat_with_tools", return_value=run): response = client.post("/agents/default/chat", data={"message": "read the readme"}) assert "APPROVE" not in response.text assert "REJECT" not in response.text