diff --git a/.github/workflows/release-packages.yml b/.github/workflows/release-packages.yml index b3590da..0eb45fe 100644 --- a/.github/workflows/release-packages.yml +++ b/.github/workflows/release-packages.yml @@ -288,13 +288,17 @@ jobs: EOF # DNF/YUM repo file + # metadata_expire=300 ensures dnf checks for new packages every 5 minutes + # (GitHub Pages CDN caches for max 10 minutes, so 5 min is a safe middle ground) cat > repo/xnoto.repo << 'EOF' [xnoto] name=xnoto packages baseurl=https://xnoto.github.io/opencode-agent-hub/rpm enabled=1 gpgcheck=1 + repo_gpgcheck=1 gpgkey=https://xnoto.github.io/opencode-agent-hub/KEY.gpg + metadata_expire=300 EOF - name: Create index page diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0d2ef46..3286ec8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -51,15 +51,16 @@ This project uses [Conventional Commits](https://www.conventionalcommits.org/) ( | `perf` | Performance improvement | Patch | | `ci` | CI/CD changes | None | -### Scope (required) +### Scope (recommended) Use a scope to indicate what area is affected: - `daemon` - Main daemon code - `watch` - Dashboard script -- `config` - Configuration/env vars +- `config` - Configuration/env vars/config file - `docs` - Documentation - `ci` - GitHub Actions - `deps` - Dependencies +- `tests` - Test files ### Examples @@ -95,18 +96,32 @@ Releases are automated via [release-please](https://github.com/google-github-act ``` ~/.agent-hub/ -├── agents/ # Agent registration files -├── messages/ # Message queue (JSON files) -│ └── archive/ # Processed messages -└── threads/ # Conversation tracking +├── agents/ # Agent registration files +├── messages/ # Message queue (JSON files) +│ └── archive/ # Processed messages +├── threads/ # Conversation tracking +├── session_agents.json # Session-to-agent identity mapping +└── oriented_sessions.json # Session orientation cache + +~/.config/agent-hub-daemon/ +├── config.json # Optional config file (env vars override) +├── AGENTS.md # Optional coordinator instructions override +└── COORDINATOR.md # Alias for AGENTS.md Daemon watches these directories and: 1. Detects new messages via watchdog -2. Looks up target agent's OpenCode session +2. Looks up target agent's OpenCode session (by session ID, not directory) 3. Injects message via OpenCode HTTP API 4. Marks message as delivered ``` +### Session-Based Agent Identity + +Multiple OpenCode sessions in the same directory each get a unique agent identity: +- Agent ID derived from session slug (e.g., "cosmic-panda") or session ID +- Enables parallel agents working on the same codebase without conflicts +- Session-agent mapping persisted in `~/.agent-hub/session_agents.json` + ## Testing Tests use pytest: diff --git a/README.md b/README.md index 4a04610..db05a47 100644 --- a/README.md +++ b/README.md @@ -53,10 +53,12 @@ Enables multiple AI agents running in separate OpenCode sessions to communicate, - **Message Bus**: Filesystem-based message passing between agents via `~/.agent-hub/messages/` - **Session Integration**: Automatically discovers and injects messages into OpenCode sessions - **Thread Management**: Conversations tracked with auto-created thread IDs and resolution -- **Agent Auto-Registration**: Sessions automatically registered as agents by project path +- **Session-Based Agent Identity**: Each OpenCode session gets a unique agent ID (even in the same directory) +- **Agent Auto-Registration**: Sessions automatically registered as agents with unique identities - **Garbage Collection**: Stale messages, agents, and threads cleaned up (1hr TTL) - **Prometheus Metrics**: Exportable metrics at `~/.agent-hub/metrics.prom` - **Dashboard**: Real-time terminal UI showing agents, threads, and messages +- **Config File Support**: Optional JSON config file at `~/.config/agent-hub-daemon/config.json` ## How It Works @@ -66,10 +68,12 @@ The daemon operates as a **message broker** between OpenCode sessions, using a l 1. **Daemon starts** an OpenCode relay server on port 4096 (if not already running) 2. **Polls the relay API** (`GET /session`) every 5 seconds to discover active sessions -3. **Auto-registers agents** based on each session's working directory (project path) +3. **Auto-registers agents** with unique identities derived from session slug or ID 4. **Injects an orientation message** into newly discovered sessions, informing the agent of its registered identity 5. **Notifies the coordinator** to capture the agent's task and introduce related agents +**Note**: Multiple sessions in the same directory each get unique agent IDs (e.g., "cosmic-panda", "brave-tiger"), enabling parallel agents working on the same codebase. + ### Message Flow When Agent A sends a message to Agent B: @@ -440,12 +444,67 @@ uv run src/opencode_agent_hub/watch.py ## Configuration -Configuration via environment variables: +The daemon supports configuration via a JSON config file and/or environment variables. + +**Precedence**: Environment variables > Config file > Defaults + +### Config File + +Create `~/.config/agent-hub-daemon/config.json`: + +```json +{ + "opencode_port": 4096, + "log_level": "INFO", + "rate_limit": { + "enabled": false, + "max_messages": 10, + "window_seconds": 300, + "cooldown_seconds": 0 + }, + "coordinator": { + "enabled": true, + "model": "opencode/claude-opus-4-5", + "directory": "~/.agent-hub/coordinator", + "agents_md": "" + }, + "gc": { + "message_ttl_seconds": 3600, + "agent_stale_seconds": 3600, + "interval_seconds": 60 + }, + "session": { + "poll_seconds": 5, + "cache_ttl": 10 + }, + "injection": { + "workers": 4, + "retries": 3, + "timeout": 5 + }, + "metrics_interval": 30 +} +``` + +All fields are optional - only specify what you want to override. + +### Environment Variables + +Environment variables take precedence over config file values: | Variable | Default | Description | |----------|---------|-------------| | `OPENCODE_PORT` | `4096` | Port for OpenCode relay server | | `AGENT_HUB_DAEMON_LOG_LEVEL` | `INFO` | Logging level (DEBUG, INFO, WARNING, ERROR) | +| `AGENT_HUB_MESSAGE_TTL` | `3600` | Message TTL in seconds | +| `AGENT_HUB_AGENT_STALE` | `3600` | Agent stale threshold in seconds | +| `AGENT_HUB_GC_INTERVAL` | `60` | Garbage collection interval in seconds | +| `AGENT_HUB_SESSION_POLL` | `5` | Session poll interval in seconds | +| `AGENT_HUB_SESSION_CACHE_TTL` | `10` | Session cache TTL in seconds | +| `AGENT_HUB_INJECTION_WORKERS` | `4` | Number of injection worker threads | +| `AGENT_HUB_INJECTION_RETRIES` | `3` | Injection retry attempts | +| `AGENT_HUB_INJECTION_TIMEOUT` | `5` | Injection timeout in seconds | +| `AGENT_HUB_METRICS_INTERVAL` | `30` | Metrics write interval in seconds | ### Rate Limiting (Optional) @@ -480,6 +539,7 @@ Configuration via environment variables: | `AGENT_HUB_COORDINATOR` | `true` | Enable the coordinator agent (`true`, `1`, or `yes`) | | `AGENT_HUB_COORDINATOR_MODEL` | `opencode/claude-opus-4-5` | OpenCode model for the coordinator session | | `AGENT_HUB_COORDINATOR_DIR` | `~/.agent-hub/coordinator` | Directory used for the coordinator session | +| `AGENT_HUB_COORDINATOR_AGENTS_MD` | (auto-detect) | Custom path to coordinator AGENTS.md | Example - run coordinator on a different model: @@ -487,16 +547,45 @@ Example - run coordinator on a different model: export AGENT_HUB_COORDINATOR_MODEL=opencode/claude-sonnet-4-5 ``` +#### Custom Coordinator Instructions + +You can customize the coordinator's behavior by providing your own AGENTS.md file. The daemon searches for the template in this order: + +1. **Explicit config**: `AGENT_HUB_COORDINATOR_AGENTS_MD` env var or `coordinator.agents_md` in config file +2. **User config**: `~/.config/agent-hub-daemon/AGENTS.md` +3. **User config alias**: `~/.config/agent-hub-daemon/COORDINATOR.md` +4. **Package template**: `contrib/coordinator/AGENTS.md` (from installation) +5. **System locations**: `/usr/local/share/opencode-agent-hub/coordinator/AGENTS.md` + +If no template is found, a minimal default is created. To customize: + +```bash +# Copy the default template and edit +mkdir -p ~/.config/agent-hub-daemon +cp /path/to/opencode-agent-hub/contrib/coordinator/AGENTS.md ~/.config/agent-hub-daemon/ +# Edit to your liking +``` + +Or specify an explicit path: + +```bash +export AGENT_HUB_COORDINATOR_AGENTS_MD=~/my-coordinator-instructions.md +``` + ## Directory Structure ``` ~/.agent-hub/ -├── agents/ # Registered agent JSON files -├── messages/ # Pending messages (JSON files) -│ └── archive/ # Processed/expired messages -├── threads/ # Conversation thread tracking -├── metrics.prom # Prometheus metrics export -└── oriented_sessions.json # Session orientation cache +├── agents/ # Registered agent JSON files +├── messages/ # Pending messages (JSON files) +│ └── archive/ # Processed/expired messages +├── threads/ # Conversation thread tracking +├── metrics.prom # Prometheus metrics export +├── oriented_sessions.json # Session orientation cache +└── session_agents.json # Session-to-agent identity mapping + +~/.config/agent-hub-daemon/ +└── config.json # Optional config file ``` ## Message Format diff --git a/src/opencode_agent_hub/daemon.py b/src/opencode_agent_hub/daemon.py index ff2c811..16344f3 100755 --- a/src/opencode_agent_hub/daemon.py +++ b/src/opencode_agent_hub/daemon.py @@ -71,7 +71,13 @@ from watchdog.events import FileSystemEvent, FileSystemEventHandler from watchdog.observers import Observer +# ============================================================================= # Configuration +# ============================================================================= +# Precedence: environment variables > config file > defaults +# Config file: ~/.config/agent-hub-daemon/config.json + +# Static paths (not configurable) AGENT_HUB_DIR = Path.home() / ".agent-hub" MESSAGES_DIR = AGENT_HUB_DIR / "messages" ARCHIVE_DIR = MESSAGES_DIR / "archive" @@ -82,43 +88,172 @@ OPENCODE_SESSIONS_DIR = ( OPENCODE_DATA_DIR / "storage/session" ) # Watch all project subdirs, not just global -OPENCODE_PORT = int(os.environ.get("OPENCODE_PORT", "4096")) +CONFIG_DIR = Path.home() / ".config" / "agent-hub-daemon" +CONFIG_FILE = CONFIG_DIR / "config.json" + + +def _load_config_file() -> dict: + """Load configuration from JSON file if it exists.""" + if not CONFIG_FILE.exists(): + return {} + try: + return json.loads(CONFIG_FILE.read_text()) + except (json.JSONDecodeError, OSError): + # Log warning later after logging is set up + return {} + + +def _get_config_value( + env_var: str, + config_path: list[str], + default: str | int | bool, + config: dict, + type_: type = str, +) -> str | int | bool: + """Get config value with precedence: env var > config file > default. + + Args: + env_var: Environment variable name + config_path: Path in config dict (e.g., ["rate_limit", "enabled"]) + default: Default value + config: Loaded config dict + type_: Expected type (str, int, or bool) + """ + # Check environment variable first + env_value = os.environ.get(env_var) + if env_value is not None: + if type_ is bool: + return env_value.lower() in ("1", "true", "yes") + elif type_ is int: + return int(env_value) + return env_value + + # Check config file - traverse path to get leaf value + value: str | int | bool | dict | None = config + for key in config_path: + if isinstance(value, dict) and key in value: + value = value[key] + else: + return default + + # If we got a dict, the path didn't reach a leaf - return default + if isinstance(value, dict): + return default + + # Type coercion for config file values + if type_ is bool: + if isinstance(value, bool): + return value + if isinstance(value, str): + return value.lower() in ("1", "true", "yes") + elif type_ is int: + if isinstance(value, int) and not isinstance(value, bool): + return value + if isinstance(value, str): + return int(value) + + # Return value if it matches expected type, otherwise default + if value is None: + return default + return cast("str | int | bool", value) + + +# Load config file once at module load +_CONFIG = _load_config_file() + +# OpenCode connection +OPENCODE_PORT = int(_get_config_value("OPENCODE_PORT", ["opencode_port"], 4096, _CONFIG, int)) OPENCODE_URL = f"http://localhost:{OPENCODE_PORT}" -LOG_LEVEL = os.environ.get("AGENT_HUB_DAEMON_LOG_LEVEL", "INFO") - -# Expiry settings -MESSAGE_TTL_SECONDS = 3600 # 1 hour -AGENT_STALE_SECONDS = 3600 # 1 hour -GC_INTERVAL_SECONDS = 60 # Run GC every 60 seconds -SESSION_POLL_SECONDS = 5 # Poll for new active sessions every 5 seconds -SESSION_CACHE_TTL = 10 # Cache sessions for 10 seconds -INJECTION_WORKERS = 4 # Concurrent injection workers -INJECTION_RETRIES = 3 # Retry failed injections -INJECTION_TIMEOUT = 5 # Shorter timeout for injections - -# Rate limiting settings (disabled by default, enable via env vars) +LOG_LEVEL = str(_get_config_value("AGENT_HUB_DAEMON_LOG_LEVEL", ["log_level"], "INFO", _CONFIG)) + +# Expiry/timing settings +MESSAGE_TTL_SECONDS = int( + _get_config_value("AGENT_HUB_MESSAGE_TTL", ["gc", "message_ttl_seconds"], 3600, _CONFIG, int) +) +AGENT_STALE_SECONDS = int( + _get_config_value("AGENT_HUB_AGENT_STALE", ["gc", "agent_stale_seconds"], 3600, _CONFIG, int) +) +GC_INTERVAL_SECONDS = int( + _get_config_value("AGENT_HUB_GC_INTERVAL", ["gc", "interval_seconds"], 60, _CONFIG, int) +) +SESSION_POLL_SECONDS = int( + _get_config_value("AGENT_HUB_SESSION_POLL", ["session", "poll_seconds"], 5, _CONFIG, int) +) +SESSION_CACHE_TTL = int( + _get_config_value("AGENT_HUB_SESSION_CACHE_TTL", ["session", "cache_ttl"], 10, _CONFIG, int) +) +INJECTION_WORKERS = int( + _get_config_value("AGENT_HUB_INJECTION_WORKERS", ["injection", "workers"], 4, _CONFIG, int) +) +INJECTION_RETRIES = int( + _get_config_value("AGENT_HUB_INJECTION_RETRIES", ["injection", "retries"], 3, _CONFIG, int) +) +INJECTION_TIMEOUT = int( + _get_config_value("AGENT_HUB_INJECTION_TIMEOUT", ["injection", "timeout"], 5, _CONFIG, int) +) +METRICS_INTERVAL = int( + _get_config_value("AGENT_HUB_METRICS_INTERVAL", ["metrics_interval"], 30, _CONFIG, int) +) + +# Rate limiting settings (disabled by default) # RATE_LIMIT_ENABLED: Enable per-agent message rate limiting # RATE_LIMIT_MAX_MESSAGES: Max messages per agent per window (default: 10) # RATE_LIMIT_WINDOW_SECONDS: Time window for rate limiting (default: 300 = 5 min) # RATE_LIMIT_COOLDOWN_SECONDS: Minimum seconds between messages from same agent (default: 0) -RATE_LIMIT_ENABLED = os.environ.get("AGENT_HUB_RATE_LIMIT", "").lower() in ("1", "true", "yes") -RATE_LIMIT_MAX_MESSAGES = int(os.environ.get("AGENT_HUB_RATE_LIMIT_MAX", "10")) -RATE_LIMIT_WINDOW_SECONDS = int(os.environ.get("AGENT_HUB_RATE_LIMIT_WINDOW", "300")) -RATE_LIMIT_COOLDOWN_SECONDS = int(os.environ.get("AGENT_HUB_RATE_LIMIT_COOLDOWN", "0")) +RATE_LIMIT_ENABLED = bool( + _get_config_value("AGENT_HUB_RATE_LIMIT", ["rate_limit", "enabled"], False, _CONFIG, bool) +) +RATE_LIMIT_MAX_MESSAGES = int( + _get_config_value("AGENT_HUB_RATE_LIMIT_MAX", ["rate_limit", "max_messages"], 10, _CONFIG, int) +) +RATE_LIMIT_WINDOW_SECONDS = int( + _get_config_value( + "AGENT_HUB_RATE_LIMIT_WINDOW", ["rate_limit", "window_seconds"], 300, _CONFIG, int + ) +) +RATE_LIMIT_COOLDOWN_SECONDS = int( + _get_config_value( + "AGENT_HUB_RATE_LIMIT_COOLDOWN", ["rate_limit", "cooldown_seconds"], 0, _CONFIG, int + ) +) # Coordinator settings # The coordinator is a dedicated OpenCode session that facilitates agent collaboration # COORDINATOR_ENABLED: Enable the coordinator agent (default: true) # COORDINATOR_MODEL: OpenCode model for coordinator (default: opencode/claude-opus-4-5) # COORDINATOR_DIR: Directory for coordinator session (default: ~/.agent-hub/coordinator) -COORDINATOR_ENABLED = os.environ.get("AGENT_HUB_COORDINATOR", "true").lower() in ( - "1", - "true", - "yes", +# COORDINATOR_AGENTS_MD: Custom path to coordinator AGENTS.md (default: auto-detect) +COORDINATOR_ENABLED = bool( + _get_config_value("AGENT_HUB_COORDINATOR", ["coordinator", "enabled"], True, _CONFIG, bool) +) +COORDINATOR_MODEL = str( + _get_config_value( + "AGENT_HUB_COORDINATOR_MODEL", + ["coordinator", "model"], + "opencode/claude-opus-4-5", + _CONFIG, + ) ) -COORDINATOR_MODEL = os.environ.get("AGENT_HUB_COORDINATOR_MODEL", "opencode/claude-opus-4-5") -COORDINATOR_DIR = Path( - os.environ.get("AGENT_HUB_COORDINATOR_DIR", str(AGENT_HUB_DIR / "coordinator")) +_coordinator_dir_str = str( + _get_config_value( + "AGENT_HUB_COORDINATOR_DIR", + ["coordinator", "directory"], + str(AGENT_HUB_DIR / "coordinator"), + _CONFIG, + ) +) +COORDINATOR_DIR = Path(os.path.expanduser(_coordinator_dir_str)) +_coordinator_agents_md_str = str( + _get_config_value( + "AGENT_HUB_COORDINATOR_AGENTS_MD", + ["coordinator", "agents_md"], + "", # Empty string means auto-detect + _CONFIG, + ) +) +# None means auto-detect, otherwise use the specified path +COORDINATOR_AGENTS_MD: Path | None = ( + Path(os.path.expanduser(_coordinator_agents_md_str)) if _coordinator_agents_md_str else None ) # Track message timestamps per agent for rate limiting @@ -127,6 +262,12 @@ # Track sessions that have been oriented (session_id -> True) ORIENTED_SESSIONS: set[str] = set() +# Session-to-agent mapping: maps session_id to agent info +# This enables multiple sessions in the same directory to have unique agent identities +# Structure: {session_id: {"agentId": str, "directory": str, "slug": str | None}} +SESSION_AGENTS: dict[str, dict] = {} +SESSION_AGENTS_FILE = AGENT_HUB_DIR / "session_agents.json" + # Daemon start time - only orient sessions created after this DAEMON_START_TIME_MS: int = int(time.time() * 1000) @@ -288,7 +429,6 @@ def log_summary(self) -> str: # Metrics file location METRICS_FILE = AGENT_HUB_DIR / "metrics.prom" -METRICS_INTERVAL = 30 # Write metrics every 30 seconds def save_oriented_sessions() -> None: @@ -300,6 +440,26 @@ def save_oriented_sessions() -> None: log.warning(f"Failed to save oriented sessions: {e}") +def save_session_agents() -> None: + """Save session-to-agent mapping to disk.""" + try: + AGENT_HUB_DIR.mkdir(parents=True, exist_ok=True) + SESSION_AGENTS_FILE.write_text(json.dumps(SESSION_AGENTS, indent=2)) + except OSError as e: + log.warning(f"Failed to save session agents: {e}") + + +def load_session_agents() -> dict[str, dict]: + """Load session-to-agent mapping from disk.""" + if not SESSION_AGENTS_FILE.exists(): + return {} + try: + return json.loads(SESSION_AGENTS_FILE.read_text()) + except (json.JSONDecodeError, OSError) as e: + log.warning(f"Failed to load session agents: {e}") + return {} + + # Hub server process (launched by daemon if needed) HUB_SERVER_PROCESS: subprocess.Popen | None = None @@ -583,6 +743,42 @@ def gc_oriented_sessions() -> int: return 0 +def gc_session_agents() -> int: + """Remove session-agent mappings for sessions that no longer exist. + + This prevents the SESSION_AGENTS mapping from growing unbounded + and ensures stale session references are cleaned up. + + Returns number of mappings cleaned. + """ + global SESSION_AGENTS + + if not SESSION_AGENTS: + return 0 + + # Get current sessions from API + current_sessions = get_sessions() + if not current_sessions: + return 0 # Don't clear on API failure + + # Build set of current session IDs + current_ids = {s.get("id", "") for s in current_sessions if s.get("id")} + + # Find session-agent mappings for non-existent sessions + stale_session_ids = [sid for sid in SESSION_AGENTS if sid not in current_ids] + + if stale_session_ids: + for sid in stale_session_ids: + del SESSION_AGENTS[sid] + save_session_agents() + log.info( + f"GC: Removed {len(stale_session_ids)} stale session-agent mappings, " + f"{len(SESSION_AGENTS)} remaining" + ) + return len(stale_session_ids) + return 0 + + def run_gc(agents: dict[str, dict]) -> None: """Run garbage collection on messages, threads, stale agents, and oriented sessions.""" now_ms = int(time.time() * 1000) @@ -594,6 +790,9 @@ def run_gc(agents: dict[str, dict]) -> None: # 0. Clean up oriented sessions - keep only sessions that still exist in API sessions_cleaned = gc_oriented_sessions() + # 0.5. Clean up session-agent mappings for non-existent sessions + gc_session_agents() + # 1. Remove stale agents (>1hr since lastSeen) if AGENTS_DIR.exists(): for agent_path in AGENTS_DIR.glob("*.json"): @@ -603,15 +802,23 @@ def run_gc(agents: dict[str, dict]) -> None: age_ms = now_ms - last_seen if age_ms > AGENT_STALE_SECONDS * 1000: agent_id = agent.get("id", agent_path.stem) + session_id = agent.get("sessionId") agent_path.unlink() # Remove from in-memory cache too agents.pop(agent_id, None) + # Also remove from session-agent mapping + if session_id and session_id in SESSION_AGENTS: + del SESSION_AGENTS[session_id] agents_cleaned += 1 log.info(f"Removed stale agent {agent_id} (age: {age_ms / 1000 / 60:.0f}m)") except (json.JSONDecodeError, OSError) as e: log.warning(f"Failed to check agent {agent_path}: {e}") continue + # Save session agents if any were cleaned + if agents_cleaned > 0: + save_session_agents() + # 2. Archive expired messages (>1hr old) for msg_path in MESSAGES_DIR.glob("*.json"): try: @@ -845,11 +1052,63 @@ def stop_hub_server() -> None: # ============================================================================= +def find_coordinator_agents_md_template() -> Path | None: + """Find the AGENTS.md template for the coordinator. + + Search order: + 1. Explicit config/env var path (COORDINATOR_AGENTS_MD) + 2. ~/.config/agent-hub-daemon/AGENTS.md (user override) + 3. ~/.config/agent-hub-daemon/COORDINATOR.md (alias) + 4. Package contrib/coordinator/AGENTS.md + 5. ~/.local/share/opencode-agent-hub/coordinator/AGENTS.md + 6. /usr/local/share/opencode-agent-hub/coordinator/AGENTS.md + + Returns the first existing path, or None if no template found. + """ + # 1. Explicit config path takes highest priority + if COORDINATOR_AGENTS_MD is not None: + if COORDINATOR_AGENTS_MD.exists(): + return COORDINATOR_AGENTS_MD + else: + log.warning(f"Configured coordinator AGENTS.md not found: {COORDINATOR_AGENTS_MD}") + # Fall through to other locations + + # 2-3. User config directory overrides + user_config_locations = [ + CONFIG_DIR / "AGENTS.md", + CONFIG_DIR / "COORDINATOR.md", + ] + + for path in user_config_locations: + if path.exists(): + return path + + # 4-6. Package and system locations + system_locations = [ + Path(__file__).parent.parent.parent / "contrib" / "coordinator" / "AGENTS.md", + Path.home() / ".local/share/opencode-agent-hub/coordinator/AGENTS.md", + Path("/usr/local/share/opencode-agent-hub/coordinator/AGENTS.md"), + ] + + for path in system_locations: + if path.exists(): + return path + + return None + + def setup_coordinator_directory() -> bool: """Set up the coordinator directory with AGENTS.md. - Copies the AGENTS.md template from contrib/coordinator/ if available, + Copies the AGENTS.md template from the first found location, otherwise creates a minimal version. + + Template search order (see find_coordinator_agents_md_template): + 1. Explicit config/env var path (COORDINATOR_AGENTS_MD) + 2. ~/.config/agent-hub-daemon/AGENTS.md (user override) + 3. ~/.config/agent-hub-daemon/COORDINATOR.md (alias) + 4. Package contrib/coordinator/AGENTS.md + 5. System share locations """ COORDINATOR_DIR.mkdir(parents=True, exist_ok=True) agents_md = COORDINATOR_DIR / "AGENTS.md" @@ -857,18 +1116,12 @@ def setup_coordinator_directory() -> bool: if agents_md.exists(): return True - # Try to find the template in common locations - template_locations = [ - Path(__file__).parent.parent.parent / "contrib" / "coordinator" / "AGENTS.md", - Path.home() / ".local/share/opencode-agent-hub/coordinator/AGENTS.md", - Path("/usr/local/share/opencode-agent-hub/coordinator/AGENTS.md"), - ] - - for template in template_locations: - if template.exists(): - shutil.copy(template, agents_md) - log.info(f"Copied coordinator AGENTS.md from {template}") - return True + # Find and copy template + template = find_coordinator_agents_md_template() + if template is not None: + shutil.copy(template, agents_md) + log.info(f"Copied coordinator AGENTS.md from {template}") + return True # Create minimal AGENTS.md if no template found minimal_agents_md = """# Coordinator Agent @@ -1059,12 +1312,24 @@ def invalidate_session_cache() -> None: def find_sessions_for_agent(agent: dict, sessions: list[dict]) -> list[dict]: - """Find the most recent session for an agent's projectPath. + """Find the session for an agent by session ID. - Only returns the single most recently updated session to avoid - spamming historical sessions with messages. + Uses session ID-based lookup for precise routing. Each agent is now + associated with exactly one session, enabling multiple agents to + operate in the same working directory. + + Falls back to directory-based matching for legacy agents without sessionId. """ + # Primary: session ID-based lookup (new behavior) + session = find_session_for_agent(agent, sessions) + if session: + return [session] + + # Fallback: directory-based matching for legacy agents agent_path = agent.get("projectPath", "") + if not agent_path: + return [] + matching = [s for s in sessions if s.get("directory") == agent_path] if not matching: return [] @@ -1239,9 +1504,125 @@ def get_or_create_agent_for_directory(directory: str, agents: dict[str, dict]) - return agent +def generate_agent_id_for_session(session: dict) -> str: + """Generate a unique agent ID from session metadata. + + Uses session slug if available (human-readable), otherwise falls back + to session ID. This ensures each session gets a unique agent identity + even when multiple sessions share the same working directory. + """ + slug = session.get("slug") + session_id = session.get("id", "") + + if slug: + # Use slug as primary identifier (e.g., "cosmic-panda") + return slug + + # Fallback to session ID (truncated for readability) + if session_id.startswith("ses_"): + # Use the unique portion after "ses_" prefix, truncated + return f"session-{session_id[4:16]}" + + return f"session-{session_id[:12]}" if session_id else "unknown-session" + + +def get_or_create_agent_for_session(session: dict, agents: dict[str, dict]) -> dict: + """Find or auto-create an agent for a specific session. + + Unlike get_or_create_agent_for_directory(), this creates a unique agent + identity per session, allowing multiple TUI sessions in the same directory + to have separate agent identities. + + The agent ID is derived from the session's slug (if available) or session ID, + ensuring uniqueness across all sessions. + """ + session_id = session.get("id", "") + directory = session.get("directory", "") + + # Check if we already have a mapping for this session + if session_id in SESSION_AGENTS: + agent_id = SESSION_AGENTS[session_id]["agentId"] + if agent_id in agents: + return agents[agent_id] + + # Generate unique agent ID from session + agent_id = generate_agent_id_for_session(session) + + # Handle conflicts by appending session ID fragment + if agent_id in agents and agents[agent_id].get("sessionId") != session_id: + # Different session has this slug - append uniquifier + agent_id = f"{agent_id}-{session_id[4:12]}" if session_id.startswith("ses_") else agent_id + + agent = { + "id": agent_id, + "sessionId": session_id, # Track which session this agent represents + "projectPath": directory, # Keep for reference/display + "slug": session.get("slug"), + "role": f"Session agent for {session.get('title', directory)[:50]}", + "capabilities": [], + "collaboratesWith": [], + "lastSeen": int(time.time() * 1000), + "status": "active", + "autoCreated": True, + } + + # Update session-to-agent mapping + SESSION_AGENTS[session_id] = { + "agentId": agent_id, + "directory": directory, + "slug": session.get("slug"), + } + save_session_agents() + + # Save agent to disk + agent_file = AGENTS_DIR / f"{agent_id}.json" + try: + agent_file.write_text(json.dumps(agent, indent=2)) + agents[agent_id] = agent + metrics.inc("agent_hub_agents_auto_created_total") + metrics.set_gauge("agent_hub_active_agents", len(agents)) + log.info(f"Auto-registered session agent '{agent_id}' for session {session_id[:12]}") + except OSError as e: + log.error(f"Failed to save auto-created session agent: {e}") + + return agent + + +def find_session_for_agent(agent: dict, sessions: list[dict]) -> dict | None: + """Find the session associated with an agent by session ID. + + This replaces directory-based matching with direct session ID lookup, + enabling precise routing to specific sessions. + """ + agent_session_id = agent.get("sessionId") + if not agent_session_id: + # Fallback for legacy agents without sessionId - check SESSION_AGENTS mapping + agent_id = agent.get("id", "") + for sid, mapping in SESSION_AGENTS.items(): + if mapping.get("agentId") == agent_id: + agent_session_id = sid + break + + if not agent_session_id: + return None + + for session in sessions: + if session.get("id") == agent_session_id: + return session + + return None + + def format_orientation(agent: dict, all_agents: dict[str, dict]) -> str: - """Format minimal orientation message for a newly detected agent session.""" + """Format orientation message for a newly detected agent session. + + Includes registration instructions with the session-specific agent ID. + This ensures the agent registers with agent-hub-mcp using the unique + ID assigned by the daemon, enabling multiple sessions in the same + directory to have separate identities. + """ agent_id = agent.get("id", "unknown") + directory = agent.get("projectPath", "") # List other active agents (exclude self) other_agents = [aid for aid, a in all_agents.items() if aid != agent_id and is_agent_active(a)] @@ -1256,6 +1637,13 @@ def format_orientation(agent: dict, all_agents: dict[str, dict]) -> str: parts.append("Tools: agent-hub_send_message, agent-hub_sync") + # Add registration instruction with session-specific ID + # This ensures agent-hub-mcp creates a unique agent entry for this session + parts.append( + f'Register with: agent-hub_register_agent(id="{agent_id}", ' + f'projectPath="{directory}", role="your role")' + ) + return " | ".join(parts) @@ -1295,6 +1683,7 @@ def process_session_file(path: Path, agents: dict[str, dict]) -> None: """Process an OpenCode session file and orient if needed. Only orients sessions created AFTER the daemon started. + Creates a unique agent identity per session using session ID/slug. """ session = load_opencode_session(path) if not session: @@ -1317,9 +1706,9 @@ def process_session_file(path: Path, agents: dict[str, dict]) -> None: if not directory: return - # Get or auto-create agent for this directory - agent = get_or_create_agent_for_directory(directory, agents) - log.info(f"File watcher: new session {session_id[:8]}, orienting") + # Get or auto-create agent for this session (unique per session) + agent = get_or_create_agent_for_session(session, agents) + log.info(f"File watcher: new session {session_id[:8]}, orienting as {agent.get('id')}") orient_session(session_id, agent, agents) @@ -1332,7 +1721,8 @@ def poll_active_sessions(agents: dict[str, dict]) -> None: - Daemon restart gives a clean slate Sessions are oriented once and tracked in ORIENTED_SESSIONS to prevent - repeated messaging. + repeated messaging. Each session gets a unique agent identity based on + its session ID/slug, allowing multiple sessions in the same directory. """ sessions = get_sessions() if not sessions: @@ -1352,9 +1742,9 @@ def poll_active_sessions(agents: dict[str, dict]) -> None: if not directory: continue - # Get or auto-create agent for this directory - agent = get_or_create_agent_for_directory(directory, agents) - log.info(f"New session {session_id[:8]} created after daemon start, orienting") + # Get or auto-create agent for this session (unique per session) + agent = get_or_create_agent_for_session(session, agents) + log.info(f"New session {session_id[:8]} orienting as {agent.get('id')}") orient_session(session_id, agent, agents) @@ -1751,12 +2141,14 @@ def main(): AGENTS_DIR.mkdir(parents=True, exist_ok=True) # Load persisted state - # Fresh start: clear oriented sessions from previous runs + # Fresh start: clear oriented sessions and session-agent mappings from previous runs # Only sessions created AFTER daemon starts will be oriented - global ORIENTED_SESSIONS, DAEMON_START_TIME_MS + global ORIENTED_SESSIONS, SESSION_AGENTS, DAEMON_START_TIME_MS DAEMON_START_TIME_MS = int(time.time() * 1000) ORIENTED_SESSIONS = set() save_oriented_sessions() + SESSION_AGENTS = {} + save_session_agents() log.info(f"Daemon starting at {DAEMON_START_TIME_MS} - only new sessions will be oriented") log.info(f"Watching messages: {MESSAGES_DIR}") diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 0000000..5b0714a --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,181 @@ +"""Tests for configuration file support.""" + +import json +import os +import tempfile +from pathlib import Path +from unittest import mock + + +def test_get_config_value_env_precedence(): + """Verify environment variables take precedence over config file.""" + from opencode_agent_hub.daemon import _get_config_value + + config = {"opencode_port": 5000} + + # Env var should override config + with mock.patch.dict(os.environ, {"OPENCODE_PORT": "6000"}): + value = _get_config_value("OPENCODE_PORT", ["opencode_port"], 4096, config, int) + assert value == 6000 + + +def test_get_config_value_config_file(): + """Verify config file values are used when env var not set.""" + from opencode_agent_hub.daemon import _get_config_value + + config = {"opencode_port": 5000} + + # Clear env var to ensure config file is used + with mock.patch.dict(os.environ, {}, clear=True): + # Remove OPENCODE_PORT if it exists + os.environ.pop("OPENCODE_PORT", None) + value = _get_config_value("OPENCODE_PORT", ["opencode_port"], 4096, config, int) + assert value == 5000 + + +def test_get_config_value_default(): + """Verify default is used when neither env var nor config file has value.""" + from opencode_agent_hub.daemon import _get_config_value + + config = {} + + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("OPENCODE_PORT", None) + value = _get_config_value("OPENCODE_PORT", ["opencode_port"], 4096, config, int) + assert value == 4096 + + +def test_get_config_value_nested_path(): + """Verify nested config paths work correctly.""" + from opencode_agent_hub.daemon import _get_config_value + + config = { + "rate_limit": { + "enabled": True, + "max_messages": 20, + } + } + + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("AGENT_HUB_RATE_LIMIT", None) + os.environ.pop("AGENT_HUB_RATE_LIMIT_MAX", None) + + enabled = _get_config_value( + "AGENT_HUB_RATE_LIMIT", ["rate_limit", "enabled"], False, config, bool + ) + assert enabled is True + + max_msgs = _get_config_value( + "AGENT_HUB_RATE_LIMIT_MAX", ["rate_limit", "max_messages"], 10, config, int + ) + assert max_msgs == 20 + + +def test_get_config_value_bool_coercion(): + """Verify boolean string coercion works.""" + from opencode_agent_hub.daemon import _get_config_value + + # Test env var bool coercion + for true_val in ["true", "True", "TRUE", "1", "yes", "YES"]: + with mock.patch.dict(os.environ, {"TEST_BOOL": true_val}): + value = _get_config_value("TEST_BOOL", ["test"], False, {}, bool) + assert value is True, f"Failed for '{true_val}'" + + for false_val in ["false", "False", "0", "no", ""]: + with mock.patch.dict(os.environ, {"TEST_BOOL": false_val}): + value = _get_config_value("TEST_BOOL", ["test"], True, {}, bool) + assert value is False, f"Failed for '{false_val}'" + + +def test_get_config_value_int_coercion(): + """Verify integer coercion works for string values.""" + from opencode_agent_hub.daemon import _get_config_value + + # From env var + with mock.patch.dict(os.environ, {"TEST_INT": "42"}): + value = _get_config_value("TEST_INT", ["test"], 0, {}, int) + assert value == 42 + assert isinstance(value, int) + + # From config file (string) + config = {"test": "99"} + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("TEST_INT", None) + value = _get_config_value("TEST_INT", ["test"], 0, config, int) + assert value == 99 + + # From config file (int) + config = {"test": 77} + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("TEST_INT", None) + value = _get_config_value("TEST_INT", ["test"], 0, config, int) + assert value == 77 + + +def test_get_config_value_missing_nested_key(): + """Verify missing nested keys return default.""" + from opencode_agent_hub.daemon import _get_config_value + + config = {"rate_limit": {}} # Missing 'enabled' key + + with mock.patch.dict(os.environ, {}, clear=True): + os.environ.pop("AGENT_HUB_RATE_LIMIT", None) + value = _get_config_value( + "AGENT_HUB_RATE_LIMIT", ["rate_limit", "enabled"], False, config, bool + ) + assert value is False + + +def test_load_config_file_not_exists(): + """Verify _load_config_file returns empty dict when file doesn't exist.""" + + # Mock CONFIG_FILE to a non-existent path + with mock.patch("opencode_agent_hub.daemon.CONFIG_FILE", Path("/nonexistent/config.json")): + # Re-import to test, but we can just call the function directly + from opencode_agent_hub import daemon + + # Manually call with mocked path + original = daemon.CONFIG_FILE + daemon.CONFIG_FILE = Path("/nonexistent/config.json") + result = daemon._load_config_file() + daemon.CONFIG_FILE = original + + assert result == {} + + +def test_load_config_file_valid(): + """Verify _load_config_file loads valid JSON.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + json.dump({"opencode_port": 5000, "log_level": "DEBUG"}, f) + f.flush() + + from opencode_agent_hub import daemon + + original = daemon.CONFIG_FILE + daemon.CONFIG_FILE = Path(f.name) + result = daemon._load_config_file() + daemon.CONFIG_FILE = original + + assert result == {"opencode_port": 5000, "log_level": "DEBUG"} + + # Cleanup + os.unlink(f.name) + + +def test_load_config_file_invalid_json(): + """Verify _load_config_file returns empty dict for invalid JSON.""" + with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as f: + f.write("not valid json {{{") + f.flush() + + from opencode_agent_hub import daemon + + original = daemon.CONFIG_FILE + daemon.CONFIG_FILE = Path(f.name) + result = daemon._load_config_file() + daemon.CONFIG_FILE = original + + assert result == {} + + # Cleanup + os.unlink(f.name) diff --git a/tests/test_coordinator.py b/tests/test_coordinator.py new file mode 100644 index 0000000..cdfb0b9 --- /dev/null +++ b/tests/test_coordinator.py @@ -0,0 +1,242 @@ +"""Tests for coordinator AGENTS.md resolution.""" + +import tempfile +from pathlib import Path +from unittest import mock + + +def test_find_coordinator_agents_md_explicit_config(): + """Verify explicit config path takes highest priority.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + # Create a custom AGENTS.md + custom_path = Path(tmpdir) / "custom-agents.md" + custom_path.write_text("# Custom Coordinator") + + # Mock the config value + original = daemon.COORDINATOR_AGENTS_MD + daemon.COORDINATOR_AGENTS_MD = custom_path + + try: + result = daemon.find_coordinator_agents_md_template() + assert result == custom_path + finally: + daemon.COORDINATOR_AGENTS_MD = original + + +def test_find_coordinator_agents_md_explicit_config_missing(): + """Verify warning logged and fallback when explicit config path doesn't exist.""" + from opencode_agent_hub import daemon + + # Mock a non-existent explicit path + original = daemon.COORDINATOR_AGENTS_MD + daemon.COORDINATOR_AGENTS_MD = Path("/nonexistent/agents.md") + + try: + with mock.patch.object(daemon, "CONFIG_DIR", Path("/also-nonexistent")): + # Should return None since no templates exist + result = daemon.find_coordinator_agents_md_template() + # Result depends on whether system templates exist + # At minimum, it shouldn't crash + assert result is None or isinstance(result, Path) + finally: + daemon.COORDINATOR_AGENTS_MD = original + + +def test_find_coordinator_agents_md_user_config_agents_md(): + """Verify ~/.config/agent-hub-daemon/AGENTS.md is checked.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = Path(tmpdir) + agents_md = config_dir / "AGENTS.md" + agents_md.write_text("# User Config AGENTS.md") + + original_config = daemon.COORDINATOR_AGENTS_MD + original_dir = daemon.CONFIG_DIR + daemon.COORDINATOR_AGENTS_MD = None # No explicit config + daemon.CONFIG_DIR = config_dir + + try: + result = daemon.find_coordinator_agents_md_template() + assert result == agents_md + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_dir + + +def test_find_coordinator_agents_md_user_config_coordinator_md(): + """Verify ~/.config/agent-hub-daemon/COORDINATOR.md alias is checked.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = Path(tmpdir) + coordinator_md = config_dir / "COORDINATOR.md" + coordinator_md.write_text("# User Config COORDINATOR.md alias") + + original_config = daemon.COORDINATOR_AGENTS_MD + original_dir = daemon.CONFIG_DIR + daemon.COORDINATOR_AGENTS_MD = None + daemon.CONFIG_DIR = config_dir + + try: + result = daemon.find_coordinator_agents_md_template() + assert result == coordinator_md + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_dir + + +def test_find_coordinator_agents_md_agents_md_priority_over_coordinator_md(): + """Verify AGENTS.md takes priority over COORDINATOR.md alias.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = Path(tmpdir) + agents_md = config_dir / "AGENTS.md" + coordinator_md = config_dir / "COORDINATOR.md" + agents_md.write_text("# AGENTS.md (should win)") + coordinator_md.write_text("# COORDINATOR.md (should lose)") + + original_config = daemon.COORDINATOR_AGENTS_MD + original_dir = daemon.CONFIG_DIR + daemon.COORDINATOR_AGENTS_MD = None + daemon.CONFIG_DIR = config_dir + + try: + result = daemon.find_coordinator_agents_md_template() + assert result == agents_md # AGENTS.md should win + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_dir + + +def test_find_coordinator_agents_md_none_when_no_templates(): + """Verify None returned when no templates exist.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + original_config = daemon.COORDINATOR_AGENTS_MD + original_dir = daemon.CONFIG_DIR + daemon.COORDINATOR_AGENTS_MD = None + daemon.CONFIG_DIR = Path(tmpdir) # Empty dir + + try: + # Mock system locations to not exist + with mock.patch.object(daemon, "Path") as mock_path: + # Make all paths report as non-existent + mock_instance = mock.MagicMock() + mock_instance.exists.return_value = False + mock_path.return_value = mock_instance + mock_path.side_effect = lambda x: Path(x) # Use real Path + + # The function should handle missing templates gracefully + result = daemon.find_coordinator_agents_md_template() + # Result is None or a system template if it happens to exist + assert result is None or isinstance(result, Path) + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_dir + + +def test_setup_coordinator_directory_copies_template(): + """Verify setup_coordinator_directory copies from found template.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = Path(tmpdir) / "config" + config_dir.mkdir() + coord_dir = Path(tmpdir) / "coordinator" + + # Create user config template + user_template = config_dir / "AGENTS.md" + user_template.write_text("# Custom Coordinator Instructions") + + original_config = daemon.COORDINATOR_AGENTS_MD + original_config_dir = daemon.CONFIG_DIR + original_coord_dir = daemon.COORDINATOR_DIR + daemon.COORDINATOR_AGENTS_MD = None + daemon.CONFIG_DIR = config_dir + daemon.COORDINATOR_DIR = coord_dir + + try: + result = daemon.setup_coordinator_directory() + assert result is True + + # Check the AGENTS.md was copied + copied = coord_dir / "AGENTS.md" + assert copied.exists() + assert copied.read_text() == "# Custom Coordinator Instructions" + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_config_dir + daemon.COORDINATOR_DIR = original_coord_dir + + +def test_setup_coordinator_directory_creates_minimal_when_no_template(): + """Verify setup_coordinator_directory creates minimal AGENTS.md when no template.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + config_dir = Path(tmpdir) / "config" + config_dir.mkdir() # Empty config dir + coord_dir = Path(tmpdir) / "coordinator" + + original_config = daemon.COORDINATOR_AGENTS_MD + original_config_dir = daemon.CONFIG_DIR + original_coord_dir = daemon.COORDINATOR_DIR + daemon.COORDINATOR_AGENTS_MD = None + daemon.CONFIG_DIR = config_dir + daemon.COORDINATOR_DIR = coord_dir + + try: + # Mock system locations to not exist + original_find = daemon.find_coordinator_agents_md_template + + def mock_find(): + # Check user config only, skip system + for path in [config_dir / "AGENTS.md", config_dir / "COORDINATOR.md"]: + if path.exists(): + return path + return None + + daemon.find_coordinator_agents_md_template = mock_find + + result = daemon.setup_coordinator_directory() + assert result is True + + # Check minimal AGENTS.md was created + created = coord_dir / "AGENTS.md" + assert created.exists() + content = created.read_text() + assert "Coordinator Agent" in content + assert "NEW_AGENT" in content + finally: + daemon.COORDINATOR_AGENTS_MD = original_config + daemon.CONFIG_DIR = original_config_dir + daemon.COORDINATOR_DIR = original_coord_dir + daemon.find_coordinator_agents_md_template = original_find + + +def test_setup_coordinator_directory_skips_if_exists(): + """Verify setup_coordinator_directory skips if AGENTS.md already exists.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + coord_dir = Path(tmpdir) / "coordinator" + coord_dir.mkdir() + existing = coord_dir / "AGENTS.md" + existing.write_text("# Existing content - should not be overwritten") + + original_coord_dir = daemon.COORDINATOR_DIR + daemon.COORDINATOR_DIR = coord_dir + + try: + result = daemon.setup_coordinator_directory() + assert result is True + + # Verify content was NOT overwritten + assert existing.read_text() == "# Existing content - should not be overwritten" + finally: + daemon.COORDINATOR_DIR = original_coord_dir diff --git a/tests/test_session_agents.py b/tests/test_session_agents.py new file mode 100644 index 0000000..cafcec3 --- /dev/null +++ b/tests/test_session_agents.py @@ -0,0 +1,244 @@ +"""Tests for session-based agent identity functionality.""" + +import json +import tempfile +from pathlib import Path +from unittest import mock + + +def test_generate_agent_id_for_session_with_slug(): + """Verify agent ID is generated from session slug when available.""" + from opencode_agent_hub.daemon import generate_agent_id_for_session + + session = { + "id": "ses_abc123def456", + "slug": "cosmic-panda", + "directory": "/home/user/project", + } + + agent_id = generate_agent_id_for_session(session) + assert agent_id == "cosmic-panda" + + +def test_generate_agent_id_for_session_without_slug(): + """Verify agent ID is generated from session ID when slug is missing.""" + from opencode_agent_hub.daemon import generate_agent_id_for_session + + session = { + "id": "ses_abc123def456ghi789", + "directory": "/home/user/project", + } + + agent_id = generate_agent_id_for_session(session) + # Should use "session-" prefix with truncated ID (after ses_ prefix) + assert agent_id == "session-abc123def456" + + +def test_generate_agent_id_for_session_empty_slug(): + """Verify empty slug falls back to session ID.""" + from opencode_agent_hub.daemon import generate_agent_id_for_session + + session = { + "id": "ses_xyz789", + "slug": "", + "directory": "/home/user/project", + } + + agent_id = generate_agent_id_for_session(session) + # Empty slug treated as falsy, falls back to session ID format + assert agent_id == "session-xyz789" + + +def test_get_or_create_agent_for_session_new(): + """Verify new agent is created for unknown session.""" + from opencode_agent_hub import daemon + + # Clear session agents + daemon.SESSION_AGENTS = {} + + session = { + "id": "ses_new123", + "slug": "brave-tiger", + "directory": "/home/user/newproject", + } + agents = {} + + agent = daemon.get_or_create_agent_for_session(session, agents) + + assert agent["id"] == "brave-tiger" + assert agent["sessionId"] == "ses_new123" + assert agent["projectPath"] == "/home/user/newproject" + assert "ses_new123" in daemon.SESSION_AGENTS + + +def test_get_or_create_agent_for_session_existing(): + """Verify existing agent is returned for known session.""" + from opencode_agent_hub import daemon + + session = { + "id": "ses_existing", + "slug": "lazy-bear", + "directory": "/home/user/existingproject", + } + + # Pre-populate session agents + daemon.SESSION_AGENTS = { + "ses_existing": { + "agentId": "lazy-bear", + "directory": "/home/user/existingproject", + "slug": "lazy-bear", + } + } + + existing_agent = { + "id": "lazy-bear", + "sessionId": "ses_existing", + "projectPath": "/home/user/existingproject", + "lastSeen": 12345, + } + agents = {"lazy-bear": existing_agent} + + agent = daemon.get_or_create_agent_for_session(session, agents) + + # Should return existing agent, not create new one + assert agent["id"] == "lazy-bear" + assert agent["lastSeen"] == 12345 + + +def test_find_session_for_agent_with_session_id(): + """Verify session lookup works with sessionId field.""" + from opencode_agent_hub.daemon import find_session_for_agent + + agent = { + "id": "test-agent", + "sessionId": "ses_target", + "projectPath": "/home/user/project", + } + + sessions = [ + {"id": "ses_other", "directory": "/home/user/other"}, + {"id": "ses_target", "directory": "/home/user/project"}, + ] + + session = find_session_for_agent(agent, sessions) + + assert session is not None + assert session["id"] == "ses_target" + + +def test_find_session_for_agent_fallback_to_session_agents(): + """Verify session lookup falls back to SESSION_AGENTS mapping for legacy agents.""" + from opencode_agent_hub import daemon + from opencode_agent_hub.daemon import find_session_for_agent + + # Set up SESSION_AGENTS mapping for legacy agent + daemon.SESSION_AGENTS = { + "ses_match": {"agentId": "legacy-agent", "directory": "/home/user/project"}, + } + + agent = { + "id": "legacy-agent", + "projectPath": "/home/user/project", + # No sessionId - legacy agent + } + + sessions = [ + {"id": "ses_match", "directory": "/home/user/project"}, + {"id": "ses_other", "directory": "/home/user/other"}, + ] + + session = find_session_for_agent(agent, sessions) + + assert session is not None + assert session["id"] == "ses_match" + + # Cleanup + daemon.SESSION_AGENTS = {} + + +def test_gc_session_agents_removes_stale(): + """Verify gc_session_agents removes mappings for non-existent sessions.""" + from opencode_agent_hub import daemon + + # Set up session agents with one that doesn't exist anymore + daemon.SESSION_AGENTS = { + "ses_active": {"agentId": "active-agent", "directory": "/active"}, + "ses_stale": {"agentId": "stale-agent", "directory": "/stale"}, + } + + # Mock get_sessions to return only one session + with mock.patch.object(daemon, "get_sessions") as mock_get_sessions: + mock_get_sessions.return_value = [ + {"id": "ses_active", "directory": "/active"}, + ] + + # Mock save to avoid file I/O + with mock.patch.object(daemon, "save_session_agents"): + cleaned = daemon.gc_session_agents() + + assert cleaned == 1 + assert "ses_active" in daemon.SESSION_AGENTS + assert "ses_stale" not in daemon.SESSION_AGENTS + + +def test_gc_session_agents_empty(): + """Verify gc_session_agents handles empty mapping.""" + from opencode_agent_hub import daemon + + daemon.SESSION_AGENTS = {} + + cleaned = daemon.gc_session_agents() + + assert cleaned == 0 + + +def test_gc_session_agents_api_failure(): + """Verify gc_session_agents doesn't clear on API failure.""" + from opencode_agent_hub import daemon + + daemon.SESSION_AGENTS = { + "ses_keep": {"agentId": "keep-agent", "directory": "/keep"}, + } + + # Mock get_sessions to return empty (simulating API failure) + with mock.patch.object(daemon, "get_sessions") as mock_get_sessions: + mock_get_sessions.return_value = [] + + cleaned = daemon.gc_session_agents() + + # Should not clean anything on API failure + assert cleaned == 0 + assert "ses_keep" in daemon.SESSION_AGENTS + + +def test_save_load_session_agents(): + """Verify session agents can be saved and loaded.""" + from opencode_agent_hub import daemon + + with tempfile.TemporaryDirectory() as tmpdir: + # Mock the file path + original_file = daemon.SESSION_AGENTS_FILE + original_dir = daemon.AGENT_HUB_DIR + daemon.SESSION_AGENTS_FILE = Path(tmpdir) / "session_agents.json" + daemon.AGENT_HUB_DIR = Path(tmpdir) + + try: + # Set and save + daemon.SESSION_AGENTS = { + "ses_test": {"agentId": "test-agent", "directory": "/test"}, + } + daemon.save_session_agents() + + # Verify file was written + assert daemon.SESSION_AGENTS_FILE.exists() + content = json.loads(daemon.SESSION_AGENTS_FILE.read_text()) + assert content == daemon.SESSION_AGENTS + + # Clear and reload + daemon.SESSION_AGENTS = {} + loaded = daemon.load_session_agents() + assert loaded == {"ses_test": {"agentId": "test-agent", "directory": "/test"}} + + finally: + daemon.SESSION_AGENTS_FILE = original_file + daemon.AGENT_HUB_DIR = original_dir diff --git a/uv.lock b/uv.lock index b58aa65..f6b73b6 100644 --- a/uv.lock +++ b/uv.lock @@ -361,7 +361,7 @@ wheels = [ [[package]] name = "opencode-agent-hub" -version = "0.2.0" +version = "0.5.5" source = { editable = "." } dependencies = [ { name = "requests" },