Files
OpenOPC/opc/plugins/office_ui/tests/test_integration_real_agents.py
2026-07-01 17:56:31 +08:00

1045 lines
44 KiB
Python

"""Integration tests — real LLM calls, real agent execution, real event pipeline.
Each test runs a NativeAgent with a real LLM (OpenRouter minimax-m2.5),
captures OPCEvents from the EventBus, translates them via EventAdapter,
and verifies the resulting visual events match what the Phaser frontend expects.
Pipeline under test:
NativeAgent.execute(task)
→ NativeRuntimeV2 publishes OPCEvents to EventBus
→ EventAdapter.translate() → VisualEvents
→ Tests verify sequences, state machine, kanban mapping
Requires: OPENROUTER_API_KEY or the key in .opc/config/llm_config.yaml
"""
from __future__ import annotations
import asyncio
import os
import textwrap
from pathlib import Path
from typing import Any
import pytest
from opc.core.config import LLMConfig, OPCConfig, SystemConfig, get_opc_home
from opc.core.events import EventBus
from opc.core.models import (
AgentInfo,
AgentStatus,
OPCEvent,
Task,
TaskResult,
TaskStatus,
)
from opc.layer3_agent.native_agent import NativeAgent
from opc.layer4_tools.registry import ToolDefinition, ToolRegistry
from opc.layer4_tools.file_ops import (
file_read,
file_write,
file_edit,
file_search,
)
from opc.layer4_tools.shell import create_shell_tool
from opc.layer4_tools.web_search import create_web_tools
from opc.layer4_tools.python_exec import create_python_tool
from opc.layer4_tools.todo import create_todo_tools
from opc.layer5_memory.memory_manager import MemoryManager
from opc.layer5_memory.preference import PreferenceManager
from opc.layer5_memory.skill_library import SkillLibrary
from opc.llm.provider import LLMProvider
from opc.plugins.office_ui.event_adapter import (
AgentAnimState,
EventAdapter,
)
# ── Skip guard ──────────────────────────────────────────────────────────────
def _load_llm_config() -> LLMConfig:
"""Load LLM config from .opc/config or env."""
try:
cfg = OPCConfig.load(get_opc_home() / "config")
if cfg.llm.api_key or cfg.llm.api_key_env:
return cfg.llm
except Exception:
pass
# Fallback to env
key = os.environ.get("OPENROUTER_API_KEY", "")
if not key:
key = os.environ.get("ANTHROPIC_API_KEY", "")
return LLMConfig(
default_model="openrouter/minimax/minimax-m2.5",
api_base="https://openrouter.ai/api/v1",
api_key=key,
temperature=0.3,
max_tokens=4096,
)
_LLM_CFG = _load_llm_config()
_HAS_KEY = bool(_LLM_CFG.api_key or os.environ.get(_LLM_CFG.api_key_env or "__NONE__", ""))
pytestmark = [
pytest.mark.skipif(not _HAS_KEY, reason="No LLM API key configured"),
pytest.mark.asyncio,
pytest.mark.timeout(180),
]
# ── Helpers ─────────────────────────────────────────────────────────────────
def types(events: list[dict]) -> list[str]:
return [e["type"] for e in events]
def types_for(events: list[dict], agent_id: str) -> list[str]:
return [e["type"] for e in events if e.get("agent_id") == agent_id]
def opc_types(events: list[OPCEvent]) -> list[str]:
return [e.event_type for e in events]
def collect_visual(opc_events: list[OPCEvent], adapter: EventAdapter) -> list[dict]:
results = []
for ev in opc_events:
results.extend(adapter.translate(ev))
return results
def assert_lifecycle(visual: list[dict], agent_id: str) -> None:
"""Assert the fundamental agent lifecycle: agent_active ... waiting."""
agent_events = [e for e in visual if e.get("agent_id") == agent_id]
assert len(agent_events) >= 2, f"Expected at least 2 events for {agent_id}, got {len(agent_events)}"
assert agent_events[0]["type"] == "agent_active", f"First event should be agent_active, got {agent_events[0]['type']}"
assert agent_events[-1]["type"] == "waiting", f"Last event should be waiting, got {agent_events[-1]['type']}"
def assert_paired_events(visual: list[dict], agent_id: str) -> None:
"""Assert all reflect_start/reflect_done and tool_start/tool_done are properly paired."""
agent_events = types_for(visual, agent_id)
reflect_depth = 0
tool_depth = 0
for t in agent_events:
if t == "reflect_start":
assert reflect_depth == 0, "Nested reflect_start without reflect_done"
reflect_depth += 1
elif t == "reflect_done":
assert reflect_depth == 1, "reflect_done without matching reflect_start"
reflect_depth -= 1
elif t == "tool_start":
assert tool_depth == 0, "Nested tool_start without tool_done"
tool_depth += 1
elif t == "tool_done":
assert tool_depth == 1, "tool_done without matching tool_start"
tool_depth -= 1
assert reflect_depth == 0, "Unclosed reflect_start"
assert tool_depth == 0, "Unclosed tool_start"
# ── Fixtures ────────────────────────────────────────────────────────────────
@pytest.fixture
def llm() -> LLMProvider:
return LLMProvider(_LLM_CFG)
@pytest.fixture
def event_collector():
"""Returns (event_bus, collected_events_list)."""
bus = EventBus()
collected: list[OPCEvent] = []
async def _listener(event: OPCEvent) -> None:
collected.append(event)
bus.subscribe_all(_listener)
return bus, collected
@pytest.fixture
def adapter() -> EventAdapter:
return EventAdapter()
@pytest.fixture
def workspace(tmp_path: Path) -> Path:
"""Create a temp workspace with seed files."""
(tmp_path / "hello.txt").write_text("Hello from OPC integration test!\nLine 2\nLine 3\n")
(tmp_path / "sample.py").write_text("x = 1\nprint(x)\n")
src = tmp_path / "src"
src.mkdir()
(src / "main.py").write_text('def main():\n print("main")\n\nif __name__ == "__main__":\n main()\n')
(src / "utils.py").write_text('def add(a, b):\n return a + b\n')
(src / "config.py").write_text('DEBUG = True\nPORT = 8080\n')
return tmp_path
def _make_tool_registry(tool_names: list[str]) -> ToolRegistry:
"""Create a ToolRegistry with only the specified tools."""
registry = ToolRegistry()
_FILE_TOOLS = {
"file_read": ToolDefinition(
name="file_read",
description="Read file contents.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "description": "Absolute file path"},
"offset": {"type": "integer", "default": 0},
"limit": {"type": "integer"},
},
"required": ["path"],
},
func=file_read,
category="filesystem",
),
"file_write": ToolDefinition(
name="file_write",
description="Write content to a file (creates dirs if needed).",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"content": {"type": "string"},
"create_dirs": {"type": "boolean", "default": True},
},
"required": ["path", "content"],
},
func=file_write,
category="filesystem",
),
"file_edit": ToolDefinition(
name="file_edit",
description="Replace an exact unique string in a file.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string"},
"old_string": {"type": "string"},
"new_string": {"type": "string"},
},
"required": ["path", "old_string", "new_string"],
},
func=file_edit,
category="filesystem",
),
"file_search": ToolDefinition(
name="file_search",
description="Search for a pattern in files using ripgrep.",
parameters={
"type": "object",
"properties": {
"pattern": {"type": "string"},
"directory": {"type": "string", "default": "."},
"file_glob": {"type": "string", "default": "*"},
"max_results": {"type": "integer", "default": 50},
},
"required": ["pattern"],
},
func=file_search,
category="filesystem",
),
"list_dir": ToolDefinition(
name="list_dir",
description="List directory contents.",
parameters={
"type": "object",
"properties": {
"path": {"type": "string", "default": "."},
"recursive": {"type": "boolean", "default": False},
},
"required": [],
},
func=_list_dir_func,
category="filesystem",
),
}
for name in tool_names:
if name in _FILE_TOOLS:
registry.register(_FILE_TOOLS[name])
elif name == "shell_exec":
registry.register(create_shell_tool())
elif name == "python_exec":
registry.register(create_python_tool())
elif name in ("web_search", "web_fetch"):
for t in create_web_tools():
if t.name == name:
registry.register(t)
elif name in ("todo_read", "todo_write"):
for t in create_todo_tools():
if t.name == name:
registry.register(t)
return registry
async def _list_dir_func(path: str = ".", recursive: bool = False) -> dict[str, Any]:
"""Simple list_dir implementation for tests."""
p = Path(path)
if not p.exists():
return {"error": f"Directory not found: {path}"}
if not p.is_dir():
return {"error": f"Not a directory: {path}"}
entries = []
if recursive:
for item in sorted(p.rglob("*")):
entries.append({"name": str(item.relative_to(p)), "type": "dir" if item.is_dir() else "file"})
else:
for item in sorted(p.iterdir()):
entries.append({"name": item.name, "type": "dir" if item.is_dir() else "file"})
return {"entries": entries[:200]}
def _make_agent(
role_id: str,
tools: list[str],
responsibility: str,
llm: LLMProvider,
event_bus: EventBus,
opc_home: Path,
max_iterations: int = 3,
) -> NativeAgent:
"""Factory for creating a NativeAgent with minimal dependencies."""
role = AgentInfo(
role_id=role_id,
name=role_id.replace("_", " ").title(),
responsibility=responsibility,
tools=tools,
)
memory = MemoryManager(opc_home=opc_home, project_id="test")
prefs = PreferenceManager(opc_home=opc_home)
skills = SkillLibrary(opc_home=opc_home)
config = OPCConfig(system=SystemConfig(max_agent_iterations=max_iterations))
tool_registry = _make_tool_registry(tools)
return NativeAgent(
role=role,
llm=llm,
tool_registry=tool_registry,
memory=memory,
preferences=prefs,
skills=skills,
event_bus=event_bus,
config=config,
)
# ═══════════════════════════════════════════════════════════════════════════════
# Test 1: Single agent reads a file — verify full event pipeline
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration01_SingleAgentFileRead:
"""Agent reads a real file; verify OPC events → visual events lifecycle."""
async def test_read_file_event_pipeline(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="reader",
tools=["file_read", "list_dir"],
responsibility="Read files and report contents",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task = Task(
title="Read hello.txt",
description=f"Read the file at {workspace}/hello.txt and tell me its full contents.",
assigned_to="reader",
)
result = await agent.execute(task)
# Agent should complete successfully
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED), f"Unexpected status: {result.status}"
# OPC events must include the lifecycle
opc_t = opc_types(collected)
assert "agent_status_changed" in opc_t, "Missing agent_status_changed events"
assert "agent_log" in opc_t, "Missing agent_log events"
# Check running → idle lifecycle in raw events
status_events = [e for e in collected if e.event_type == "agent_status_changed"]
assert status_events[0].payload["status"] == "running"
assert status_events[-1].payload["status"] == "idle"
# Translate to visual events
visual = collect_visual(collected, adapter)
assert len(visual) >= 2, f"Expected at least 2 visual events, got {len(visual)}"
# Verify lifecycle and pairing
assert_lifecycle(visual, "reader")
assert_paired_events(visual, "reader")
# If agent used file_read, verify tool_start(read) appears
tool_events = [e for e in collected if e.event_type == "agent_log" and e.payload.get("tool") == "file_read"]
if tool_events:
tool_visuals = [v for v in visual if v["type"] == "tool_start" and v["data"].get("tool_name") == "read"]
assert len(tool_visuals) >= 1, "file_read executed but no tool_start(read) visual event"
# Adapter state should be clean
tracker = adapter._get_tracker("reader")
assert tracker.state == AgentAnimState.IDLE
assert tracker.task_id is None
# ═══════════════════════════════════════════════════════════════════════════════
# Test 2: Single agent multi-tool — read, edit, verify
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration02_SingleAgentMultiTool:
"""Agent reads, edits, then runs shell to verify — multiple tool transitions."""
async def test_multi_tool_sequence(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="developer",
tools=["file_read", "file_edit", "shell_exec"],
responsibility="Code development and verification",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=5,
)
task = Task(
title="Fix sample.py",
description=(
f"1. Read the file at {workspace}/sample.py\n"
f"2. Change 'x = 1' to 'x = 42'\n"
f"3. Run 'python3 {workspace}/sample.py' to verify it prints 42"
),
assigned_to="developer",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Verify multiple tool types were attempted
tool_names_used = {
e.payload.get("tool")
for e in collected
if e.event_type == "agent_log" and e.payload.get("status") == "executing"
}
# At minimum, agent should have tried file_read
assert len(tool_names_used) >= 1, f"Expected at least 1 tool, got: {tool_names_used}"
# Translate and verify
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "developer")
assert_paired_events(visual, "developer")
# Verify tool_start events have correct mapped names
tool_starts = [v for v in visual if v["type"] == "tool_start"]
for ts in tool_starts:
assert ts["data"]["tool_name"] in ("read", "edit", "shell", "write", "search", "list", "reflect"), \
f"Unexpected tool_name: {ts['data']['tool_name']}"
# ═══════════════════════════════════════════════════════════════════════════════
# Test 3: Single agent with probe sub-agent
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration03_ProbeSubagent:
"""Agent with write tools gets probe capability — verify subagent_spawn if used."""
async def test_probe_exploration(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="architect",
tools=["file_read", "file_write", "file_edit", "file_search", "list_dir"],
responsibility="Codebase exploration and architecture documentation",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=5,
)
task = Task(
title="Explore and summarize",
description=(
f"Explore the project structure at {workspace}/src/ using the probe tool. "
f"Then create a file {workspace}/SUMMARY.md summarizing what you found."
),
assigned_to="architect",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "architect")
assert_paired_events(visual, "architect")
# If probe was used, verify subagent_spawn visual event
probe_used = any(
e.event_type == "agent_log" and e.payload.get("tool") == "probe"
for e in collected
)
if probe_used:
assert "subagent_spawn" in types(visual), "Probe used but no subagent_spawn event"
probe_tool_starts = [
v for v in visual
if v["type"] == "tool_start" and v["data"].get("tool_name") == "reflect"
]
assert len(probe_tool_starts) >= 1, "Probe used but no tool_start(reflect) event"
# ═══════════════════════════════════════════════════════════════════════════════
# Test 4: Two agents working in parallel — interleaved events
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration04_TwoParallelAgents:
"""Two agents run concurrently — verify independent event streams."""
async def test_parallel_execution(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent_a = _make_agent(
role_id="writer_agent",
tools=["file_write"],
responsibility="Create files",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
agent_b = _make_agent(
role_id="shell_agent",
tools=["shell_exec"],
responsibility="Run shell commands",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task_a = Task(
title="Write a file",
description=f"Create a file at {workspace}/output_a.txt with content 'Agent A was here'",
assigned_to="writer_agent",
)
task_b = Task(
title="Run echo",
description="Run the command: echo 'Agent B says hello'",
assigned_to="shell_agent",
)
results = await asyncio.gather(
agent_a.execute(task_a),
agent_b.execute(task_b),
)
# Both should complete
for r in results:
assert r.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Both agents should have status events
status_agents = {
e.payload.get("role_id")
for e in collected
if e.event_type == "agent_status_changed"
}
assert "writer_agent" in status_agents
assert "shell_agent" in status_agents
# Translate and verify each agent independently
visual = collect_visual(collected, adapter)
for agent_id in ("writer_agent", "shell_agent"):
agent_vis = [v for v in visual if v.get("agent_id") == agent_id]
if len(agent_vis) >= 2:
assert_lifecycle(visual, agent_id)
assert_paired_events(visual, agent_id)
# Verify trackers are independent
tracker_a = adapter._get_tracker("writer_agent")
tracker_b = adapter._get_tracker("shell_agent")
assert tracker_a.state == AgentAnimState.IDLE
assert tracker_b.state == AgentAnimState.IDLE
# ═══════════════════════════════════════════════════════════════════════════════
# Test 5: Agent with web_search
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration05_WebSearch:
"""Agent uses web_search — verify tool mapping to web_search visual."""
async def test_web_search_visual_events(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="researcher",
tools=["web_search"],
responsibility="Web research and information gathering",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task = Task(
title="Web research",
description="Search the web for 'Python asyncio best practices 2024' and summarize what you find.",
assigned_to="researcher",
)
result = await agent.execute(task)
# Agent should complete (even if web_search fails, LLM produces text)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "researcher")
assert_paired_events(visual, "researcher")
# If web_search was used, check visual mapping
ws_used = any(
e.event_type == "agent_log" and e.payload.get("tool") == "web_search"
for e in collected
)
if ws_used:
ws_visuals = [
v for v in visual
if v["type"] == "tool_start" and v["data"].get("tool_name") == "web_search"
]
assert len(ws_visuals) >= 1, "web_search used but no tool_start(web_search) visual"
# ═══════════════════════════════════════════════════════════════════════════════
# Test 6: Agent creates a new file
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration06_FileWrite:
"""Agent creates a new file — verify file exists and tool_start(write) event."""
async def test_file_creation(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
target = workspace / "greeting.txt"
agent = _make_agent(
role_id="writer",
tools=["file_write"],
responsibility="Create files with specified content",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task = Task(
title="Create greeting file",
description=f"Create a file at {target} with the content: Hello World!",
assigned_to="writer",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "writer")
assert_paired_events(visual, "writer")
# If file_write was used, verify visual
fw_used = any(
e.event_type == "agent_log" and e.payload.get("tool") == "file_write"
for e in collected
)
if fw_used:
assert target.exists(), "Agent used file_write but file was not created"
write_visuals = [
v for v in visual
if v["type"] == "tool_start" and v["data"].get("tool_name") == "write"
]
assert len(write_visuals) >= 1
# ═══════════════════════════════════════════════════════════════════════════════
# Test 7: Agent completes without using any tools
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration07_NoTools:
"""Agent answers a simple question without tool calls."""
async def test_direct_answer(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="advisor",
tools=[],
responsibility="Answer questions directly",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task = Task(
title="Simple math",
description="What is 2 + 2? Reply with just the number.",
assigned_to="advisor",
)
result = await agent.execute(task)
assert result.status == TaskStatus.DONE
assert "4" in result.content, f"Expected '4' in response, got: {result.content[:200]}"
# Should have no executing events (no tool calls)
exec_events = [
e for e in collected
if e.event_type == "agent_log" and e.payload.get("status") == "executing"
]
assert len(exec_events) == 0, f"Expected no tool executions, got {len(exec_events)}"
# Visual: agent_active → reflect_start → reflect_done → waiting (no tool events)
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "advisor")
assert_paired_events(visual, "advisor")
tool_starts = [v for v in visual if v["type"] == "tool_start" and v.get("agent_id") == "advisor"]
assert len(tool_starts) == 0, "No tools used but tool_start events appeared"
# ═══════════════════════════════════════════════════════════════════════════════
# Test 8: Agent hits max iterations
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration08_MaxIterations:
"""Agent hits max_iterations=2 and fails — verify lifecycle still closes."""
async def test_max_iterations_failure(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
# Create many files so the task can't finish in 2 iterations
for i in range(20):
(workspace / "src" / f"module_{i}.py").write_text(f"# Module {i}\ndef func_{i}(): pass\n")
agent = _make_agent(
role_id="analyzer",
tools=["file_read", "list_dir", "file_search"],
responsibility="Comprehensive code analysis",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=2, # intentionally low
)
task = Task(
title="Analyze all modules",
description=(
f"Read and analyze EVERY single .py file in {workspace}/src/ directory. "
f"You must read each file individually and provide detailed analysis of each one."
),
assigned_to="analyzer",
)
result = await agent.execute(task)
# Should fail or succeed within 2 iterations
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Verify max 2 thinking events
thinking_events = [
e for e in collected
if e.event_type == "agent_log" and e.payload.get("status") == "thinking"
]
assert len(thinking_events) <= 2, f"Expected at most 2 thinking events, got {len(thinking_events)}"
# Visual lifecycle should still close properly
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "analyzer")
assert_paired_events(visual, "analyzer")
# Adapter state should be clean
tracker = adapter._get_tracker("analyzer")
assert tracker.state == AgentAnimState.IDLE
# ═══════════════════════════════════════════════════════════════════════════════
# Test 9: Agent doom loop — tool fails repeatedly
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration09_DoomLoop:
"""Agent calls a tool that always fails — verify doom loop detection."""
async def test_doom_loop_detection(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
# Register a custom tool that always fails
registry = _make_tool_registry(["file_read"])
async def always_fail(**kwargs: Any) -> dict:
return {"error": "Service unavailable: database connection failed", "success": False}
registry.register(ToolDefinition(
name="check_database",
description="Check database connection status. Always call this tool first.",
parameters={
"type": "object",
"properties": {
"host": {"type": "string", "default": "localhost"},
},
},
func=always_fail,
))
role = AgentInfo(
role_id="db_checker",
name="DB Checker",
responsibility="Check database connections",
tools=["check_database", "file_read"],
)
memory = MemoryManager(opc_home=workspace, project_id="test")
prefs = PreferenceManager(opc_home=workspace)
skills = SkillLibrary(opc_home=workspace)
config = OPCConfig(system=SystemConfig(max_agent_iterations=6))
agent = NativeAgent(
role=role, llm=llm, tool_registry=registry,
memory=memory, preferences=prefs, skills=skills,
event_bus=bus, config=config,
)
task = Task(
title="Check DB",
description="Use the check_database tool to verify the database is running. Keep trying until it works.",
assigned_to="db_checker",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Verify the failing tool was called multiple times
db_calls = [
e for e in collected
if e.event_type == "agent_log"
and e.payload.get("tool") == "check_database"
and e.payload.get("status") == "executing"
]
# Doom loop triggers after 3 identical failures; agent should have called it at least twice
assert len(db_calls) >= 1, "Expected at least 1 check_database call"
# Visual lifecycle should still close
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "db_checker")
assert_paired_events(visual, "db_checker")
# ═══════════════════════════════════════════════════════════════════════════════
# Test 10: Full kanban state tracking — task_created → ... → waiting
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration10_KanbanStateTracking:
"""Full kanban lifecycle: task_created → agent_active → tools → waiting."""
async def test_kanban_lifecycle(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
# Manually publish task_created event first (simulates engine behavior)
await bus.publish(OPCEvent(
event_type="task_created",
payload={"title": "Create hello.py and run it", "task_id": "kanban-task-001"},
))
agent = _make_agent(
role_id="implementer",
tools=["file_write", "shell_exec"],
responsibility="Implement and verify code",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=4,
)
task = Task(
id="kanban-task-001",
title="Create hello.py",
description=f"Create {workspace}/hello.py that prints 'hello world', then run it with python3.",
assigned_to="implementer",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Translate ALL events (including task_created)
visual = collect_visual(collected, adapter)
# Verify kanban lifecycle order
all_types = types(visual)
# task_routed should appear (from task_created)
assert "task_routed" in all_types, "Missing task_routed from task_created event"
task_routed_idx = all_types.index("task_routed")
# agent_active should appear after task_routed
assert "agent_active" in all_types, "Missing agent_active"
agent_active_idx = all_types.index("agent_active")
assert agent_active_idx > task_routed_idx, "agent_active should come after task_routed"
# waiting should be the last event for this agent
assert "waiting" in all_types, "Missing waiting"
# task_display_counter should be 1
assert adapter.task_display_counter == 1
# Full lifecycle check
assert_lifecycle(visual, "implementer")
assert_paired_events(visual, "implementer")
# Adapter state clean
tracker = adapter._get_tracker("implementer")
assert tracker.state == AgentAnimState.IDLE
assert tracker.task_id is None
# ═══════════════════════════════════════════════════════════════════════════════
# Test 11: Event ordering integrity — all events properly paired
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration11_EventOrderingIntegrity:
"""Verify strict event ordering: no orphaned starts, no nested pairs."""
async def test_strict_ordering(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="strict_dev",
tools=["file_read", "file_edit", "shell_exec", "list_dir"],
responsibility="Code development with careful verification",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=5,
)
task = Task(
title="Read and modify",
description=(
f"1. List the files in {workspace}/src/\n"
f"2. Read {workspace}/src/utils.py\n"
f"3. Tell me what functions are defined there"
),
assigned_to="strict_dev",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
visual = collect_visual(collected, adapter)
agent_vis = types_for(visual, "strict_dev")
# Structural validation: no two consecutive starts without a done in between
assert_paired_events(visual, "strict_dev")
# Verify agent_active is first, waiting is last
assert_lifecycle(visual, "strict_dev")
# Verify no visual event has empty agent_id
for v in visual:
assert v.get("agent_id"), f"Visual event missing agent_id: {v}"
# Verify all visual events have required fields
for v in visual:
assert "event_id" in v, f"Missing event_id: {v}"
assert "type" in v, f"Missing type: {v}"
assert "timestamp" in v, f"Missing timestamp: {v}"
# Verify reflect_start count == reflect_done count
starts = agent_vis.count("reflect_start")
dones = agent_vis.count("reflect_done")
assert starts == dones, f"reflect_start ({starts}) != reflect_done ({dones})"
# Same for tool_start/tool_done
ts = agent_vis.count("tool_start")
td = agent_vis.count("tool_done")
assert ts == td, f"tool_start ({ts}) != tool_done ({td})"
# ═══════════════════════════════════════════════════════════════════════════════
# Test 12: Multi-agent with dependency — second agent uses first's output
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration12_MultiAgentDependency:
"""Agent B depends on Agent A's output — sequential execution with handoff."""
async def test_dependency_handoff(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
# Agent A: write a data file
agent_a = _make_agent(
role_id="data_creator",
tools=["file_write"],
responsibility="Create data files",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task_a = Task(
title="Create data file",
description=f"Create a file at {workspace}/data.json with content: {{\"name\": \"OpenOPC\", \"version\": \"1.0\"}}",
assigned_to="data_creator",
)
result_a = await agent_a.execute(task_a)
assert result_a.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Agent B: read and process that file
agent_b = _make_agent(
role_id="data_reader",
tools=["file_read"],
responsibility="Read and analyze data files",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task_b = Task(
title="Read data file",
description=f"Read the file at {workspace}/data.json and tell me the project name and version.",
assigned_to="data_reader",
result={"previous_agent": "data_creator", "output": result_a.content[:500]},
)
result_b = await agent_b.execute(task_b)
assert result_b.status in (TaskStatus.DONE, TaskStatus.FAILED)
# Verify two distinct agent lifecycles in events
visual = collect_visual(collected, adapter)
# Both agents should have agent_active and waiting
creator_types = types_for(visual, "data_creator")
reader_types = types_for(visual, "data_reader")
assert "agent_active" in creator_types, "data_creator missing agent_active"
assert "waiting" in creator_types, "data_creator missing waiting"
assert "agent_active" in reader_types, "data_reader missing agent_active"
assert "waiting" in reader_types, "data_reader missing waiting"
# Verify proper pairing for each agent independently
assert_paired_events(visual, "data_creator")
assert_paired_events(visual, "data_reader")
# Verify creator finishes (waiting) before reader starts (agent_active)
creator_waiting_idx = None
reader_active_idx = None
for i, v in enumerate(visual):
if v.get("agent_id") == "data_creator" and v["type"] == "waiting":
creator_waiting_idx = i
if v.get("agent_id") == "data_reader" and v["type"] == "agent_active":
reader_active_idx = i
break
if creator_waiting_idx is not None and reader_active_idx is not None:
assert creator_waiting_idx < reader_active_idx, \
"data_reader started before data_creator finished"
# Both trackers should be idle
assert adapter._get_tracker("data_creator").state == AgentAnimState.IDLE
assert adapter._get_tracker("data_reader").state == AgentAnimState.IDLE
# ═══════════════════════════════════════════════════════════════════════════════
# Test 13: Agent with python_exec tool
# ═══════════════════════════════════════════════════════════════════════════════
class TestIntegration13_PythonExec:
"""Agent uses python_exec to run a computation — verify shell mapping."""
async def test_python_exec_event(self, llm, event_collector, adapter, workspace):
bus, collected = event_collector
agent = _make_agent(
role_id="calculator",
tools=["python_exec"],
responsibility="Run Python computations",
llm=llm, event_bus=bus, opc_home=workspace,
max_iterations=3,
)
task = Task(
title="Calculate factorial",
description="Use python_exec to calculate the factorial of 10 and tell me the result.",
assigned_to="calculator",
)
result = await agent.execute(task)
assert result.status in (TaskStatus.DONE, TaskStatus.FAILED)
visual = collect_visual(collected, adapter)
assert_lifecycle(visual, "calculator")
assert_paired_events(visual, "calculator")
# python_exec maps to "shell" in TOOL_MAP
py_used = any(
e.event_type == "agent_log" and e.payload.get("tool") == "python_exec"
for e in collected
)
if py_used:
shell_visuals = [
v for v in visual
if v["type"] == "tool_start" and v["data"].get("tool_name") == "shell"
]
assert len(shell_visuals) >= 1, "python_exec maps to shell but no tool_start(shell) found"