161 lines
5.3 KiB
Python
161 lines
5.3 KiB
Python
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
from datetime import datetime
|
||
|
|
|
||
|
|
import pytest
|
||
|
|
|
||
|
|
from yuxi.channels.thread_binding_manager import (
|
||
|
|
BindingType,
|
||
|
|
ThreadBinding,
|
||
|
|
ThreadBindingManager,
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestBindingType:
|
||
|
|
def test_agent(self):
|
||
|
|
assert BindingType.AGENT == "agent"
|
||
|
|
|
||
|
|
def test_subagent(self):
|
||
|
|
assert BindingType.SUBAGENT == "subagent"
|
||
|
|
|
||
|
|
def test_acp(self):
|
||
|
|
assert BindingType.ACP == "acp"
|
||
|
|
|
||
|
|
|
||
|
|
class TestThreadBinding:
|
||
|
|
def test_creation(self):
|
||
|
|
binding = ThreadBinding(
|
||
|
|
thread_id="thread_1",
|
||
|
|
binding_type=BindingType.AGENT,
|
||
|
|
target_id="agent_1",
|
||
|
|
)
|
||
|
|
assert binding.thread_id == "thread_1"
|
||
|
|
assert binding.binding_type == BindingType.AGENT
|
||
|
|
assert binding.target_id == "agent_1"
|
||
|
|
assert binding.expires_at is None
|
||
|
|
assert binding.is_expired is False
|
||
|
|
|
||
|
|
def test_not_expired_without_expiry(self):
|
||
|
|
binding = ThreadBinding(
|
||
|
|
thread_id="t1",
|
||
|
|
binding_type=BindingType.AGENT,
|
||
|
|
target_id="a1",
|
||
|
|
)
|
||
|
|
assert binding.is_expired is False
|
||
|
|
|
||
|
|
def test_expired(self):
|
||
|
|
from datetime import timedelta
|
||
|
|
|
||
|
|
from yuxi.utils.datetime_utils import utc_now_naive
|
||
|
|
|
||
|
|
binding = ThreadBinding(
|
||
|
|
thread_id="t1",
|
||
|
|
binding_type=BindingType.AGENT,
|
||
|
|
target_id="a1",
|
||
|
|
expires_at=utc_now_naive() - timedelta(hours=1),
|
||
|
|
)
|
||
|
|
assert binding.is_expired is True
|
||
|
|
|
||
|
|
def test_default_metadata(self):
|
||
|
|
binding = ThreadBinding(
|
||
|
|
thread_id="t1",
|
||
|
|
binding_type=BindingType.AGENT,
|
||
|
|
target_id="a1",
|
||
|
|
)
|
||
|
|
assert binding.metadata == {}
|
||
|
|
|
||
|
|
|
||
|
|
class TestThreadBindingManager:
|
||
|
|
def test_bind_success(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
binding = mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
assert binding.thread_id == "thread_1"
|
||
|
|
assert binding.target_id == "agent_1"
|
||
|
|
|
||
|
|
def test_bind_and_get(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
result = mgr.get_binding("thread_1", BindingType.AGENT)
|
||
|
|
assert result is not None
|
||
|
|
assert result.target_id == "agent_1"
|
||
|
|
|
||
|
|
def test_get_nonexistent(self):
|
||
|
|
mgr = ThreadBindingManager()
|
||
|
|
result = mgr.get_binding("nonexistent", BindingType.AGENT)
|
||
|
|
assert result is None
|
||
|
|
|
||
|
|
def test_unbind(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
assert mgr.unbind("thread_1", BindingType.AGENT) is True
|
||
|
|
assert mgr.get_binding("thread_1", BindingType.AGENT) is None
|
||
|
|
|
||
|
|
def test_unbind_nonexistent(self):
|
||
|
|
mgr = ThreadBindingManager()
|
||
|
|
assert mgr.unbind("nonexistent", BindingType.AGENT) is False
|
||
|
|
|
||
|
|
def test_list_bindings(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
mgr.bind("thread_1", BindingType.SUBAGENT, "sub_1")
|
||
|
|
mgr.bind("thread_2", BindingType.AGENT, "agent_2")
|
||
|
|
|
||
|
|
all_bindings = mgr.list_bindings()
|
||
|
|
assert len(all_bindings) == 3
|
||
|
|
|
||
|
|
thread1_bindings = mgr.list_bindings(thread_id="thread_1")
|
||
|
|
assert len(thread1_bindings) == 2
|
||
|
|
|
||
|
|
agent_bindings = mgr.list_bindings(binding_type=BindingType.AGENT)
|
||
|
|
assert len(agent_bindings) == 2
|
||
|
|
|
||
|
|
def test_bind_with_ttl(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
binding = mgr.bind("thread_1", BindingType.AGENT, "agent_1", ttl_hours=24)
|
||
|
|
assert binding.expires_at is not None
|
||
|
|
|
||
|
|
def test_bind_with_default_ttl(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=24)
|
||
|
|
binding = mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
assert binding.expires_at is not None
|
||
|
|
|
||
|
|
def test_bind_with_metadata(self):
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
binding = mgr.bind("thread_1", BindingType.AGENT, "agent_1", metadata={"key": "val"})
|
||
|
|
assert binding.metadata["key"] == "val"
|
||
|
|
|
||
|
|
def test_listener_notification(self):
|
||
|
|
events: list[tuple[str, str]] = []
|
||
|
|
|
||
|
|
def listener(event: str, binding: ThreadBinding):
|
||
|
|
events.append((event, binding.thread_id))
|
||
|
|
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.add_listener(listener)
|
||
|
|
mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
mgr.unbind("thread_1", BindingType.AGENT)
|
||
|
|
|
||
|
|
assert len(events) == 2
|
||
|
|
assert events[0][0] == "bind"
|
||
|
|
assert events[1][0] == "unbind"
|
||
|
|
|
||
|
|
def test_remove_listener(self):
|
||
|
|
events: list[tuple[str, str]] = []
|
||
|
|
|
||
|
|
def listener(event: str, binding: ThreadBinding):
|
||
|
|
events.append((event, binding.thread_id))
|
||
|
|
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.add_listener(listener)
|
||
|
|
mgr.remove_listener(listener)
|
||
|
|
mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
assert len(events) == 0
|
||
|
|
|
||
|
|
def test_listener_exception_does_not_crash(self):
|
||
|
|
def faulty_listener(event: str, binding: ThreadBinding):
|
||
|
|
raise RuntimeError("test error")
|
||
|
|
|
||
|
|
mgr = ThreadBindingManager(default_ttl_hours=None)
|
||
|
|
mgr.add_listener(faulty_listener)
|
||
|
|
binding = mgr.bind("thread_1", BindingType.AGENT, "agent_1")
|
||
|
|
assert binding is not None
|