"""Tests for AgentManager persistent agent lifecycle.""" from __future__ import annotations import tempfile from pathlib import Path import pytest @pytest.fixture def manager(): """Create an AgentManager with a temp database.""" from openjarvis.agents.manager import AgentManager with tempfile.TemporaryDirectory() as tmpdir: db_path = Path(tmpdir) / "agents.db" mgr = AgentManager(db_path=str(db_path)) yield mgr mgr.close() class TestAgentCRUD: def test_create_agent(self, manager): agent = manager.create_agent( name="researcher", agent_type="monitor_operative", config={ "tools": ["web_search"], "schedule_type": "cron", "schedule_value": "0 9 * * *", }, ) assert agent["id"] assert agent["name"] == "researcher" assert agent["agent_type"] == "monitor_operative" assert agent["status"] == "idle" def test_list_agents(self, manager): manager.create_agent(name="agent1", agent_type="simple") manager.create_agent(name="agent2", agent_type="orchestrator") agents = manager.list_agents() assert len(agents) == 2 names = {a["name"] for a in agents} assert names == {"agent1", "agent2"} def test_get_agent(self, manager): created = manager.create_agent(name="test", agent_type="simple") fetched = manager.get_agent(created["id"]) assert fetched is not None assert fetched["name"] == "test" def test_get_agent_not_found(self, manager): assert manager.get_agent("nonexistent") is None def test_update_agent(self, manager): created = manager.create_agent(name="old", agent_type="simple") updated = manager.update_agent(created["id"], name="new") assert updated["name"] == "new" def test_delete_agent_soft(self, manager): created = manager.create_agent(name="doomed", agent_type="simple") manager.delete_agent(created["id"]) agent = manager.get_agent(created["id"]) assert agent["status"] == "archived" def test_pause_resume(self, manager): created = manager.create_agent(name="pausable", agent_type="simple") manager.pause_agent(created["id"]) assert manager.get_agent(created["id"])["status"] == "paused" manager.resume_agent(created["id"]) assert manager.get_agent(created["id"])["status"] == "idle" class TestTaskCRUD: def test_create_task(self, manager): agent = manager.create_agent(name="worker", agent_type="simple") task = manager.create_task(agent["id"], description="Find papers on reasoning") assert task["id"] assert task["description"] == "Find papers on reasoning" assert task["status"] == "pending" def test_list_tasks(self, manager): agent = manager.create_agent(name="worker", agent_type="simple") manager.create_task(agent["id"], description="task1") manager.create_task(agent["id"], description="task2") tasks = manager.list_tasks(agent["id"]) assert len(tasks) == 2 def test_update_task(self, manager): agent = manager.create_agent(name="worker", agent_type="simple") task = manager.create_task(agent["id"], description="task1") updated = manager.update_task(task["id"], status="completed") assert updated["status"] == "completed" def test_delete_task(self, manager): agent = manager.create_agent(name="worker", agent_type="simple") task = manager.create_task(agent["id"], description="task1") manager.delete_task(task["id"]) tasks = manager.list_tasks(agent["id"]) assert len(tasks) == 0 class TestChannelBindings: def test_bind_channel(self, manager): agent = manager.create_agent(name="slacker", agent_type="simple") binding = manager.bind_channel( agent["id"], channel_type="slack", config={ "channel": "#research", "mention_only": False, "typing_indicators": True, }, ) assert binding["id"] assert binding["channel_type"] == "slack" def test_list_bindings(self, manager): agent = manager.create_agent(name="slacker", agent_type="simple") manager.bind_channel( agent["id"], channel_type="slack", config={"channel": "#a"} ) manager.bind_channel( agent["id"], channel_type="telegram", config={"chat_id": "123"} ) bindings = manager.list_channel_bindings(agent["id"]) assert len(bindings) == 2 def test_unbind_channel(self, manager): agent = manager.create_agent(name="slacker", agent_type="simple") binding = manager.bind_channel(agent["id"], channel_type="slack", config={}) manager.unbind_channel(binding["id"]) assert len(manager.list_channel_bindings(agent["id"])) == 0 class TestSummaryMemory: def test_initial_summary_empty(self, manager): agent = manager.create_agent(name="test", agent_type="simple") assert agent["summary_memory"] == "" def test_update_summary(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.update_summary_memory(agent["id"], "Key finding: X is Y") updated = manager.get_agent(agent["id"]) assert updated["summary_memory"] == "Key finding: X is Y" def test_summary_max_length(self, manager): # Import the cap from the module so this test follows the constant # rather than hardcoding a number that drifts every time the cap is # tuned (it was 2000, then 16000 after the truncation fix). from openjarvis.agents.manager import _SUMMARY_MAX agent = manager.create_agent(name="test", agent_type="simple") long_text = "x" * (_SUMMARY_MAX + 1000) manager.update_summary_memory(agent["id"], long_text) updated = manager.get_agent(agent["id"]) assert len(updated["summary_memory"]) == _SUMMARY_MAX class TestConcurrency: def test_run_tick_guard(self, manager): agent = manager.create_agent(name="busy", agent_type="simple") # Simulate agent running manager._set_status(agent["id"], "running") # Trying to run again should raise with pytest.raises(ValueError, match="already executing"): manager.start_tick(agent["id"]) class TestCheckpoints: def test_save_checkpoint(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.save_checkpoint( agent["id"], tick_id="tick-001", conversation_state={"messages": [{"role": "user", "content": "hello"}]}, tool_state={"web_search": {"last_query": "test"}}, ) checkpoints = manager.list_checkpoints(agent["id"]) assert len(checkpoints) == 1 assert checkpoints[0]["tick_id"] == "tick-001" def test_get_latest_checkpoint(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.save_checkpoint(agent["id"], "tick-001", {"v": 1}, {}) manager.save_checkpoint(agent["id"], "tick-002", {"v": 2}, {}) latest = manager.get_latest_checkpoint(agent["id"]) assert latest is not None assert latest["tick_id"] == "tick-002" assert latest["conversation_state"]["v"] == 2 def test_checkpoint_retention_max_5(self, manager): agent = manager.create_agent(name="test", agent_type="simple") for i in range(8): manager.save_checkpoint(agent["id"], f"tick-{i:03d}", {"v": i}, {}) checkpoints = manager.list_checkpoints(agent["id"]) assert len(checkpoints) == 5 # Oldest should be tick-003 (0,1,2 pruned) assert checkpoints[-1]["tick_id"] == "tick-003" def test_recover_agent(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.save_checkpoint(agent["id"], "tick-001", {"messages": []}, {}) manager.update_agent(agent["id"], status="error") checkpoint = manager.recover_agent(agent["id"]) assert checkpoint is not None assert manager.get_agent(agent["id"])["status"] == "idle" class TestMessageQueue: def test_send_queued_message(self, manager): agent = manager.create_agent(name="test", agent_type="simple") msg = manager.send_message(agent["id"], "Focus on transformers", mode="queued") assert msg["id"] assert msg["direction"] == "user_to_agent" assert msg["mode"] == "queued" assert msg["status"] == "pending" def test_list_messages(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.send_message(agent["id"], "msg1", mode="queued") manager.send_message(agent["id"], "msg2", mode="queued") messages = manager.list_messages(agent["id"]) assert len(messages) == 2 def test_get_pending_messages(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.send_message(agent["id"], "pending1", mode="queued") manager.send_message(agent["id"], "pending2", mode="queued") pending = manager.get_pending_messages(agent["id"]) assert len(pending) == 2 assert all(m["status"] == "pending" for m in pending) def test_mark_messages_delivered(self, manager): agent = manager.create_agent(name="test", agent_type="simple") msg = manager.send_message(agent["id"], "test", mode="queued") manager.mark_message_delivered(msg["id"]) messages = manager.list_messages(agent["id"]) assert messages[0]["status"] == "delivered" def test_add_agent_response(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.send_message(agent["id"], "What did you find?", mode="immediate") resp = manager.add_agent_response(agent["id"], "Found 3 papers") assert resp["direction"] == "agent_to_user" def test_store_agent_response_with_tool_calls(self, manager): """Tool calls captured during a turn must survive a list_messages round-trip so the UI can re-render them after a page reload.""" agent = manager.create_agent(name="test", agent_type="simple") tool_calls = [ { "tool": "file_read", "arguments": '{"path": "~/notes.md"}', "result": "hello world", "success": True, "latency": 12.3, }, { "tool": "shell_exec", "arguments": '{"command": "ls"}', "result": "a.md b.md", "success": True, "latency": 4.5, }, ] manager.store_agent_response( agent["id"], "Here is what I found", tool_calls=tool_calls ) messages = manager.list_messages(agent["id"]) assert len(messages) == 1 assert messages[0]["content"] == "Here is what I found" assert messages[0]["tool_calls"] == tool_calls def test_store_agent_response_without_tool_calls(self, manager): agent = manager.create_agent(name="test", agent_type="simple") manager.store_agent_response(agent["id"], "plain reply") messages = manager.list_messages(agent["id"]) assert messages[0]["tool_calls"] is None def test_update_agent_budget_fields(tmp_path): """update_agent() accepts budget and stall kwargs.""" import time from openjarvis.agents.manager import AgentManager mgr = AgentManager(str(tmp_path / "test.db")) agent = mgr.create_agent("budget-test") # Increment total_cost and total_tokens mgr.update_agent(agent["id"], total_cost_increment=1.50, total_tokens_increment=500) updated = mgr.get_agent(agent["id"]) assert updated["total_cost"] == 1.50 assert updated["total_tokens"] == 500 # Accumulate mgr.update_agent(agent["id"], total_cost_increment=0.75, total_tokens_increment=200) updated = mgr.get_agent(agent["id"]) assert updated["total_cost"] == 2.25 assert updated["total_tokens"] == 700 # Set last_activity_at now = time.time() mgr.update_agent(agent["id"], last_activity_at=now) updated = mgr.get_agent(agent["id"]) assert updated["last_activity_at"] == now # Set stall_retries mgr.update_agent(agent["id"], stall_retries=3) updated = mgr.get_agent(agent["id"]) assert updated["stall_retries"] == 3 mgr.close() def test_learning_log_crud(tmp_path): """AgentManager can write and read learning log entries.""" from openjarvis.agents.manager import AgentManager mgr = AgentManager(str(tmp_path / "test.db")) agent = mgr.create_agent("learner") entry = mgr.add_learning_log( agent["id"], "cycle_completed", description="Analyzed 20 traces", data={"sft_pairs": 5, "status": "completed"}, ) assert entry["event_type"] == "cycle_completed" logs = mgr.list_learning_log(agent["id"]) assert len(logs) == 1 assert logs[0]["data"]["sft_pairs"] == 5 # Add a second entry mgr.add_learning_log(agent["id"], "skill_discovered", description="Found new skill") logs = mgr.list_learning_log(agent["id"]) assert len(logs) == 2 mgr.close() class TestSchemaAndThreading: def test_agent_has_runtime_columns(self, manager): """New columns from ALTER TABLE migration should exist.""" agent = manager.create_agent(name="test", agent_type="simple") assert "total_tokens" in agent assert "total_cost" in agent assert "total_runs" in agent assert "last_run_at" in agent assert "last_activity_at" in agent assert agent["total_tokens"] == 0 assert agent["total_cost"] == 0.0 assert agent["total_runs"] == 0 def test_thread_safety(self, manager): """AgentManager should be usable from a different thread.""" import threading results = [] def create_in_thread(): agent = manager.create_agent(name="threaded", agent_type="simple") results.append(agent) t = threading.Thread(target=create_in_thread) t.start() t.join(timeout=5) assert len(results) == 1 assert results[0]["name"] == "threaded"