"""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"