"""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()