Fix inbox creation issues in agent coordinator

- Fixed Task.new/3 to handle both maps and keyword lists
- Added robust inbox existence checking in find_available_agent
- Ensure inbox creation during agent registration and task assignment
- Add helper function ensure_inbox_exists to avoid crashes
This commit is contained in:
Ra
2025-08-23 14:46:28 -07:00
parent 5048db99c7
commit 943d8ad4d7
40 changed files with 7798 additions and 404 deletions

View File

@@ -0,0 +1,175 @@
defmodule AgentCoordinator.AutoHeartbeatTest do
use ExUnit.Case, async: true
alias AgentCoordinator.{Client, EnhancedMCPServer, TaskRegistry}
setup do
# Start necessary services for testing
{:ok, _} = Registry.start_link(keys: :unique, name: AgentCoordinator.InboxRegistry)
{:ok, _} = DynamicSupervisor.start_link(name: AgentCoordinator.InboxSupervisor, strategy: :one_for_one)
{:ok, _} = TaskRegistry.start_link()
{:ok, _} = AgentCoordinator.MCPServer.start_link()
{:ok, _} = AgentCoordinator.AutoHeartbeat.start_link()
{:ok, _} = EnhancedMCPServer.start_link()
:ok
end
describe "automatic heartbeat functionality" do
test "agent automatically sends heartbeats during operations" do
# Start a client with auto-heartbeat
{:ok, client} = Client.start_session("TestAgent", [:coding], auto_heartbeat: true, heartbeat_interval: 1000)
# Get initial session info
{:ok, initial_info} = Client.get_session_info(client)
initial_heartbeat = initial_info.last_heartbeat
# Wait a bit for automatic heartbeat
Process.sleep(1500)
# Check that heartbeat was updated
{:ok, updated_info} = Client.get_session_info(client)
assert DateTime.compare(updated_info.last_heartbeat, initial_heartbeat) == :gt
# Cleanup
Client.stop_session(client)
end
test "agent stays online with regular heartbeats" do
# Start client
{:ok, client} = Client.start_session("OnlineAgent", [:analysis], auto_heartbeat: true, heartbeat_interval: 500)
# Get agent info
{:ok, session_info} = Client.get_session_info(client)
agent_id = session_info.agent_id
# Check task board initially
{:ok, initial_board} = Client.get_task_board(client)
agent = Enum.find(initial_board.agents, fn a -> a["agent_id"] == agent_id end)
assert agent["online"] == true
# Wait longer than heartbeat interval but not longer than online timeout
Process.sleep(2000)
# Agent should still be online due to automatic heartbeats
{:ok, updated_board} = Client.get_task_board(client)
updated_agent = Enum.find(updated_board.agents, fn a -> a["agent_id"] == agent_id end)
assert updated_agent["online"] == true
Client.stop_session(client)
end
test "multiple agents coordinate without collisions" do
# Start multiple agents
{:ok, agent1} = Client.start_session("Agent1", [:coding], auto_heartbeat: true)
{:ok, agent2} = Client.start_session("Agent2", [:testing], auto_heartbeat: true)
{:ok, agent3} = Client.start_session("Agent3", [:review], auto_heartbeat: true)
# All should be online
{:ok, board} = Client.get_task_board(agent1)
online_agents = Enum.filter(board.agents, fn a -> a["online"] end)
assert length(online_agents) >= 3
# Create tasks from different agents simultaneously
task1 = Task.async(fn ->
Client.create_task(agent1, "Task1", "Description1", %{"priority" => "normal"})
end)
task2 = Task.async(fn ->
Client.create_task(agent2, "Task2", "Description2", %{"priority" => "high"})
end)
task3 = Task.async(fn ->
Client.create_task(agent3, "Task3", "Description3", %{"priority" => "low"})
end)
# All tasks should complete successfully
{:ok, result1} = Task.await(task1)
{:ok, result2} = Task.await(task2)
{:ok, result3} = Task.await(task3)
# Verify heartbeat metadata is included
assert Map.has_key?(result1, "_heartbeat_metadata")
assert Map.has_key?(result2, "_heartbeat_metadata")
assert Map.has_key?(result3, "_heartbeat_metadata")
# Cleanup
Client.stop_session(agent1)
Client.stop_session(agent2)
Client.stop_session(agent3)
end
test "heartbeat metadata is included in responses" do
{:ok, client} = Client.start_session("MetadataAgent", [:documentation])
# Perform an operation
{:ok, result} = Client.create_task(client, "Test Task", "Test Description")
# Check for heartbeat metadata
assert Map.has_key?(result, "_heartbeat_metadata")
metadata = result["_heartbeat_metadata"]
# Verify metadata structure
{:ok, session_info} = Client.get_session_info(client)
assert metadata["agent_id"] == session_info.agent_id
assert Map.has_key?(metadata, "timestamp")
assert Map.has_key?(metadata, "pre_heartbeat")
assert Map.has_key?(metadata, "post_heartbeat")
Client.stop_session(client)
end
test "session cleanup on client termination" do
# Start client
{:ok, client} = Client.start_session("CleanupAgent", [:coding])
# Get session info
{:ok, session_info} = Client.get_session_info(client)
agent_id = session_info.agent_id
# Verify agent is in task board
{:ok, board} = Client.get_task_board(client)
assert Enum.any?(board.agents, fn a -> a["agent_id"] == agent_id end)
# Stop client
Client.stop_session(client)
# Give some time for cleanup
Process.sleep(100)
# Start another client to check board
{:ok, checker_client} = Client.start_session("CheckerAgent", [:analysis])
{:ok, updated_board} = Client.get_task_board(checker_client)
# Original agent should show as offline or be cleaned up
case Enum.find(updated_board.agents, fn a -> a["agent_id"] == agent_id end) do
nil ->
# Agent was cleaned up - this is acceptable
:ok
agent ->
# Agent should be offline
refute agent["online"]
end
Client.stop_session(checker_client)
end
end
describe "enhanced task board" do
test "provides session information" do
{:ok, client} = Client.start_session("BoardAgent", [:analysis])
{:ok, board} = Client.get_task_board(client)
# Should have session metadata
assert Map.has_key?(board, "active_sessions")
assert board["active_sessions"] >= 1
# Agents should have enhanced information
agent = Enum.find(board.agents, fn a -> a["name"] == "BoardAgent" end)
assert Map.has_key?(agent, "session_active")
assert agent["session_active"] == true
Client.stop_session(client)
end
end
end

