Initial import of Shirobana bot backend
Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Skill orchestration tests with fake models, isolated skill directories and no sends."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
import sys
|
||||
import tempfile
|
||||
from types import SimpleNamespace
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
BASE = Path(__file__).resolve().parents[1]
|
||||
sys.path[:0] = [str(BASE / 'bot-utility'), str(BASE)]
|
||||
import skill_registry
|
||||
import skill_runtime
|
||||
import help_ui
|
||||
import notification_render
|
||||
import notifications
|
||||
import agent_loop
|
||||
from chat import engine
|
||||
|
||||
|
||||
class SkillIntegrationTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self):
|
||||
self.tmp = tempfile.TemporaryDirectory()
|
||||
base = Path(self.tmp.name)
|
||||
root = base / 'skills'
|
||||
self.body = 'SPECIFIC_STYLE_RULE: 用户称呼优先于默认规范。'
|
||||
for name in ('language', 'result-style'):
|
||||
folder = root / name
|
||||
folder.mkdir(parents=True)
|
||||
(folder / 'SKILL.md').write_text(
|
||||
f'---\nname: {name}\ndescription: Language style\n---\n' + self.body + name,
|
||||
encoding='utf-8')
|
||||
config = base / 'skills_config.yml'
|
||||
config.write_text(json.dumps({
|
||||
'enabled': True, 'allowlist': ['language', 'result-style'],
|
||||
'always': ['language'], 'scenes': {'tool_result': ['result-style']},
|
||||
}), encoding='utf-8')
|
||||
self.registry = skill_registry.SkillRegistry(root, config)
|
||||
self.registry_patch = patch.object(skill_runtime, 'registry', self.registry)
|
||||
self.registry_patch.start()
|
||||
|
||||
def tearDown(self):
|
||||
self.registry_patch.stop()
|
||||
self.tmp.cleanup()
|
||||
|
||||
async def test_help_keeps_personal_settings_and_unwraps_text(self):
|
||||
model = AsyncMock(return_value=json.dumps({'text': '主人,可以查邮件。', 'tools': []}))
|
||||
with patch.object(engine, '_call_deepseek_transport', new=model), \
|
||||
patch.object(engine, 'user_persona_prompt', return_value='USER_SETTING: 称呼主人'):
|
||||
result = await help_ui.chat_help(42)
|
||||
self.assertEqual(result, '主人,可以查邮件。')
|
||||
prompt = model.await_args.args[0]
|
||||
self.assertIn(self.body, prompt)
|
||||
self.assertIn('USER_SETTING: 称呼主人', prompt)
|
||||
self.assertIn('help', prompt)
|
||||
self.assertIsNone(skill_runtime.current_turn())
|
||||
|
||||
async def test_notification_and_reminder_open_own_scene(self):
|
||||
scenes = []
|
||||
async def model(*args, **kwargs):
|
||||
scenes.append(skill_runtime.current_turn().scene)
|
||||
return json.dumps({'text': '提醒内容', 'tools': []})
|
||||
with patch.object(engine, '_call_deepseek_transport', new=model), \
|
||||
patch.object(engine, 'user_persona_prompt', return_value='USER_SETTING'):
|
||||
for kind in ('komari', 'reminder'):
|
||||
event = notifications.Event(kind=kind, dedupe_key='test-' + kind,
|
||||
uid=42, legacy_text='原文')
|
||||
self.assertEqual(await notification_render.render_event(event, 'chat', 1), '提醒内容')
|
||||
self.assertEqual(scenes, ['notification', 'reminder'])
|
||||
|
||||
async def test_notification_cannot_execute_business_tool_and_keeps_fallback(self):
|
||||
response = json.dumps({'text': '不应发送', 'tools': [{'name': 'akaros_mail', 'args': {}}]})
|
||||
with patch.object(engine, '_call_deepseek_transport', new=AsyncMock(return_value=response)), \
|
||||
patch.object(engine, 'user_persona_prompt', return_value='base'):
|
||||
event = notifications.Event(kind='komari', dedupe_key='readonly', uid=42, legacy_text='原始告警')
|
||||
result = await notification_render.render_event(event, 'chat', 1)
|
||||
self.assertIn('原始告警', result)
|
||||
self.assertNotIn('不应发送', result)
|
||||
|
||||
async def test_private_and_proactive_sessions_cover_entire_generation(self):
|
||||
seen = []
|
||||
async def impl(*args, **kwargs):
|
||||
seen.append(skill_runtime.current_turn().scene)
|
||||
with patch.object(engine, '_resolve_delivery', return_value=None), \
|
||||
patch.object(engine, '_call_model_and_reply_impl', new=impl), \
|
||||
patch.object(notifications.center, 'begin_conversation'), \
|
||||
patch.object(notifications.center, 'end_conversation'), \
|
||||
patch.object(notifications.center, 'deliver_due', new=AsyncMock()):
|
||||
await engine._call_model_and_reply(None, 101, 'hi', False)
|
||||
await engine._call_model_and_reply(None, 101, 'hi', True)
|
||||
self.assertEqual(seen, ['private_chat', 'proactive'])
|
||||
self.assertIsNone(skill_runtime.current_turn())
|
||||
|
||||
async def test_group_has_group_session_not_private_persona(self):
|
||||
seen = []
|
||||
async def impl(*args, **kwargs):
|
||||
seen.append(skill_runtime.current_turn().scene)
|
||||
with patch.object(engine, '_call_group_model_and_reply_impl', new=impl):
|
||||
await engine._call_group_model_and_reply(None, 201, 'hi', 42)
|
||||
self.assertEqual(seen, ['group_chat'])
|
||||
|
||||
async def test_agent_followup_binds_result_style_without_repeating_business(self):
|
||||
final = json.dumps({'message': [{'content': 'done'}], 'tools': []})
|
||||
model = AsyncMock(return_value=final)
|
||||
channel = SimpleNamespace(name='tg', is_admin=lambda uid: False)
|
||||
store = MagicMock()
|
||||
with skill_runtime.skill_session('private_chat'), \
|
||||
patch.object(engine, '_call_deepseek_transport', new=model), \
|
||||
patch.object(engine, 'run_tools', new=AsyncMock(return_value='read result')) as tools, \
|
||||
patch.object(agent_loop, 'AgentTaskStore', return_value=store), \
|
||||
patch.object(agent_loop, 'ToolExecutor'):
|
||||
result = await agent_loop.run_agent_loop(engine,
|
||||
{'message': [], 'tools': [{'name': 'web_search', 'args': {'query': 'x'}}]},
|
||||
'system', 'user', channel, 'private', 42, 1)
|
||||
self.assertEqual(result['tools'], [])
|
||||
tools.assert_awaited_once()
|
||||
self.assertIn(self.body + 'result-style', model.await_args.args[0])
|
||||
|
||||
async def test_internal_sticker_audit_suspends_skills(self):
|
||||
image = Path(self.tmp.name) / 'preview.png'
|
||||
image.write_bytes(b'preview')
|
||||
model = AsyncMock(return_value='{"send":true}')
|
||||
with skill_runtime.skill_session('private_chat'), \
|
||||
patch.object(engine, '_call_deepseek_transport', new=model):
|
||||
self.assertTrue(await engine._confirm_sticker({'preview_path': str(image)}, 'happy', 'hi'))
|
||||
self.assertNotIn(self.body, model.await_args.args[0])
|
||||
|
||||
def test_usage_logger_contains_counts_not_payload(self):
|
||||
payload = {'usage': {'prompt_tokens': 100, 'prompt_tokens_details': {'cached_tokens': 80}},
|
||||
'choices': [{'message': {'content': 'PRIVATE_MESSAGE'}}]}
|
||||
with patch('builtins.print') as output:
|
||||
engine._log_model_usage('test', payload)
|
||||
text = output.call_args.args[0]
|
||||
self.assertIn('cached_tokens', text)
|
||||
self.assertNotIn('PRIVATE_MESSAGE', text)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user