Include utility modules, shared channels, prompts, read-only skills, wardrobe memory and regression tests. Externalize legacy credentials and exclude private runtime state.
264 lines
13 KiB
Python
264 lines
13 KiB
Python
"""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()
|