Files
enterprise-addons/ai/tests/test_ir_actions_server.py
T

359 lines
15 KiB
Python

import json
from unittest.mock import patch
from odoo.addons.ai.utils.llm_api_service import LLMApiService
from odoo.exceptions import AccessError
from odoo.tests import TransactionCase, new_test_user, tagged
from odoo.tools import mute_logger
from odoo.addons.ai.models.ir_actions_server import _logger as tool_logger
@tagged('post_install', '-at_install')
class TestAiServerActions(TransactionCase):
def _mock_llm_api_get_token(self):
def _mock_get_api_token(self):
return "dummy"
return patch.object(LLMApiService, '_get_api_token', _mock_get_api_token)
def test_ai_server_action_access(self):
"""Test that the group check is skipped on the tool, but not on the AI action."""
user = new_test_user(self.env, "internal_user_ai", "base.group_user,base.group_partner_manager")
partner = self.env["res.partner"].create({"name": "Partner"})
ir_action_tool = self.env["ir.actions.server"].create({
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "code",
"name": "Write Name",
"use_in_ai": True,
"code": "record.write({'name': value})",
"group_ids": self.env.ref("base.group_system").ids,
})
action = self.env["ir.actions.server"].create(
{
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "ai",
"name": "Test",
"ai_tool_ids": ir_action_tool.ids,
"ai_action_prompt": "Main Prompt",
},
)
llm_calls = 0
def _mocked_request_llm(
service, llm_model, system_prompts, user_prompts, tools=None,
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=None,
):
nonlocal llm_calls
llm_calls += 1
return ["Done"], [], []
# Check that we skip the group check on the tools
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
action.with_user(user).with_context(active_id=partner.id, active_model='res.partner').run()
self.assertEqual(llm_calls, 1)
# But not on the AI action
action.group_ids = self.env.ref("base.group_system").ids
llm_calls = 0
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm), self.assertRaises(AccessError):
action.with_user(user).with_context(active_id=partner.id, active_model='res.partner').run()
self.assertEqual(llm_calls, 0)
def test_ai_server_action_user_access(self):
user = new_test_user(self.env, "internal_user_ai", "base.group_user")
partner = self.env["res.partner"].create({"name": "Partner"})
# Remove res.partner read access from the user
access_records = self.env['ir.model.access'].search([
('model_id.model', '=', 'res.partner'),
('group_id', 'in', user.group_ids.ids)
])
access_records.write({'perm_read': False})
# Prepare the mocked LLM responses, openai format
mock_request = self._create_mock_request([
{
"output": [
{
"type": "function_call",
"name": "ir_actions_server_search",
"call_id": "call_1",
"arguments": json.dumps({"model_name": "res.partner", "domain": json.dumps([["id", "=", partner.id]]), "fields": ["name"]}),
},
],
},
{
"output": [
{"text": "Unable to fetch partner details due to access rights."},
],
},
])
# Fetch the ask_ai_agent record with elevated rights, just like they do in the controller
ask_ai_agent_sudo = self.env["ai.agent"]._get_potential_ask_ai_agent().with_user(user).sudo()
# Need to ensure the llm model is openai since the mocked request is for openai
ask_ai_agent_sudo.write({"llm_model": "gpt-4.1"})
with patch.object(LLMApiService, "_request", mock_request), \
patch.object(LLMApiService, "_get_api_token", return_value='dummy_token'), \
self.assertLogs(tool_logger, level='ERROR') as mock_tool_logger:
ask_ai_agent_sudo._generate_response("test")
# Single error log stating the user cannot access the records
error_log_record, = mock_tool_logger.records
self.assertIn("An error occurred while executing AI: Search: You are not allowed to access 'Contact' (res.partner) records.", error_log_record.msg)
@mute_logger("odoo.addons.ai.utils.llm_api_service")
def test_ai_server_action_ai_tool(self):
llm_calls = 0
def _mocked_request_llm(
service, llm_model, system_prompts, user_prompts, tools=None,
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
):
nonlocal llm_calls
llm_calls += 1
tool_names = sorted(tools, key=lambda t: tools[t][0])
self.assertEqual(len(tools or ()), 2)
self.assertEqual(tool_names[0], f"action_{ir_action_tools[0].id}")
self.assertEqual(tools[tool_names[0]][0], ir_action_tools[0].name)
self.assertEqual(tool_names[1], f"action_{ir_action_tools[1].id}")
self.assertEqual(tools[tool_names[1]][0], ir_action_tools[1].name)
if llm_calls == 1:
self.assertFalse(inputs)
# Call "Return Value" and wait the result
return self._ai_tool_call(tool_names[0], "call_123456", {})
if llm_calls == 2:
self.assertEqual(len(inputs), 2)
self.assertEqual(inputs[0].get('call_id'), "call_123456")
self.assertEqual(inputs[1].get('call_id'), "call_123456")
self.assertEqual(inputs[1].get('output'), "133333337")
self.assertEqual(inputs[1].get('type'), "function_call_output")
return self._ai_tool_call(
tool_names[1],
"call_789123",
{"value": "new name", "__end_message": "Renamed!"},
)
return ["Done"], [], []
partner = self.env["res.partner"].create({"name": "Partner"})
ir_action_tools = self.env["ir.actions.server"].create([{
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "code",
"name": "Return Value",
"use_in_ai": True,
"code": "ai['result'] = 133333337",
}, {
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "code",
"name": "Write Name",
"use_in_ai": True,
"code": "record.write({'name': value})",
}])
action = self.env["ir.actions.server"].create(
{
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "ai",
"name": "Test",
"ai_tool_ids": ir_action_tools.ids,
"ai_action_prompt": "Main Prompt",
},
)
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
action.with_context(active_model=partner._name, active_id=partner.id).run()
self.assertEqual(llm_calls, 2)
self.assertEqual(partner.name, "new name")
# Simulate the LLM answering a forbidden action (not in the tools)
bad_action = self.env["ir.actions.server"].create({
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "code",
"name": "Bad Action",
"use_in_ai": True,
"code": "record.write({'name': 'bad'})",
})
llm_calls = 0
def _mocked_request_llm_bad_action(*args, **kwargs):
nonlocal llm_calls
llm_calls += 1
if llm_calls == 1:
return self._ai_tool_call(f"action_{bad_action.id}", "call_123456", {})
return ["Done"], [], []
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm_bad_action):
action.with_context(active_model=partner._name, active_id=partner.id).run()
self.assertEqual(llm_calls, 2)
self.assertEqual(partner.name, "new name", "Should not execute the action because it's not listed in the tools")
def _patched_can_execute_action_on_records(*__):
raise AccessError("")
with (
patch.object(self.env.registry['ir.actions.server'], "_can_execute_action_on_records", _patched_can_execute_action_on_records),
self.assertRaises(AccessError),
):
action.with_context(active_model=partner._name, active_id=partner.id).run()
# Check that if we mark a tool as "not used with AI" we don't send it
# to the LLM even if the m2m relation still exist
ir_action_tools[1].use_in_ai = False
partner.name = 'name'
llm_calls = 0
def _mocked_request_llm_use_in_ai_false(
service, llm_model, system_prompts, user_prompts, tools=None,
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
):
nonlocal llm_calls
llm_calls += 1
self.assertEqual(len(tools), 1)
if llm_calls == 1:
# The LLM still try to execute it
return self._ai_tool_call(
f"action_{ir_action_tools[1].id}",
"call_789123",
{"value": "new name"},
)
return ["Done"], [], []
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm_use_in_ai_false):
action.with_context(active_model=partner._name, active_id=partner.id).run()
self.assertEqual(llm_calls, 2)
self.assertEqual(partner.name, "name", "The action is disabled and should not be executed")
@mute_logger("odoo.addons.ai.models.ir_actions_server")
def test_ai_server_action_ai_interactive_tool(self):
"""Check that we raise an error for interactive tools."""
llm_calls = 0
def _mocked_request_llm(
service, llm_model, system_prompts, user_prompts, tools=None,
files=None, schema=None, temperature=0.2, inputs=(), web_grounding=False,
):
nonlocal llm_calls
llm_calls += 1
if llm_calls == 1:
self.assertFalse(inputs)
return self._ai_tool_call(f"action_{ir_action_tool.id}", "call_123456", {})
return ["Done"], [], []
partner = self.env["res.partner"].create({"name": "Partner"})
ir_action_tool = self.env["ir.actions.server"].create({
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "code",
"name": "Return Value",
"use_in_ai": True,
# The action tries to open a window action
"code": """action = {
"type": "ir.actions.act_window",
"res_model": "res.partner",
"target": "new",
}
""",
})
action = self.env["ir.actions.server"].create(
{
"model_id": self.env["ir.model"]._get_id("res.partner"),
"state": "ai",
"name": "Test",
"ai_tool_ids": ir_action_tool.ids,
"ai_action_prompt": "Main Prompt",
},
)
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
action.with_context(active_model=partner._name, active_id=partner.id).run()
self.assertEqual(llm_calls, 2)
self.assertIn(
'This action is interactive and cannot be executed by the agent.',
''.join(partner.message_ids.mapped('body')),
'Should log the error on the partner',
)
def _ai_tool_call(self, name, call_id, arguments):
# Simulate the response of `_request_llm` when the LLM ask to execute a tool
return ["Done"], [(name, call_id, arguments)], [{"call_id": call_id, "name": name, "arguments": json.dumps(arguments)}]
def _create_mock_request(self, responses):
call_count = 0
def mock_request(*args, **kwargs):
nonlocal call_count
result = responses[call_count]
call_count += 1
return result
return mock_request
def test_ai_create_activity(self):
# check that activities can be created from an AI action
create_activity_action = self.env['ir.actions.server'].create({
'model_id': self.env['ir.model']._get_id('res.partner'),
'state': 'next_activity',
'name': 'create activity',
'use_in_ai': True,
'activity_type_id': self.env.ref('mail.mail_activity_data_todo').id,
'activity_note': 'created by AI',
})
ai_action = self.env['ir.actions.server'].create({
'model_id': self.env['ir.model']._get_id('res.partner'),
'state': 'ai',
'name': 'call create activity',
'ai_action_prompt': 'create an activity',
'ai_tool_ids': create_activity_action.ids,
})
def _mocked_request_llm(*args, **kwargs):
return self._ai_tool_call(f"action_{create_activity_action.id}", "call_111111", {})
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
ai_action._ai_action_run(self.env.user.partner_id)
self.assertIn('created by AI', self.env.user.partner_id.activity_ids[0].note)
def test_ai_send_mail(self):
partner_model_id = self.env['ir.model']._get_id('res.partner')
send_mail_action = self.env['ir.actions.server'].create({
'model_id': partner_model_id,
'state': 'mail_post',
'name': 'send mail',
'use_in_ai': True,
'template_id': self.env['mail.template'].create({
'name': 'Test template',
'model_id': partner_model_id,
'subject': 'mail sent by AI',
}).id
})
ai_action = self.env['ir.actions.server'].create({
'model_id': partner_model_id,
'state': 'ai',
'name': 'send mail',
'ai_action_prompt': 'send an email',
'ai_tool_ids': send_mail_action.ids,
})
def _mocked_request_llm(*args, **kwargs):
return self._ai_tool_call(f"action_{send_mail_action.id}", "call_111111", {})
with patch.object(LLMApiService, "_request_llm", _mocked_request_llm):
ai_action._ai_action_run(self.env.user.partner_id)
# check that the mail has been created
self.assertTrue(any(msg.subject == 'mail sent by AI' for msg in self.env.user.partner_id.message_ids))