Files
wehub-resource-sync c3bf08ac8d
K8s Workspace Integration Tests / k8s-workspace-tests (push) Has been cancelled
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Python Unittest Coverage / test (macos-15, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (ubuntu-latest, 3.11) (push) Has been cancelled
Python Unittest Coverage / test (windows-latest, 3.11) (push) Has been cancelled
Web UI / check (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:39:27 +08:00

1111 lines
40 KiB
Python

# -*- coding: utf-8 -*-
# pylint: disable=protected-access
"""Unit tests for RedisStorage using fakeredis."""
from unittest.async_case import IsolatedAsyncioTestCase
import fakeredis.aioredis
from agentscope.app.storage import (
RedisStorage,
AgentRecord,
SessionConfig,
SessionRecord,
ChatModelConfig,
ScheduleRecord,
ScheduleData,
SessionSource,
TeamData,
TeamRecord,
)
from agentscope.credential import OllamaCredential
from agentscope.app.storage import AgentData
from agentscope.agent import ContextConfig, ReActConfig
from agentscope.message import UserMsg, AssistantMsg, TextBlock
from agentscope.state import AgentState
def make_storage() -> RedisStorage:
"""Create a RedisStorage instance backed by fakeredis."""
storage = RedisStorage.__new__(RedisStorage)
# pylint: disable=protected-access
storage._client = fakeredis.aioredis.FakeRedis(decode_responses=True)
storage.key_ttl = None
storage.key_config = RedisStorage.KeyConfig()
return storage
def make_agent_record(user_id: str) -> AgentRecord:
"""Create a test AgentRecord with all-default sub-configs."""
return AgentRecord(
user_id=user_id,
data=AgentData(
id="agent-data-id",
name="test-agent",
system_prompt="You are a helpful assistant.",
context_config=ContextConfig(),
react_config=ReActConfig(),
),
)
def make_session_config(workspace_id: str = "ws-1") -> SessionConfig:
"""Create a test SessionConfig with a chat model config."""
return SessionConfig(
workspace_id=workspace_id,
chat_model_config=ChatModelConfig(
type="openai",
credential_id="cred-1",
model="gpt-4",
parameters={},
),
)
class TestCredential(IsolatedAsyncioTestCase):
"""Tests for credential CRUD and cascading operations."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
async def test_create(self) -> None:
"""Create a credential and verify it is retrievable via list."""
cred_id = await self.storage.upsert_credential(
self.user_id,
OllamaCredential(host="http://localhost:11434"),
)
records = await self.storage.list_credentials(self.user_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].id, cred_id)
self.assertEqual(records[0].data.get("type"), "ollama_credential")
self.assertEqual(
records[0].data.get("host"),
"http://localhost:11434",
)
async def test_list_empty(self) -> None:
"""Verify list returns empty when no records exist."""
records = await self.storage.list_credentials(self.user_id)
self.assertEqual(records, [])
async def test_update_in_place(self) -> None:
"""Update a credential and verify data changed without adding
a new record."""
cred_id = await self.storage.upsert_credential(
self.user_id,
OllamaCredential(host="http://old-host:11434"),
)
await self.storage.upsert_credential(
self.user_id,
OllamaCredential(id=cred_id, host="http://new-host:11434"),
)
records = await self.storage.list_credentials(self.user_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].data.get("host"), "http://new-host:11434")
async def test_delete(self) -> None:
"""Delete a credential and verify it is gone from Redis."""
cred_id = await self.storage.upsert_credential(
self.user_id,
OllamaCredential(host="http://localhost:11434"),
)
result = await self.storage.delete_credential(self.user_id, cred_id)
self.assertTrue(result)
records = await self.storage.list_credentials(self.user_id)
self.assertEqual(records, [])
async def test_delete_nonexistent(self) -> None:
"""Verify delete returns False for non-existent record."""
result = await self.storage.delete_credential(
self.user_id,
"no-such-id",
)
self.assertFalse(result)
async def test_user_isolation(self) -> None:
"""Verify different users cannot see each other's records."""
await self.storage.upsert_credential(
"user-A",
OllamaCredential(host="http://localhost:11434"),
)
records = await self.storage.list_credentials("user-B")
self.assertEqual(records, [])
class TestAgent(IsolatedAsyncioTestCase):
"""Tests for agent CRUD and cascading operations."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
async def test_create(self) -> None:
"""Create an agent and verify it is retrievable via list."""
record = make_agent_record(self.user_id)
agent_id = await self.storage.upsert_agent(self.user_id, record)
records = await self.storage.list_agents(self.user_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].id, agent_id)
self.assertEqual(records[0].data.name, "test-agent")
async def test_list_empty(self) -> None:
"""Verify list returns empty when no records exist."""
records = await self.storage.list_agents(self.user_id)
self.assertEqual(records, [])
async def test_delete(self) -> None:
"""Delete an agent and verify it is gone from Redis."""
record = make_agent_record(self.user_id)
await self.storage.upsert_agent(self.user_id, record)
result = await self.storage.delete_agent(self.user_id, record.id)
self.assertTrue(result)
records = await self.storage.list_agents(self.user_id)
self.assertEqual(records, [])
async def test_delete_nonexistent(self) -> None:
"""Verify delete returns False for non-existent record."""
result = await self.storage.delete_agent(self.user_id, "no-such-id")
self.assertFalse(result)
async def test_user_isolation(self) -> None:
"""Verify different users cannot see each other's records."""
await self.storage.upsert_agent("user-A", make_agent_record("user-A"))
records = await self.storage.list_agents("user-B")
self.assertEqual(records, [])
class TestSession(IsolatedAsyncioTestCase):
"""Tests for session CRUD and cascading operations."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
self.agent_id = "agent-1"
self.workspace_id = "ws-1"
async def test_create(self) -> None:
"""Create a session and verify it is retrievable via list."""
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
)
records = await self.storage.list_sessions(self.user_id, self.agent_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].config.workspace_id, self.workspace_id)
self.assertEqual(records[0].agent_id, self.agent_id)
async def test_list_empty(self) -> None:
"""Verify list returns empty when no records exist."""
records = await self.storage.list_sessions(self.user_id, self.agent_id)
self.assertEqual(records, [])
async def test_upsert_same_triple_updates_in_place(self) -> None:
"""Second upsert with the same session_id must update the existing
record, not create a second one."""
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
)
first_id = session.id
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
session_id=first_id,
)
records_after = await self.storage.list_sessions(
self.user_id,
self.agent_id,
)
self.assertEqual(len(records_after), 1)
self.assertEqual(records_after[0].id, first_id)
async def test_create_with_explicit_session_id_uses_that_key(self) -> None:
"""A caller-provided session_id should be the stored record id."""
session_id = "session-from-router"
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
session_id=session_id,
)
self.assertEqual(session.id, session_id)
fetched = await self.storage.get_session(
self.user_id,
self.agent_id,
session_id,
)
self.assertIsNotNone(fetched)
self.assertEqual(fetched.id, session_id)
await self.storage.update_session_state(
self.user_id,
self.agent_id,
session_id,
AgentState(),
)
records = await self.storage.list_sessions(self.user_id, self.agent_id)
self.assertEqual([record.id for record in records], [session_id])
async def test_delete(self) -> None:
"""Delete a session and verify it is gone from Redis."""
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
)
records = await self.storage.list_sessions(self.user_id, self.agent_id)
result = await self.storage.delete_session(
self.user_id,
self.agent_id,
records[0].id,
)
self.assertTrue(result)
remaining = await self.storage.list_sessions(
self.user_id,
self.agent_id,
)
self.assertEqual(remaining, [])
async def test_delete_cascades_lookup_key(self) -> None:
"""Deleting a session must remove the lookup key so a subsequent upsert
for the same (user, agent) pair creates a fresh session with a new
id."""
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
)
records = await self.storage.list_sessions(self.user_id, self.agent_id)
old_id = records[0].id
await self.storage.delete_session(self.user_id, self.agent_id, old_id)
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(self.workspace_id),
)
new_records = await self.storage.list_sessions(
self.user_id,
self.agent_id,
)
self.assertEqual(len(new_records), 1)
self.assertNotEqual(new_records[0].id, old_id)
async def test_delete_nonexistent(self) -> None:
"""Verify delete returns False for non-existent record."""
result = await self.storage.delete_session(
self.user_id,
self.agent_id,
"no-such-id",
)
self.assertFalse(result)
async def test_agent_isolation(self) -> None:
"""Verify different agents cannot see each other's sessions."""
await self.storage.upsert_session(
self.user_id,
"agent-A",
make_session_config(self.workspace_id),
)
records = await self.storage.list_sessions(self.user_id, "agent-B")
self.assertEqual(records, [])
class TestMessage(IsolatedAsyncioTestCase):
"""Tests for message persistence: upsert_message, get_message and
list_messages."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
self.session_id = "session-1"
async def test_upsert_appends_new_message(self) -> None:
"""Upserting a new message appends it to the session list."""
msg = UserMsg(name="alice", content="hello")
await self.storage.upsert_message(self.user_id, self.session_id, msg)
messages = await self.storage.list_messages(
self.user_id,
self.session_id,
)
self.assertListEqual(
[m.model_dump() for m in messages],
[msg.model_dump()],
)
async def test_upsert_refreshes_message_list_ttl(self) -> None:
"""Message list keys expire with the session storage TTL."""
self.storage.key_ttl = 60
msg = UserMsg(name="alice", content="hello")
await self.storage.upsert_message(self.user_id, self.session_id, msg)
ttl = await self.storage._client.ttl(
self.storage._message_key(self.user_id, self.session_id),
)
self.assertGreater(ttl, 0)
async def test_upsert_replaces_last_message_with_same_id(self) -> None:
"""Upserting a message whose id matches the last entry replaces it
in-place (streaming overwrite), rather than creating a duplicate."""
msg = AssistantMsg(name="bot", content="v1")
await self.storage.upsert_message(self.user_id, self.session_id, msg)
# Keep the same id but replace content — simulates a streaming update.
updated = msg.model_copy(
update={"content": [TextBlock(text="v2")]},
)
await self.storage.upsert_message(
self.user_id,
self.session_id,
updated,
)
messages = await self.storage.list_messages(
self.user_id,
self.session_id,
)
self.assertListEqual(
[m.model_dump() for m in messages],
[updated.model_dump()],
"Duplicate must not be created; existing entry must be replaced.",
)
async def test_replace_refreshes_message_list_ttl(self) -> None:
"""Streaming message replacement keeps the message key expiring."""
self.storage.key_ttl = 60
msg = AssistantMsg(name="bot", content="v1")
await self.storage.upsert_message(self.user_id, self.session_id, msg)
await self.storage._client.persist(
self.storage._message_key(self.user_id, self.session_id),
)
updated = msg.model_copy(
update={"content": [TextBlock(text="v2")]},
)
await self.storage.upsert_message(
self.user_id,
self.session_id,
updated,
)
ttl = await self.storage._client.ttl(
self.storage._message_key(self.user_id, self.session_id),
)
self.assertGreater(ttl, 0)
async def test_upsert_appends_when_id_differs_from_last(self) -> None:
"""Upserting a message with a different id than the last always
appends, even if an earlier message shares the same id."""
msg1 = UserMsg(name="alice", content="first")
msg2 = UserMsg(name="alice", content="second")
await self.storage.upsert_message(self.user_id, self.session_id, msg1)
await self.storage.upsert_message(self.user_id, self.session_id, msg2)
messages = await self.storage.list_messages(
self.user_id,
self.session_id,
)
self.assertListEqual(
[m.model_dump() for m in messages],
[msg1.model_dump(), msg2.model_dump()],
)
async def test_get_message_returns_correct_message(self) -> None:
"""get_message fetches the message matching the given id."""
msg1 = UserMsg(name="alice", content="first")
msg2 = UserMsg(name="alice", content="second")
await self.storage.upsert_message(self.user_id, self.session_id, msg1)
await self.storage.upsert_message(self.user_id, self.session_id, msg2)
fetched = await self.storage.get_message(
self.user_id,
self.session_id,
msg1.id,
)
self.assertIsNotNone(fetched)
self.assertDictEqual(fetched.model_dump(), msg1.model_dump())
async def test_get_message_nonexistent_returns_none(self) -> None:
"""get_message returns None when the message id does not exist."""
result = await self.storage.get_message(
self.user_id,
self.session_id,
"no-such-id",
)
self.assertIsNone(result)
async def test_list_messages_empty_session(self) -> None:
"""list_messages returns an empty list for a session with no
messages."""
messages = await self.storage.list_messages(
self.user_id,
self.session_id,
)
self.assertListEqual(messages, [])
async def test_list_messages_pagination(self) -> None:
"""list_messages respects offset and limit parameters."""
msgs = [UserMsg(name="alice", content=f"msg-{i}") for i in range(5)]
for m in msgs:
await self.storage.upsert_message(
self.user_id,
self.session_id,
m,
)
# Fetch the middle slice: offset=1, limit=3 → msgs[1], msgs[2], msgs[3]
page = await self.storage.list_messages(
self.user_id,
self.session_id,
offset=1,
limit=3,
)
self.assertListEqual(
[m.model_dump() for m in page],
[m.model_dump() for m in msgs[1:4]],
)
async def test_list_messages_order_preserved(self) -> None:
"""Messages are returned in the insertion order (chronological)."""
msgs = [
UserMsg(name="alice", content=text)
for text in ["alpha", "beta", "gamma"]
]
for m in msgs:
await self.storage.upsert_message(
self.user_id,
self.session_id,
m,
)
messages = await self.storage.list_messages(
self.user_id,
self.session_id,
)
self.assertListEqual(
[m.model_dump() for m in messages],
[m.model_dump() for m in msgs],
)
async def test_session_isolation(self) -> None:
"""Messages belonging to different sessions do not interfere."""
await self.storage.upsert_message(
self.user_id,
"session-A",
UserMsg(name="alice", content="in A"),
)
messages = await self.storage.list_messages(
self.user_id,
"session-B",
)
self.assertListEqual(messages, [])
def make_schedule_record(user_id: str, agent_id: str) -> ScheduleRecord:
"""Create a test ScheduleRecord."""
return ScheduleRecord(
user_id=user_id,
agent_id=agent_id,
data=ScheduleData(
name="test-schedule",
cron_expression="0 9 * * *",
started_at="2026-01-01T00:00:00",
chat_model_config=ChatModelConfig(
type="openai",
credential_id="cred-1",
model="gpt-4",
parameters={},
),
),
)
class TestScheduleSession(IsolatedAsyncioTestCase):
"""Tests for schedule-session index and cascade deletion."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
self.agent_id = "agent-1"
async def test_list_sessions_by_schedule(self) -> None:
"""Sessions created with source_schedule_id are queryable by
schedule."""
schedule = make_schedule_record(self.user_id, self.agent_id)
await self.storage.upsert_schedule(self.user_id, schedule)
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
source=SessionSource.SCHEDULE,
source_schedule_id=schedule.id,
)
results = await self.storage.list_sessions_by_schedule(
self.user_id,
schedule.id,
)
self.assertEqual(len(results), 1)
self.assertEqual(results[0].id, session.id)
self.assertEqual(results[0].source_schedule_id, schedule.id)
async def test_list_sessions_by_schedule_empty(self) -> None:
"""Returns empty list when no sessions exist for a schedule."""
results = await self.storage.list_sessions_by_schedule(
self.user_id,
"nonexistent-schedule",
)
self.assertEqual(results, [])
async def test_schedule_session_also_in_agent_index(self) -> None:
"""A schedule-created session appears in both the schedule and agent
session indexes."""
schedule = make_schedule_record(self.user_id, self.agent_id)
await self.storage.upsert_schedule(self.user_id, schedule)
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
source=SessionSource.SCHEDULE,
source_schedule_id=schedule.id,
)
agent_sessions = await self.storage.list_sessions(
self.user_id,
self.agent_id,
)
self.assertEqual(len(agent_sessions), 1)
self.assertEqual(agent_sessions[0].id, session.id)
async def test_delete_schedule_cascades_sessions(self) -> None:
"""Deleting a schedule removes all its execution sessions."""
schedule = make_schedule_record(self.user_id, self.agent_id)
await self.storage.upsert_schedule(self.user_id, schedule)
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
source=SessionSource.SCHEDULE,
source_schedule_id=schedule.id,
)
await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
source=SessionSource.SCHEDULE,
source_schedule_id=schedule.id,
)
await self.storage.delete_schedule(self.user_id, schedule.id)
schedule_sessions = await self.storage.list_sessions_by_schedule(
self.user_id,
schedule.id,
)
self.assertEqual(schedule_sessions, [])
agent_sessions = await self.storage.list_sessions(
self.user_id,
self.agent_id,
)
self.assertEqual(agent_sessions, [])
async def test_delete_session_cleans_schedule_index(self) -> None:
"""Deleting a session removes it from the schedule session index."""
schedule = make_schedule_record(self.user_id, self.agent_id)
await self.storage.upsert_schedule(self.user_id, schedule)
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
source=SessionSource.SCHEDULE,
source_schedule_id=schedule.id,
)
await self.storage.delete_session(
self.user_id,
self.agent_id,
session.id,
)
results = await self.storage.list_sessions_by_schedule(
self.user_id,
schedule.id,
)
self.assertEqual(results, [])
def make_team_record(
user_id: str,
session_id: str = "leader-session-1",
name: str = "test-team",
member_ids: list[str] | None = None,
) -> TeamRecord:
"""Create a test TeamRecord."""
return TeamRecord(
user_id=user_id,
session_id=session_id,
data=TeamData(
name=name,
member_ids=member_ids if member_ids is not None else [],
),
)
class TestAgentSource(IsolatedAsyncioTestCase):
"""Tests for the ``source`` field on AgentRecord."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
async def test_default_source_is_user(self) -> None:
"""An AgentRecord built without ``source`` defaults to ``"user"``."""
record = make_agent_record(self.user_id)
self.assertEqual(record.source, "user")
await self.storage.upsert_agent(self.user_id, record)
loaded = await self.storage.get_agent(self.user_id, record.id)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.source, "user")
async def test_team_source_round_trips(self) -> None:
"""A worker AgentRecord persists ``source='team'`` through Redis."""
record = AgentRecord(
user_id=self.user_id,
source="team",
data=AgentData(
id="worker-1",
name="worker",
system_prompt="You are a team worker.",
context_config=ContextConfig(),
react_config=ReActConfig(),
),
)
await self.storage.upsert_agent(self.user_id, record)
loaded = await self.storage.get_agent(self.user_id, record.id)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.source, "team")
async def test_list_agents_filters_out_team_workers(self) -> None:
"""``storage.list_agents`` returns only ``source='user'`` agents.
Workers (``source='team'``) are scoped to a team and addressable
only via direct id lookup."""
user_agent = make_agent_record(self.user_id)
worker = AgentRecord(
id="worker-1",
user_id=self.user_id,
source="team",
data=AgentData(
id="data-w",
name="worker",
system_prompt="",
context_config=ContextConfig(),
react_config=ReActConfig(),
),
)
await self.storage.upsert_agent(self.user_id, user_agent)
await self.storage.upsert_agent(self.user_id, worker)
listed = await self.storage.list_agents(self.user_id)
listed_ids = {a.id for a in listed}
self.assertIn(user_agent.id, listed_ids)
self.assertNotIn(worker.id, listed_ids)
# But direct lookup still works for the worker.
loaded_worker = await self.storage.get_agent(
self.user_id,
worker.id,
)
self.assertIsNotNone(loaded_worker)
self.assertEqual(loaded_worker.source, "team")
async def test_legacy_record_without_source_deserializes(self) -> None:
"""JSON written before the ``source`` field was added still loads
and falls back to the default ``"user"``."""
legacy_record = make_agent_record(self.user_id)
legacy_json = legacy_record.model_dump_json()
# Strip the new field to simulate pre-migration data.
import json
payload = json.loads(legacy_json)
payload.pop("source", None)
# pylint: disable=protected-access
key = self.storage._key(
self.storage.key_config.agent,
user_id=self.user_id,
agent_id=legacy_record.id,
)
await self.storage._client.set(key, json.dumps(payload))
await self.storage._client.sadd(
self.storage._key(
self.storage.key_config.agent_index,
user_id=self.user_id,
),
legacy_record.id,
)
loaded = await self.storage.get_agent(self.user_id, legacy_record.id)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.source, "user")
class TestSessionTeamId(IsolatedAsyncioTestCase):
"""Tests for the ``team_id`` field on SessionRecord."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
self.agent_id = "agent-1"
async def test_default_team_id_is_none(self) -> None:
"""A SessionRecord built without ``team_id`` defaults to ``None``."""
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
)
self.assertIsNone(session.team_id)
loaded = await self.storage.get_session(
self.user_id,
self.agent_id,
session.id,
)
self.assertIsNotNone(loaded)
self.assertIsNone(loaded.team_id)
async def test_legacy_session_without_team_id_deserializes(self) -> None:
"""JSON written before ``team_id`` existed still loads with default
``None``."""
session = await self.storage.upsert_session(
self.user_id,
self.agent_id,
make_session_config(),
)
# Strip the new field from the persisted JSON to simulate pre-migration
# data, then write it back at the same key.
import json
# pylint: disable=protected-access
key = self.storage._key(
self.storage.key_config.session,
user_id=self.user_id,
session_id=session.id,
)
raw = await self.storage._client.get(key)
payload = json.loads(raw)
payload.pop("team_id", None)
await self.storage._client.set(key, json.dumps(payload))
loaded = await self.storage.get_session(
self.user_id,
self.agent_id,
session.id,
)
self.assertIsNotNone(loaded)
self.assertIsNone(loaded.team_id)
class TestTeam(IsolatedAsyncioTestCase):
"""Tests for team CRUD."""
async def asyncSetUp(self) -> None:
"""Set up test fixtures."""
self.storage = make_storage()
self.user_id = "user-1"
async def test_create(self) -> None:
"""Create a team and verify it is retrievable via list."""
record = make_team_record(self.user_id)
stored = await self.storage.upsert_team(self.user_id, record)
records = await self.storage.list_teams(self.user_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].id, stored.id)
self.assertEqual(records[0].session_id, "leader-session-1")
self.assertEqual(records[0].data.name, "test-team")
self.assertEqual(records[0].data.member_ids, [])
async def test_list_empty(self) -> None:
"""Verify list returns empty when no teams exist."""
records = await self.storage.list_teams(self.user_id)
self.assertEqual(records, [])
async def test_get_returns_record(self) -> None:
"""get_team returns the persisted record by id."""
record = make_team_record(
self.user_id,
member_ids=["worker-a", "worker-b"],
)
await self.storage.upsert_team(self.user_id, record)
loaded = await self.storage.get_team(self.user_id, record.id)
self.assertIsNotNone(loaded)
self.assertEqual(loaded.id, record.id)
self.assertEqual(loaded.data.member_ids, ["worker-a", "worker-b"])
async def test_get_nonexistent_returns_none(self) -> None:
"""get_team returns None when the id does not exist."""
loaded = await self.storage.get_team(self.user_id, "no-such-id")
self.assertIsNone(loaded)
async def test_update_in_place(self) -> None:
"""Upsert with the same id overwrites the existing record."""
record = make_team_record(self.user_id, name="original")
await self.storage.upsert_team(self.user_id, record)
record.data.name = "renamed"
record.data.member_ids = ["new-worker"]
await self.storage.upsert_team(self.user_id, record)
records = await self.storage.list_teams(self.user_id)
self.assertEqual(len(records), 1)
self.assertEqual(records[0].data.name, "renamed")
self.assertEqual(records[0].data.member_ids, ["new-worker"])
async def test_upsert_refreshes_updated_at(self) -> None:
"""upsert_team bumps ``updated_at`` on each write."""
record = make_team_record(self.user_id)
first = await self.storage.upsert_team(self.user_id, record)
first_updated = first.updated_at
# Second write must have a strictly non-decreasing updated_at.
second = await self.storage.upsert_team(self.user_id, record)
self.assertGreaterEqual(second.updated_at, first_updated)
async def test_delete(self) -> None:
"""Delete a team and verify it is gone from Redis and the index."""
record = make_team_record(self.user_id)
await self.storage.upsert_team(self.user_id, record)
result = await self.storage.delete_team(self.user_id, record.id)
self.assertTrue(result)
loaded = await self.storage.get_team(self.user_id, record.id)
self.assertIsNone(loaded)
self.assertEqual(await self.storage.list_teams(self.user_id), [])
async def test_delete_nonexistent(self) -> None:
"""delete_team returns False for an unknown id."""
result = await self.storage.delete_team(self.user_id, "no-such-id")
self.assertFalse(result)
async def test_user_isolation(self) -> None:
"""Teams from one user are invisible to another."""
await self.storage.upsert_team(
"user-A",
make_team_record("user-A"),
)
records = await self.storage.list_teams("user-B")
self.assertEqual(records, [])
def make_worker_agent(user_id: str, agent_id: str) -> AgentRecord:
"""Create a source='team' worker AgentRecord."""
return AgentRecord(
id=agent_id,
user_id=user_id,
source="team",
data=AgentData(
id=f"data-{agent_id}",
name=f"name-{agent_id}",
system_prompt="worker",
context_config=ContextConfig(),
react_config=ReActConfig(),
),
)
class TestTeamCascade(IsolatedAsyncioTestCase):
"""End-to-end cascade tests for the team relationship graph."""
async def asyncSetUp(self) -> None:
"""Build a small team fixture: 1 leader user-agent + 2 workers."""
self.storage = make_storage()
self.user_id = "user-1"
# Leader agent (user-created) and its session
self.leader_agent = make_agent_record(self.user_id)
await self.storage.upsert_agent(self.user_id, self.leader_agent)
leader_session = await self.storage.upsert_session(
self.user_id,
self.leader_agent.id,
make_session_config(),
)
self.leader_session_id = leader_session.id
# Two worker agents and their sessions
self.worker_a = make_worker_agent(self.user_id, "worker-a")
self.worker_b = make_worker_agent(self.user_id, "worker-b")
await self.storage.upsert_agent(self.user_id, self.worker_a)
await self.storage.upsert_agent(self.user_id, self.worker_b)
worker_a_session = await self.storage.upsert_session(
self.user_id,
self.worker_a.id,
make_session_config(),
)
worker_b_session = await self.storage.upsert_session(
self.user_id,
self.worker_b.id,
make_session_config(),
)
self.worker_a_session_id = worker_a_session.id
self.worker_b_session_id = worker_b_session.id
# The team itself + leader's team_id back-reference
self.team = make_team_record(
self.user_id,
session_id=self.leader_session_id,
member_ids=[self.worker_a.id, self.worker_b.id],
)
await self.storage.upsert_team(self.user_id, self.team)
# Stamp team_id on every team-participating session
# pylint: disable=protected-access
for sid in [
self.leader_session_id,
self.worker_a_session_id,
self.worker_b_session_id,
]:
session_key = self.storage._key(
self.storage.key_config.session,
user_id=self.user_id,
session_id=sid,
)
raw = await self.storage._client.get(session_key)
rec = SessionRecord.model_validate_json(raw)
rec.team_id = self.team.id
await self.storage._client.set(session_key, rec.model_dump_json())
async def test_delete_team_cascades_workers_and_clears_leader(
self,
) -> None:
"""delete_team removes all workers + sessions + clears leader."""
result = await self.storage.delete_team(self.user_id, self.team.id)
self.assertTrue(result)
# Team record gone
self.assertIsNone(
await self.storage.get_team(self.user_id, self.team.id),
)
# Worker agents gone
self.assertIsNone(
await self.storage.get_agent(self.user_id, self.worker_a.id),
)
self.assertIsNone(
await self.storage.get_agent(self.user_id, self.worker_b.id),
)
# Worker sessions gone
self.assertIsNone(
await self.storage.get_session(
self.user_id,
self.worker_a.id,
self.worker_a_session_id,
),
)
# Leader session still exists, team_id cleared
leader = await self.storage.get_session(
self.user_id,
self.leader_agent.id,
self.leader_session_id,
)
self.assertIsNotNone(leader)
self.assertIsNone(leader.team_id)
async def test_delete_leader_session_dissolves_team(self) -> None:
"""Deleting a leader session auto-dissolves its team."""
await self.storage.delete_session(
self.user_id,
self.leader_agent.id,
self.leader_session_id,
)
# Team gone
self.assertIsNone(
await self.storage.get_team(self.user_id, self.team.id),
)
# Workers gone
self.assertIsNone(
await self.storage.get_agent(self.user_id, self.worker_a.id),
)
# Leader agent itself still exists (only the session was deleted)
self.assertIsNotNone(
await self.storage.get_agent(self.user_id, self.leader_agent.id),
)
async def test_delete_leader_agent_dissolves_all_its_teams(self) -> None:
"""Deleting the leader agent dissolves every team it leads."""
await self.storage.delete_agent(self.user_id, self.leader_agent.id)
self.assertIsNone(
await self.storage.get_team(self.user_id, self.team.id),
)
self.assertIsNone(
await self.storage.get_agent(self.user_id, self.worker_a.id),
)
self.assertIsNone(
await self.storage.get_agent(self.user_id, self.leader_agent.id),
)
async def test_direct_delete_worker_agent_scrubs_member_ids(self) -> None:
"""Bypassing delete_team still keeps team.member_ids consistent."""
await self.storage.delete_agent(self.user_id, self.worker_a.id)
team = await self.storage.get_team(self.user_id, self.team.id)
self.assertIsNotNone(team)
self.assertEqual(team.data.member_ids, [self.worker_b.id])
# The other worker is untouched
self.assertIsNotNone(
await self.storage.get_agent(self.user_id, self.worker_b.id),
)
async def test_direct_delete_worker_session_does_not_dissolve_team(
self,
) -> None:
"""Deleting a worker's session leaves the team intact (asymmetric
with leader-session deletion — there's no FK from session to its
owning agent)."""
await self.storage.delete_session(
self.user_id,
self.worker_a.id,
self.worker_a_session_id,
)
# Team and worker agent still exist
self.assertIsNotNone(
await self.storage.get_team(self.user_id, self.team.id),
)
self.assertIsNotNone(
await self.storage.get_agent(self.user_id, self.worker_a.id),
)
# The session is gone though
self.assertIsNone(
await self.storage.get_session(
self.user_id,
self.worker_a.id,
self.worker_a_session_id,
),
)
async def test_delete_team_idempotent_on_nonexistent(self) -> None:
"""delete_team on a missing team returns False without crashing."""
result = await self.storage.delete_team(self.user_id, "no-such-id")
self.assertFalse(result)