Files
DemonGatanjieu-Anomalous_Mo…/tests/test_ai_gates.py
T
DemonGatanjieuandClaude Opus 5.5 537ebc46d7 Make the new-route reminder say try it, not change models
A Flash exam showed a light model can build a route it is told, so the reminder
no longer sends the user to a flagship model: the route is untried, see how the
run turns out, and save a card if it is good.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 14:12:50 +08:00

242 lines
12 KiB
Python

"""Gates (api/ai_gates.py): what AI apps may do without asking, the step that waits for the
user's OK, an answer that comes after the AI stopped waiting. Everything in temporary folders."""
import json
import os
import sys
import tempfile
import threading
import time
import unittest
from unittest import mock
from pathlib import Path
PLUGIN = Path(__file__).resolve().parents[1]
sys.path[:0] = [str(PLUGIN), str(PLUGIN.parents[1]), str(PLUGIN / "tests")]
from api import ai_gates, ai_panels, ai_tasks, mcp_canvas_edit # noqa: E402
from api.mcp_tools import ToolError # noqa: E402
from test_ai_tasks import isolate, ok # noqa: E402
GATES = tempfile.mkdtemp(prefix="amb_ai_gates_")
class GateTests(unittest.TestCase):
def setUp(self):
isolate()
ai_gates._path = lambda: os.path.join(GATES, "ai_gates.json")
if os.path.exists(ai_gates._path()):
os.remove(ai_gates._path())
def call(self, tool, arguments):
"""What mcp_server does: a running step, the gate, then the outcome recorded."""
ref = ai_tasks.begin_step("Claude", tool, arguments, time.time(), False)
verdict = ai_gates.check(tool, arguments, ref)
result = verdict if verdict is not None else ok({"done": True})
ai_tasks.finish_step("Claude", tool, arguments, result, time.time(), False, step_ref=ref)
return ref, verdict
def answer_soon(self, ref, decision, text=""):
def later():
for _ in range(100):
task = ai_tasks.get_task(ref[0])
if any(step.get("state") == "asking" for step in task["steps"]):
ai_gates.decide(ref[0], ref[1], decision, text)
return
time.sleep(0.02)
threading.Thread(target=later).start()
def test_policies(self):
self.assertEqual(ai_gates.policies(), ai_gates.DEFAULTS)
self.assertEqual(ai_gates.set_policy("gpu", "ask")["gpu"], "ask")
self.assertEqual(ai_gates.policies()["gpu"], "ask")
with self.assertRaises(ValueError):
ai_gates.set_policy("gpu", "maybe")
with open(ai_gates._path(), "w", encoding="utf-8") as target:
json.dump({"canvas": "never", "gpu": "nonsense"}, target)
self.assertEqual((ai_gates.policies()["canvas"], ai_gates.policies()["gpu"]), ("never", "auto"))
def test_how_long_the_ai_waits_for_an_answer(self):
ai_gates._tools_path = lambda: os.path.join(GATES, "ai_tools.json")
if os.path.exists(ai_gates._tools_path()):
os.remove(ai_gates._tools_path())
self.assertEqual(ai_gates.answer_wait(), 180)
self.assertEqual(ai_gates.set_answer_wait(600), 600)
self.assertEqual(ai_gates.answer_wait(), 600)
for wrong in (30, True, "600"):
with self.assertRaises(ValueError):
ai_gates.set_answer_wait(wrong)
def test_how_the_ai_waits_for_a_run(self):
ai_gates._tools_path = lambda: os.path.join(GATES, "ai_tools.json")
if os.path.exists(ai_gates._tools_path()):
os.remove(ai_gates._tools_path())
self.assertEqual(ai_gates.run_wait(), "call", "the user tells the AI when it is done")
ai_gates.set_answer_wait(600)
self.assertEqual(ai_gates.set_run_wait("wait"), "wait")
self.assertEqual((ai_gates.run_wait(), ai_gates.answer_wait()), ("wait", 600), "one setting keeps the other")
with open(ai_gates._tools_path(), "w", encoding="utf-8") as target:
target.write("[1]")
self.assertEqual(ai_gates.run_wait(), "call", "a broken file: the defaults")
with self.assertRaises(ValueError):
ai_gates.set_run_wait("soon")
def test_custom_nodes_only_by_the_ways_when_the_user_says_so(self):
ai_gates._tools_path = lambda: os.path.join(GATES, "ai_tools.json")
if os.path.exists(ai_gates._tools_path()):
os.remove(ai_gates._tools_path())
info = {"LanPaint_KSampler": {"python_module": "custom_nodes.LanPaint"}, "KJThing": {"python_module": "custom_nodes.ComfyUI-KJNodes"},
"KSampler": {"python_module": "nodes"}}
ctx = {"client": "Claude", "task": ai_tasks.begin_task("Claude", "Test")["id"]}
with mock.patch.object(mcp_canvas_edit, "object_info", return_value=info):
self.assertEqual(ai_gates.node_rule(), "any")
mcp_canvas_edit._allowed(ctx, ["KJThing"])
ai_gates.set_node_rule("ways")
with self.assertRaisesRegex(ToolError, "only by the ready-made ways"):
mcp_canvas_edit._allowed(ctx, ["KJThing"])
mcp_canvas_edit._allowed(ctx, ["LanPaint_KSampler", "KSampler"]) # a way's node, ComfyUI's own
with self.assertRaises(ValueError):
ai_gates.set_node_rule("some")
def test_auto_and_never(self):
self.assertIsNone(self.call("set_prompt", {"prompt": "x"})[1])
self.assertIsNone(self.call("search_models", {})[1], "reads have no gate")
ai_gates.set_policy("canvas", "never")
ref, verdict = self.call("set_prompt", {"prompt": "x"})
self.assertTrue(verdict["isError"])
self.assertEqual(ai_tasks.get_task(ref[0])["steps"][-1]["state"], "error")
def test_ask_and_allow(self):
ai_gates.set_policy("gpu", "ask")
ref = ai_tasks.begin_step("Claude", "run_workflow", {"batch": 2}, time.time(), False)
self.answer_soon(ref, "allow")
self.assertIsNone(ai_gates.check("run_workflow", {"batch": 2}, ref))
step = ai_tasks.get_task(ref[0])["steps"][0]
self.assertEqual((step["state"], step["gate"]["decision"]), ("running", "allow"))
self.assertEqual(step["gate"]["lines"], [{"label": "batch", "value": "2"}])
def test_a_new_route_gets_one_reminder_or_waits_for_an_ok(self):
tools = ai_gates._tools_path()
ai_gates._tools_path = lambda: os.path.join(GATES, "ai_tools.json")
self.addCleanup(setattr, ai_gates, "_tools_path", lambda: tools)
if os.path.exists(ai_gates._tools_path()):
os.remove(ai_gates._tools_path())
self.assertEqual(ai_gates.route_rule(), "allow")
ref, verdict = self.call("add_node", {"type": "ImageScale"})
self.assertIsNone(verdict)
self.call("add_node", {"type": "ImageInvert"})
self.call("add_nodes", {"use": "upscale"})
notes = [step for step in ai_tasks.get_task(ref[0])["steps"] if step["tool"] == "new_route"]
self.assertEqual(len(notes), 1, "one reminder a task, for what the AI places itself")
self.assertIn("存成卡", notes[0]["text"])
ai_gates.set_route_rule("ask")
self.assertIsNone(self.call("add_nodes", {"use": "upscale"})[1], "a way is no new route")
ref = ai_tasks.begin_step("Claude", "add_nodes", {"nodes": {"a": {}}}, time.time(), False)
self.answer_soon(ref, "allow")
self.assertIsNone(ai_gates.check("add_nodes", {"nodes": {"a": {}}}, ref))
self.assertEqual(ai_tasks.get_task(ref[0])["steps"][-1]["gate"]["gate"], "route")
self.assertIsNone(self.call("add_node", {"type": "ImageBlur"})[1], "the OK holds for the task")
def test_ask_and_no_with_words(self):
ai_gates.set_policy("download", "ask")
ref = ai_tasks.begin_step("Claude", "download_model", {"type": "loras", "url": "u"}, time.time(), False)
self.answer_soon(ref, "deny", "use the one I have")
verdict = ai_gates.check("download_model", {"type": "loras", "url": "u"}, ref)
self.assertTrue(verdict["isError"])
self.assertIn("use the one I have", verdict["content"][0]["text"])
def test_one_allow_cannot_authorize_another_call(self):
ai_gates.set_policy("gpu", "ask")
ref = ai_tasks.begin_step("Claude", "run_workflow", {}, time.time())
self.answer_soon(ref, "allow")
self.assertIsNone(ai_gates.check("run_workflow", {}, ref))
with mock.patch.object(ai_gates, "WAIT_SECONDS", 0):
_, verdict = self.call("run_workflow", {})
self.assertTrue(verdict["structuredContent"]["waiting_for_user"])
def test_late_allow_is_consumed_only_once_even_after_retry(self):
ai_gates.set_policy("gpu", "ask")
with mock.patch.object(ai_gates, "WAIT_SECONDS", 0):
ref, _ = self.call("run_workflow", {})
ai_gates.decide(*ref, "allow")
self.assertIsNone(self.call("run_workflow", {})[1])
self.assertTrue(self.call("run_workflow", {})[1]["structuredContent"]["waiting_for_user"])
def test_ask_fails_closed_without_its_record(self):
ai_gates.set_policy("gpu", "ask")
for ref in (None, ("missing", "missing")):
self.assertTrue(ai_gates.check("run_workflow", {}, ref)["isError"])
def test_late_decision_survives_tool_completion_race(self):
ai_gates.set_policy("gpu", "ask")
for decision, expected in (("allow", "allowed"), ("deny", "error")):
arguments = {"batch": 1 if decision == "allow" else 2}
ref = ai_tasks.begin_step("Claude", "run_workflow", arguments, time.time())
with mock.patch.object(ai_gates, "WAIT_SECONDS", 0):
verdict = ai_gates.check("run_workflow", arguments, ref)
ai_gates.decide(*ref, decision)
ai_tasks.finish_step("Claude", "run_workflow", {}, verdict, time.time(), step_ref=ref)
step = ai_tasks.get_task(ref[0])["steps"][-1]
self.assertEqual(step["state"], expected)
self.assertEqual(step["ok"], decision == "allow")
def test_restart_makes_pending_gate_answer_deliverable(self):
ai_gates.set_policy("gpu", "ask")
ref = ai_tasks.begin_step("Claude", "run_workflow", {}, time.time())
task = ai_tasks._load()[ref[0]]
task["steps"][-1].update(state="asking", gate={"gate": "gpu", "signature": ai_gates._signature("run_workflow", {})})
ai_tasks._save(task)
ai_tasks._tasks = None # the call waiting on the old process is gone
ai_gates.decide(*ref, "allow")
self.assertEqual(ai_tasks.get_task(ref[0])["steps"][-1]["state"], "allowed")
self.assertEqual(ai_panels.get_answers("Claude", task_id=ref[0])["messages"][0]["kind"], "gate")
def test_late_decision_wakes_a_waiting_answer_reader(self):
ai_gates.set_policy("gpu", "ask")
with mock.patch.object(ai_gates, "WAIT_SECONDS", 0):
ref, _ = self.call("run_workflow", {})
waiting = threading.Event()
original = ai_panels._answered.wait
def wait(timeout=None):
waiting.set()
return original(timeout)
answers = []
with mock.patch.object(ai_panels._answered, "wait", side_effect=wait):
reader = threading.Thread(target=lambda: answers.append(ai_panels.get_answers("Claude", task_id=ref[0], wait_seconds=3)))
reader.start()
self.assertTrue(waiting.wait(1))
ai_gates.decide(*ref, "allow")
reader.join(1)
woke_promptly = not reader.is_alive()
reader.join(3)
self.assertTrue(woke_promptly, "a decision should wake get_answers, not wait out its timeout")
self.assertEqual(answers[0]["messages"][0]["kind"], "gate")
def test_an_answer_after_the_ai_stopped_waiting(self):
ai_gates.set_policy("gpu", "ask")
ai_gates.WAIT_SECONDS, wait = 0.05, ai_gates.WAIT_SECONDS
try:
ref, verdict = self.call("run_workflow", {"batch": 1})
finally:
ai_gates.WAIT_SECONDS = wait
self.assertTrue(verdict["structuredContent"]["waiting_for_user"])
self.assertFalse(verdict["isError"])
self.assertEqual(ai_tasks.get_task(ref[0])["steps"][0]["state"], "asking")
self.assertEqual(ai_tasks.list_tasks()[0]["asks"], 1, "the task asks the user")
ai_gates.decide(ref[0], ref[1], "allow", remember=True)
self.assertEqual(ai_tasks.get_task(ref[0])["steps"][0]["state"], "allowed")
self.assertEqual(ai_gates.policies()["gpu"], "auto", "remembered: no more asking")
message = ai_panels.attach_pending(ok({}), "Claude", task_id=ref[0])["structuredContent"]["user_messages"][0]
self.assertEqual(message["kind"], "gate")
self.assertIn("call it again", message["text"])
ai_gates.set_policy("gpu", "ask")
again, verdict = self.call("run_workflow", {"batch": 1})
self.assertIsNone(verdict, "the same call goes ahead without asking twice")
steps = ai_tasks.get_task(ref[0])["steps"]
self.assertEqual(steps[0]["state"], "done")
with self.assertRaises(ValueError):
ai_gates.decide(ref[0], ref[1], "allow")
if __name__ == "__main__":
unittest.main()