Files
shirobana-bot/tests/test_wardrobe_reply_loop.py
T
RRRosmontis 34089d0042 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.
2026-11-11 00:25:30 +08:00

264 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Wardrobe orchestration regressions: fake model, executor and task store only."""
import json
import sys
import unittest
from pathlib import Path
from unittest.mock import patch
BASE = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(BASE / "bot-utility"), str(BASE)]
import agent_loop
from agent import ToolResult
def call(name="白衬衫", description="柔软", action="add", tool="wardrobe"):
return {"name": tool, "args": {"action": action, "params": {"name": name, "description": description}}}
def request(*calls):
return {"message": [], "tools": list(calls)}
def reply(text):
return {"message": [{"content": text}], "tools": []}
class Channel:
name = "test"
def is_admin(self, uid):
return False
class Store:
last = None
def __init__(self):
self.steps = []
self.finished = None
Store.last = self
def start(self, *args):
return "fake-task"
def record(self, *args):
self.steps.append(args)
def finish(self, task, status, error=""):
self.finished = status
class Engine:
BASE_PROMPT = "GROUP_PERSONA"
user_persona_prompt = staticmethod(lambda uid: "ORIGINAL_PERSONA")
def __init__(self, responses):
self.responses = list(responses)
self.prompts = []
async def call_deepseek(self, prompt, *args, **kwargs):
polishing = "【仅润色回复正文】" in prompt
assert kwargs["reasoning"] is (not polishing)
self.prompts.append(prompt)
response = self.responses.pop(0) if self.responses else RuntimeError("outage")
if isinstance(response, Exception):
raise response
return json.dumps(response, ensure_ascii=False) if polishing and isinstance(response, dict) else response
def parse_json_response(self, raw):
return raw
class WardrobeReplyTest(unittest.IsolatedAsyncioTestCase):
async def run_loop(self, initial, responses, outcomes=None, **limits):
calls = []
outcomes = list(outcomes or [])
class Executor:
async def execute(self, name, args, ctx):
calls.append((name, args))
if outcomes:
return outcomes.pop(0)
item = {"id": len(calls), "uid": 999, "name": args["params"]["name"],
"description": "RAW_PRIVATE_DESCRIPTION", "created_at": "SECRET_TIME"}
return ToolResult(True, "ok", json.dumps(item), data={"item": item})
engine = Engine(responses)
with patch.object(agent_loop, "AgentTaskStore", Store), patch.object(agent_loop, "ToolExecutor", Executor):
with patch.multiple(agent_loop, **({"MAX_CALLS": 12, "MAX_ROUNDS": 8} | limits)):
result = await agent_loop.run_agent_loop(engine, initial, "ORIGINAL_PERSONA", "user", Channel(), "private", 1, 2)
return result, calls, engine
async def test_plain_prose_finalization_does_not_need_json_fields(self):
result, calls, model = await self.run_loop(request(call()), [None, "谢谢主人,这件我记住啦。"])
self.assertEqual(agent_loop._message_text(result), "谢谢主人,这件我记住啦。")
self.assertEqual(len(calls), 1)
self.assertEqual(result['tools'], [])
self.assertNotIn('请仅生成有效JSON', model.prompts[-1])
self.assertNotIn('RAW_PRIVATE_DESCRIPTION', model.prompts[-1])
self.assertNotIn('SECRET_TIME', model.prompts[-1])
self.assertIn('ORIGINAL_PERSONA', model.prompts[-1])
self.assertEqual(Store.last.finished, 'done')
async def test_legacy_text_envelope_can_omit_tools(self):
result, calls, _ = await self.run_loop(request(call()), [None, '{"text":"谢谢主人。"}'])
self.assertEqual(agent_loop._message_text(result), '谢谢主人。')
self.assertEqual(len(calls), 1)
def test_plain_reply_rejects_tools_and_db_dump_without_execution(self):
for raw in ('{"tools":[{"name":"wardrobe"}],"text":"x"}',
'{"id":"abc","uid":42,"name":"dress"}', '', None):
self.assertIsNone(agent_loop._plain_reply(raw))
self.assertEqual(agent_loop._plain_reply('好呀,谢谢你。'), '好呀,谢谢你。')
async def test_outage_never_exposes_raw_records(self):
result, calls, engine = await self.run_loop(request(call()), [RuntimeError("offline")])
self.assertEqual(agent_loop._message_text(result), "好呀,白衬衫已经在衣柜里啦。")
self.assertEqual(result["tools"], [])
self.assertEqual(len(engine.prompts), 2)
self.assertIn("ORIGINAL_PERSONA", engine.prompts[-1])
self.assertIn("实际工具结果", engine.prompts[-1])
async def test_same_normalized_name_paraphrase_replays(self):
result, calls, engine = await self.run_loop(
request(call("A Shirt", "丝绸")),
[request(call("a\tshirt", "轻柔丝质")), reply("记住啦")])
self.assertEqual(len(calls), 1)
self.assertIn('"code": "already_saved"', engine.prompts[-1])
self.assertIn("update", engine.prompts[-1])
self.assertIn('"id": 1', engine.prompts[-1])
async def test_outage_receipt_names_only_completed_adds(self):
result, calls, _ = await self.run_loop(request(call("红裙"), call("蓝裙"), call("未执行绿裙")), [None, None])
text = agent_loop._message_text(result)
self.assertEqual(len(calls), 2)
self.assertEqual("好呀,红裙、蓝裙已经在衣柜里啦。", text)
for forbidden in ("未执行绿裙", "RAW_PRIVATE_DESCRIPTION", "uid", "SECRET_TIME", "{"):
self.assertNotIn(forbidden, text)
async def test_different_names_both_saved(self):
_, calls, _ = await self.run_loop(request(call("红裙"), call("蓝裙")), [reply("好呀")])
self.assertEqual(len(calls), 2)
async def test_update_reversion_is_not_replayed_from_stale_cache(self):
first = call('外套', '蓝色', action='update')
second = call('外套', '红色', action='update')
_, calls, _ = await self.run_loop(request(first), [request(second), request(first), reply('改好了')])
self.assertEqual(len(calls), 3)
async def test_delete_invalidates_same_turn_add_guard(self):
added = ToolResult(True, 'ok', '已保存', data={'item': {'id': 'first', 'name': '外套'}})
removed = ToolResult(True, 'ok', '已删除', data={'item': {'id': 'first', 'name': '外套'}})
readded = ToolResult(True, 'ok', '已保存', data={'item': {'id': 'second', 'name': '外套'}})
_, calls, _ = await self.run_loop(request(call('外套')),
[request(call('外套', action='delete')), request(call('外套')), reply('收好了')],
outcomes=[added, removed, readded])
self.assertEqual(len(calls), 3)
async def test_exact_duplicates_do_not_execute(self):
_, calls, _ = await self.run_loop(request(call(), call()), [reply("好呀")])
self.assertEqual(len(calls), 1)
async def test_failed_add_can_retry(self):
failed = ToolResult(False, "busy", "暂时忙,请重试")
_, calls, _ = await self.run_loop(request(call()), [request(call()), reply("好了")], [failed])
self.assertEqual(len(calls), 2)
async def test_all_prior_successes_survive_in_bounded_ledger(self):
rounds = [request(call(f"衣服{i}")) for i in range(1, 5)]
_, _, engine = await self.run_loop(request(call("衣服0")), rounds + [reply("好呀")])
last = engine.prompts[-1]
ledger = json.loads(last.split("【累计成功操作摘要(内部)】\n")[1].split("\n【你刚才")[0])
self.assertEqual(len(ledger), 5)
for i in range(5):
self.assertIn(f"衣服{i}", " ".join(ledger))
self.assertTrue(all(len(entry) <= 400 for entry in ledger))
self.assertNotIn("RAW_PRIVATE_DESCRIPTION", " ".join(ledger))
self.assertNotIn("SECRET_TIME", " ".join(ledger))
self.assertIn("tool=wardrobe; action=add; code=ok", ledger[0])
self.assertIn('"action": "add"', last)
async def test_ledger_bound_and_no_raw_arguments(self):
rounds = [request(call(f"衣服{i}")) for i in range(1, 14)]
_, _, engine = await self.run_loop(request(call("衣服0", "password=secret")), rounds + [reply("好了")], MAX_ROUNDS=20, MAX_CALLS=20)
ledger = json.loads(engine.prompts[-1].split("【累计成功操作摘要(内部)】\n")[1].split("\n【你刚才")[0])
self.assertEqual(len(ledger), 12)
self.assertNotIn("secret", " ".join(ledger))
self.assertTrue(all(len(entry) <= 400 for entry in ledger))
async def test_read_only_queries_are_not_deduplicated(self):
for tool in ("wardrobe", "mail"):
_, calls, _ = await self.run_loop(request(call(action="list", tool=tool)),
[request(call(action="list", tool=tool)), reply("好了")])
self.assertEqual(len(calls), 2)
async def test_invalid_followup_and_invalid_final_content(self):
result, calls, engine = await self.run_loop(request(call()),
[{"tools": "invalid", "message": []}, {"tools": [], "message": [{"content": {"text": "wrong type"}}]}])
self.assertEqual(agent_loop._message_text(result), "好呀,白衬衫已经在衣柜里啦。")
self.assertEqual(len(engine.prompts), 2)
self.assertEqual(len(calls), 1)
async def test_latest_observation_keeps_all_results(self):
_, _, engine = await self.run_loop(request(call("一"), call("二")), [reply("好了")])
observation = engine.prompts[0].split("【你刚才执行了工具,以下是结构化结果】\n")[1].split("\n请根据")[0]
self.assertEqual(len(json.loads(observation)), 2)
async def test_invalid_model_fallback_status_is_not_done(self):
await self.run_loop(request(call()), [None, None])
self.assertEqual(Store.last.finished, "response_fallback")
async def test_finalization_cannot_execute_proposed_tool(self):
result, calls, engine = await self.run_loop(request(call()), [None, request(call("不该保存"))])
self.assertEqual(len(calls), 1)
self.assertEqual(result["tools"], [])
self.assertEqual(len(engine.prompts), 2)
async def test_leaked_final_response_gets_one_repair(self):
result, calls, engine = await self.run_loop(request(call()), [reply('{"id":1,"description":"private"}'), reply("谢谢你,我记住啦。")])
self.assertEqual(agent_loop._message_text(result), "谢谢你,我记住啦。")
self.assertEqual(len(engine.prompts), 2)
async def test_leaked_repair_rejected(self):
result, _, _ = await self.run_loop(request(call()), [None, reply('{"uid":1,"created_at":"today"}')])
self.assertNotIn("uid", str(result))
self.assertIn("已经在衣柜里", str(result))
async def test_empty_message_and_limits_finalize(self):
for limits, responses in [({}, [reply(""), reply("记住了")]),
({"MAX_ROUNDS": 1}, [request(call("后续")), reply("记住了")]),
({"MAX_CALLS": 1}, [request(call("后续")), reply("记住了")])]:
result, calls, engine = await self.run_loop(request(call()), responses, **limits)
self.assertEqual(agent_loop._message_text(result), "记住了")
self.assertEqual(len(calls), 1)
self.assertEqual(len(engine.prompts), 2)
async def test_mixed_failure_not_all_success(self):
good = ToolResult(True, "ok", '{"id":1,"uid":999}', data={"item": {"name": "裙子"}})
bad = ToolResult(False, "invalid_args", "衣物名称不能为空")
result, _, _ = await self.run_loop(request(call("一"), call("二")), [None], [good, bad])
text = agent_loop._message_text(result)
self.assertIn("还有操作没完成", text)
self.assertIn("衣物名称不能为空", text)
self.assertNotIn("这些衣服我记住啦", text)
self.assertNotIn("uid", text)
def test_list_names_only_and_write_fallbacks(self):
record = {"id": 4, "uid": 42, "name": "绿裙", "description": "SECRET", "created_at": "DATE"}
result = agent_loop._fallback_parsed([{"tool": "wardrobe", "action": "list", "ok": True, "safe_message": json.dumps(record), "data": {"items": [record]}}])
self.assertEqual(agent_loop._message_text(result), "衣柜里有:绿裙。")
for action, word in [("update", "改好"), ("delete", "移除")]:
text = agent_loop._message_text(agent_loop._fallback_parsed([{"tool": "wardrobe", "action": action, "ok": True, "safe_message": json.dumps(record)}]))
self.assertIn(word, text)
self.assertNotIn("SECRET", text)
def test_nonwardrobe_fallback_unchanged(self):
result = agent_loop._fallback_parsed([{"tool": "mail", "ok": True, "safe_message": "邮件已发送"}])
self.assertEqual(agent_loop._message_text(result), "已经处理好了呢。\n邮件已发送")
result = agent_loop._fallback_parsed([{"tool": "pixiv", "ok": False, "code": "send_failed", "safe_message": "发送失败 https://example.test"}])
self.assertEqual(agent_loop._message_text(result), "处理时遇到了具体问题:\npixiv(send_failed):发送失败 https://example.test")
if __name__ == "__main__":
unittest.main()