View File

@@ -0,0 +1,635 @@
defmodule AgentCoordinator.MCPServerTest do
use ExUnit.Case, async: false
alias AgentCoordinator.{MCPServer, TaskRegistry, Agent, Task, Inbox}
setup do
# Clean up any existing named processes safely
if Process.whereis(MCPServer), do: GenServer.stop(MCPServer, :normal, 1000)
if Process.whereis(TaskRegistry), do: GenServer.stop(TaskRegistry, :normal, 1000)
if Process.whereis(AgentCoordinator.PubSub),
do: GenServer.stop(AgentCoordinator.PubSub, :normal, 1000)
if Process.whereis(AgentCoordinator.InboxSupervisor),
do: DynamicSupervisor.stop(AgentCoordinator.InboxSupervisor, :normal, 1000)
# Registry has to be handled differently
case Process.whereis(AgentCoordinator.InboxRegistry) do
nil ->
:ok
pid ->
Process.unlink(pid)
Process.exit(pid, :kill)
end
# Wait a bit for processes to terminate
Process.sleep(200)
# Start fresh components needed for testing (without NATS)
start_supervised!({Registry, keys: :unique, name: AgentCoordinator.InboxRegistry})
start_supervised!({Phoenix.PubSub, name: AgentCoordinator.PubSub})
start_supervised!(
{DynamicSupervisor, name: AgentCoordinator.InboxSupervisor, strategy: :one_for_one}
)
# Start task registry without NATS for testing
# Empty map for no NATS connection
start_supervised!({TaskRegistry, nats: %{}})
start_supervised!(MCPServer)
:ok
end
describe "MCP protocol compliance" do
test "returns tools list for tools/list method" do
request = %{"method" => "tools/list", "jsonrpc" => "2.0", "id" => 1}
response = MCPServer.handle_mcp_request(request)
assert %{"jsonrpc" => "2.0", "result" => %{"tools" => tools}} = response
assert is_list(tools)
assert length(tools) == 6
# Check that all expected tools are present
tool_names = Enum.map(tools, & &1["name"])
expected_tools = [
"register_agent",
"create_task",
"get_next_task",
"complete_task",
"get_task_board",
"heartbeat"
]
for tool_name <- expected_tools do
assert tool_name in tool_names, "Missing tool: #{tool_name}"
end
end
test "returns error for unknown method" do
request = %{"method" => "unknown/method", "jsonrpc" => "2.0", "id" => 1}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"error" => %{"code" => -32601, "message" => "Method not found"}
} = response
end
test "returns error for unknown tool" do
request = %{
"method" => "tools/call",
"params" => %{"name" => "unknown_tool", "arguments" => %{}},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"error" => %{"code" => -1, "message" => "Unknown tool: unknown_tool"}
} = response
end
end
describe "register_agent tool" do
test "successfully registers an agent with valid capabilities" do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "register_agent",
"arguments" => %{
"name" => "TestAgent",
"capabilities" => ["coding", "testing"]
}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"agent_id" => agent_id, "status" => "registered"} = data
assert is_binary(agent_id)
# Verify agent is in registry
agents = TaskRegistry.list_agents()
assert Enum.any?(agents, fn agent -> agent.id == agent_id and agent.name == "TestAgent" end)
end
test "fails to register agent with duplicate name" do
# Register first agent
args1 = %{"name" => "DuplicateAgent", "capabilities" => ["coding"]}
request1 = %{
"method" => "tools/call",
"params" => %{"name" => "register_agent", "arguments" => args1},
"jsonrpc" => "2.0",
"id" => 1
}
MCPServer.handle_mcp_request(request1)
# Try to register second agent with same name
args2 = %{"name" => "DuplicateAgent", "capabilities" => ["testing"]}
request2 = %{
"method" => "tools/call",
"params" => %{"name" => "register_agent", "arguments" => args2},
"jsonrpc" => "2.0",
"id" => 2
}
response = MCPServer.handle_mcp_request(request2)
assert %{"jsonrpc" => "2.0", "id" => 2, "error" => %{"code" => -1, "message" => message}} =
response
assert String.contains?(message, "Agent name already exists")
end
end
describe "create_task tool" do
setup do
# Register an agent for task assignment
agent = Agent.new("TaskAgent", [:coding, :testing])
TaskRegistry.register_agent(agent)
Inbox.start_link(agent.id)
%{agent_id: agent.id}
end
test "successfully creates and assigns task to available agent", %{agent_id: agent_id} do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "create_task",
"arguments" => %{
"title" => "Test Task",
"description" => "A test task description",
"priority" => "high",
"file_paths" => ["test.ex"],
"required_capabilities" => ["coding"]
}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"task_id" => task_id, "assigned_to" => ^agent_id, "status" => "assigned"} = data
assert is_binary(task_id)
end
test "queues task when no agents available" do
# Don't register any agents
request = %{
"method" => "tools/call",
"params" => %{
"name" => "create_task",
"arguments" => %{
"title" => "Queued Task",
"description" => "This task will be queued"
}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"task_id" => task_id, "status" => "queued"} = data
assert is_binary(task_id)
end
test "creates task with minimum required fields" do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "create_task",
"arguments" => %{
"title" => "Minimal Task",
"description" => "Minimal task description"
}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"task_id" => task_id} = data
assert is_binary(task_id)
end
end
describe "get_next_task tool" do
setup do
# Register agent and create a task
agent = Agent.new("WorkerAgent", [:coding])
TaskRegistry.register_agent(agent)
Inbox.start_link(agent.id)
task = Task.new("Work Task", "Some work to do", priority: :high)
Inbox.add_task(agent.id, task)
%{agent_id: agent.id, task_id: task.id}
end
test "returns next task for agent with pending tasks", %{agent_id: agent_id, task_id: task_id} do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "get_next_task",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{
"task_id" => ^task_id,
"title" => "Work Task",
"description" => "Some work to do",
"priority" => "high"
} = data
end
test "returns no tasks message when no pending tasks", %{agent_id: agent_id} do
# First get the task to make inbox empty
Inbox.get_next_task(agent_id)
request = %{
"method" => "tools/call",
"params" => %{
"name" => "get_next_task",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"message" => "No tasks available"} = data
end
end
describe "complete_task tool" do
setup do
# Setup agent with a task in progress
agent = Agent.new("CompletionAgent", [:coding])
TaskRegistry.register_agent(agent)
Inbox.start_link(agent.id)
task = Task.new("Complete Me", "Task to complete")
Inbox.add_task(agent.id, task)
# Start the task
completed_task = Inbox.get_next_task(agent.id)
%{agent_id: agent.id, task_id: completed_task.id}
end
test "successfully completes current task", %{agent_id: agent_id, task_id: task_id} do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "complete_task",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{
"task_id" => ^task_id,
"status" => "completed",
"completed_at" => completed_at
} = data
assert is_binary(completed_at)
end
test "fails when no task in progress" do
# Register agent without starting any tasks
agent = Agent.new("IdleAgent", [:coding])
TaskRegistry.register_agent(agent)
Inbox.start_link(agent.id)
request = %{
"method" => "tools/call",
"params" => %{
"name" => "complete_task",
"arguments" => %{"agent_id" => agent.id}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{"jsonrpc" => "2.0", "id" => 1, "error" => %{"code" => -1, "message" => message}} =
response
assert String.contains?(message, "no_task_in_progress")
end
end
describe "get_task_board tool" do
setup do
# Register multiple agents with different states
agent1 = Agent.new("BusyAgent", [:coding])
agent2 = Agent.new("IdleAgent", [:testing])
TaskRegistry.register_agent(agent1)
TaskRegistry.register_agent(agent2)
Inbox.start_link(agent1.id)
Inbox.start_link(agent2.id)
# Add task to first agent
task = Task.new("Busy Work", "Work in progress")
Inbox.add_task(agent1.id, task)
# Start the task
Inbox.get_next_task(agent1.id)
%{agent1_id: agent1.id, agent2_id: agent2.id}
end
test "returns status of all agents", %{agent1_id: agent1_id, agent2_id: agent2_id} do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "get_task_board",
"arguments" => %{}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"agents" => agents} = data
assert length(agents) == 2
# Find agents by ID
busy_agent = Enum.find(agents, fn agent -> agent["agent_id"] == agent1_id end)
idle_agent = Enum.find(agents, fn agent -> agent["agent_id"] == agent2_id end)
assert busy_agent["name"] == "BusyAgent"
assert busy_agent["capabilities"] == ["coding"]
assert busy_agent["current_task"]["title"] == "Busy Work"
assert idle_agent["name"] == "IdleAgent"
assert idle_agent["capabilities"] == ["testing"]
assert is_nil(idle_agent["current_task"])
end
end
describe "heartbeat tool" do
setup do
agent = Agent.new("HeartbeatAgent", [:coding])
TaskRegistry.register_agent(agent)
%{agent_id: agent.id}
end
test "successfully processes heartbeat for registered agent", %{agent_id: agent_id} do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "heartbeat",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{
"jsonrpc" => "2.0",
"id" => 1,
"result" => %{"content" => [%{"type" => "text", "text" => text}]}
} = response
data = Jason.decode!(text)
assert %{"status" => "heartbeat_received"} = data
end
test "fails heartbeat for non-existent agent" do
request = %{
"method" => "tools/call",
"params" => %{
"name" => "heartbeat",
"arguments" => %{"agent_id" => "non-existent-id"}
},
"jsonrpc" => "2.0",
"id" => 1
}
response = MCPServer.handle_mcp_request(request)
assert %{"jsonrpc" => "2.0", "id" => 1, "error" => %{"code" => -1, "message" => message}} =
response
assert String.contains?(message, "agent_not_found")
end
end
describe "full workflow integration" do
test "complete agent coordination workflow" do
# 1. Register an agent
register_request = %{
"method" => "tools/call",
"params" => %{
"name" => "register_agent",
"arguments" => %{
"name" => "WorkflowAgent",
"capabilities" => ["coding", "testing"]
}
},
"jsonrpc" => "2.0",
"id" => 1
}
register_response = MCPServer.handle_mcp_request(register_request)
register_data =
register_response["result"]["content"]
|> List.first()
|> Map.get("text")
|> Jason.decode!()
agent_id = register_data["agent_id"]
# 2. Create a task
create_request = %{
"method" => "tools/call",
"params" => %{
"name" => "create_task",
"arguments" => %{
"title" => "Workflow Task",
"description" => "Complete workflow test",
"priority" => "high",
"required_capabilities" => ["coding"]
}
},
"jsonrpc" => "2.0",
"id" => 2
}
create_response = MCPServer.handle_mcp_request(create_request)
create_data =
create_response["result"]["content"] |> List.first() |> Map.get("text") |> Jason.decode!()
task_id = create_data["task_id"]
assert create_data["assigned_to"] == agent_id
# 3. Get the task
get_request = %{
"method" => "tools/call",
"params" => %{
"name" => "get_next_task",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 3
}
get_response = MCPServer.handle_mcp_request(get_request)
get_data =
get_response["result"]["content"] |> List.first() |> Map.get("text") |> Jason.decode!()
assert get_data["task_id"] == task_id
assert get_data["title"] == "Workflow Task"
# 4. Check task board
board_request = %{
"method" => "tools/call",
"params" => %{
"name" => "get_task_board",
"arguments" => %{}
},
"jsonrpc" => "2.0",
"id" => 4
}
board_response = MCPServer.handle_mcp_request(board_request)
board_data =
board_response["result"]["content"] |> List.first() |> Map.get("text") |> Jason.decode!()
agent_status = board_data["agents"] |> List.first()
assert agent_status["agent_id"] == agent_id
assert agent_status["current_task"]["id"] == task_id
# 5. Complete the task
complete_request = %{
"method" => "tools/call",
"params" => %{
"name" => "complete_task",
"arguments" => %{"agent_id" => agent_id}
},
"jsonrpc" => "2.0",
"id" => 5
}
complete_response = MCPServer.handle_mcp_request(complete_request)
complete_data =
complete_response["result"]["content"]
|> List.first()
|> Map.get("text")
|> Jason.decode!()
assert complete_data["task_id"] == task_id
assert complete_data["status"] == "completed"
# 6. Verify task board shows completed state
final_board_response = MCPServer.handle_mcp_request(board_request)
final_board_data =
final_board_response["result"]["content"]
|> List.first()
|> Map.get("text")
|> Jason.decode!()
final_agent_status = final_board_data["agents"] |> List.first()
assert is_nil(final_agent_status["current_task"])
assert final_agent_status["completed_tasks"] == 1
end
end
end