"""Reusable test doubles for octop.""" from __future__ import annotations import tempfile from collections.abc import AsyncIterator from datetime import UTC, datetime from pathlib import Path from types import SimpleNamespace from typing import Any from deepagents.backends.protocol import ( FileDownloadResponse, FileUploadResponse, GlobResult, GrepResult, LsResult, ReadResult, ) from octop_gateway.channel import BaseChannel from octop_gateway.models import InboundMessage, MessageEvent from octop_harness.backends.workspace import BackendWorkspace def fake_bin_path(name: str) -> str: """Host-agnostic fake binary path for mocks (never opened as a real file). Prefer this over hard-coded ``/usr/bin/...`` strings so argv assertions stay valid on Windows CI (uses ``pathlib`` / ``os.sep``). """ return str(Path("fake-bin") / name) class FakeHarnessAgent: """Minimal stand-in for ``octop_harness.HarnessAgent``. Yields a programmable list of chunk dicts, then ``state_snapshot``. The ``request`` argument is captured into ``last_request`` for inspection. """ def __init__( self, chunks: list[dict[str, Any]] | None = None, raise_on_stream: BaseException | None = None, *, workspace_dir: Path | None = None, virtual_mode: bool = True, system_files_path: str = "", ) -> None: self.chunks = chunks or [] self.raise_on_stream = raise_on_stream self.last_request: dict[str, Any] | None = None self.closed = False if workspace_dir is not None: self.use_workspace_dir( workspace_dir, virtual_mode=virtual_mode, system_files_path=system_files_path, ) else: self._workspace_dir = Path(tempfile.mkdtemp(prefix="octop-fake-ws-")) self._backend = _FakeBackend() self._workspace = BackendWorkspace(self._backend, self._workspace_dir) self.config = SimpleNamespace( mcp_server_configs={}, skills_disabled=frozenset(), tools_disabled=frozenset(), skill_package_roots=None, ) self._mcp_tools: list[Any] = [] self._mcp_tool_name_set: frozenset[str] = frozenset() self._thread_messages: dict[str, list[Any]] = {} self.graph = _FakeHarnessGraph(self._thread_messages) def use_workspace_dir( self, workspace_dir: Path, *, virtual_mode: bool = False, system_files_path: str = "", ) -> None: """Bind workspace I/O to a real ``local_shell`` backend on disk. The fake mirrors production's ``root_dir="/"`` behaviour by honoring absolute paths as-is: ``BackendWorkspace`` resolves paths under ``workspace_dir`` and the disk backend writes them there. ``virtual_mode`` is forced off so absolute paths are not re-rooted under ``root_dir`` (which would nest writes under ``workspace_dir/tmp/...``). """ from deepagents.backends.local_shell import LocalShellBackend self._workspace_dir = workspace_dir workspace_dir.mkdir(parents=True, exist_ok=True) self._backend = LocalShellBackend(root_dir=str(workspace_dir), virtual_mode=False) self._workspace = BackendWorkspace( self._backend, workspace_dir, system_files_path=system_files_path, ) async def stream(self, request: dict[str, Any]) -> AsyncIterator[dict[str, Any]]: self.last_request = request if self.raise_on_stream is not None: raise self.raise_on_stream for chunk in self.chunks: yield chunk yield {"type": "state_snapshot", "data": {}} def close(self) -> None: self.closed = True @property def backend(self) -> Any: """Return an in-memory fake backend for workspace operations.""" return self._backend @property def workspace(self) -> BackendWorkspace: return self._workspace def is_bootstrapped(self) -> bool: return True def append_mcp_tools(self, extra_tools: list[Any]) -> None: """Mirror harness ``append_mcp_tools`` (no graph recompile in tests).""" if not extra_tools: return existing = {str(getattr(t, "name", "") or "") for t in self._mcp_tools} new_tools = [ tool for tool in extra_tools if str(getattr(tool, "name", "") or "") not in existing ] if not new_tools: return self._mcp_tools = list(new_tools) + list(self._mcp_tools) self._mcp_tool_name_set = frozenset( str(getattr(t, "name", "") or "") for t in self._mcp_tools if getattr(t, "name", None) ) def inject_mcp_tools(self, extra_tools: list[Any]) -> None: """Alias for :meth:`append_mcp_tools` (gateway in-process MCP injection).""" self.append_mcp_tools(extra_tools) async def seed_default_subagent(self) -> None: """Mirror harness init_workspace default subagent seed.""" content = """--- name: General Purpose description: General-purpose agent --- # General Purpose """ await self._workspace.aupload_bytes( "agents/general-purpose.md", content.encode("utf-8"), ) async def list_subagent_summaries(self) -> list[dict[str, Any]]: from octop.infra.utils.frontmatter import parse_frontmatter paths: set[str] = set() for root in ("agents", ".octop/agents"): agents_dir = self._workspace_dir / root if agents_dir.is_dir(): for fpath in agents_dir.glob("*.md"): paths.add(f"{root}/{fpath.name}") glob_result = await self._workspace.aglob("agents/*.md", ".") matches = getattr(glob_result, "matches", None) or [] for m in matches: p = str(getattr(m, "path", m.get("path") if isinstance(m, dict) else "") or "") if p: paths.add(p) out: list[dict[str, Any]] = [] for path in sorted(paths): text = await self._workspace.aread_text(path) if not text: fpath = self._workspace_dir / path if fpath.is_file(): text = fpath.read_text(encoding="utf-8") if not text: continue meta, _ = parse_frontmatter(text) slug = Path(path).stem row: dict[str, Any] = { "slug": slug, "name": str(meta.get("name") or slug), "description": str(meta.get("description") or ""), "path": path if path.startswith("agents/") else f"agents/{Path(path).name}", } if meta.get("emoji"): row["emoji"] = meta["emoji"] if meta.get("color"): row["color"] = str(meta["color"]).strip() out.append(row) return out def set_skills_disabled( self, disabled: set[str] | frozenset[str] | list[str] | None, ) -> None: """Hot-update disabled skills (mirrors octop_harness.HarnessAgent).""" self.config.skills_disabled = frozenset(str(x) for x in (disabled or ())) def set_tools_disabled( self, disabled: set[str] | frozenset[str] | list[str] | None, ) -> None: """Hot-update disabled tools (mirrors octop_harness.HarnessAgent).""" self.config.tools_disabled = frozenset(str(x) for x in (disabled or ())) def set_skill_package_roots(self, roots: list[dict[str, str]] | None) -> None: """Hot-update skill package roots (mirrors octop_harness.HarnessAgent).""" self.config.skill_package_roots = roots async def list_skill_summaries(self) -> list[dict[str, Any]]: """Mirror harness skill catalog: list workspace + builtin ``SKILL.md`` manifests. Skills are written via ``agent.workspace`` (the in-memory fake backend for tests, or the real disk-backed backend). Enumerate from whichever store holds them so the listed summaries match what the routers persisted. """ from octop.infra.skills.presentation import apply_skill_presentation from octop.infra.utils.frontmatter import parse_frontmatter collected: dict[str, str] = {} # In-memory fake backend store (skills are written here by the routers). files = getattr(self._backend, "_files", None) if isinstance(files, dict): for fpath, data in files.items(): if not fpath.endswith(".md"): continue if "/skills/" not in fpath and "_builtin_skills/" not in fpath: continue if isinstance(data, bytes): try: collected[fpath] = data.decode("utf-8") except UnicodeDecodeError: continue elif isinstance(data, str): collected[fpath] = data # Real disk-backed workspace (e.g. when use_workspace_dir was called). for root in ("skills", "_builtin_skills", ".octop/skills", ".octop/_builtin_skills"): disk_root = self._workspace_dir / root if disk_root.is_dir(): for fpath in disk_root.rglob("*.md"): rel = f"{root}/{fpath.relative_to(disk_root)}" if rel not in collected: try: collected[rel] = fpath.read_text(encoding="utf-8") except (OSError, UnicodeDecodeError): continue disabled = frozenset(str(x) for x in getattr(self.config, "skills_disabled", frozenset())) # Merge by slug, mirroring the harness catalog: workspace skills # override package and builtin skills (later roots win). merged: dict[str, dict[str, Any]] = {} for root in getattr(self.config, "skill_package_roots", None) or (): package_id = str(root.get("id") or "") package_root = Path(str(root.get("path") or "")) if not package_id or not package_root.is_dir(): continue for skill_dir in sorted(package_root.iterdir()): manifest = skill_dir / "SKILL.md" if not skill_dir.is_dir() or not manifest.is_file(): continue try: meta, _ = parse_frontmatter(manifest.read_text(encoding="utf-8")) except (OSError, UnicodeDecodeError): continue if meta.get("removed"): continue slug = skill_dir.name name = str(meta.get("name") or slug) is_disabled = slug in disabled or name in disabled merged[slug] = apply_skill_presentation( { "slug": slug, "name": name, "description": str(meta.get("description") or ""), "kind": "package", "package_id": package_id, "enabled": not is_disabled, "disabled": is_disabled, }, meta, ) for path in sorted(collected): meta, _ = parse_frontmatter(collected[path]) # Slug is the skill directory name (parent of SKILL.md), matching the # harness catalog where ``skills//SKILL.md`` is the layout. slug = Path(path).parent.name if not slug or slug.startswith("."): continue # Mirror harness: skip soft-deleted skills (``removed: true`` frontmatter). if meta.get("removed"): continue kind = "builtin" if "_builtin_skills/" in path else "workspace" if slug in merged and not ( merged[slug]["kind"] in {"builtin", "package"} and kind == "workspace" ): continue name = str(meta.get("name") or slug) is_disabled = slug in disabled or name in disabled merged[slug] = apply_skill_presentation( { "slug": slug, "name": name, "description": str(meta.get("description") or ""), "path": path, "kind": kind, "enabled": not is_disabled, "disabled": is_disabled, }, meta, ) return sorted( merged.values(), key=lambda row: ( {"builtin": 0, "package": 1, "workspace": 2}.get(str(row["kind"]), 99), str(row["slug"]), ), ) async def aget_history( self, thread_id: str, *, limit: int = 50, before: str | None = None ) -> list[Any]: del before return list(self._thread_messages.get(thread_id, []))[-limit:] def seed_thread_messages(self, thread_id: str, messages: list[Any]) -> None: self._thread_messages[thread_id] = list(messages) class _FakeHarnessGraph: """In-memory LangGraph stand-in for checkpoint read/write in tests.""" def __init__(self, store: dict[str, list[Any]]) -> None: self._store = store async def aget_state(self, config: dict[str, Any]) -> SimpleNamespace: thread_id = str((config.get("configurable") or {}).get("thread_id") or "") return SimpleNamespace(values={"messages": list(self._store.get(thread_id, []))}) async def aupdate_state( self, config: dict[str, Any], values: dict[str, Any], **_kwargs: Any ) -> None: thread_id = str((config.get("configurable") or {}).get("thread_id") or "") incoming = values.get("messages") or [] bucket = self._store.setdefault(thread_id, []) bucket.extend(list(incoming)) class _FakeBackend: """In-memory backend supporting the harness BackendProtocol surface used by Octop routers.""" def __init__(self) -> None: self._files: dict[str, bytes] = {} # --- legacy helpers (media backend) --- async def write_bytes(self, path: str, data: bytes) -> None: self._files[path] = data async def read_bytes(self, path: str) -> bytes: if path not in self._files: raise FileNotFoundError(path) return self._files[path] # --- BackendProtocol surface used by skills / workspace routers --- async def aupload_files(self, uploads: list[tuple[str, bytes]]) -> list[FileUploadResponse]: results = [] for path, data in uploads: self._files[path] = data results.append(FileUploadResponse(path=path, error=None)) return results async def aread(self, path: str) -> ReadResult: if path not in self._files: return ReadResult(error="not_found", file_data=None) try: content = self._files[path].decode("utf-8") return ReadResult(error=None, file_data={"content": content, "encoding": "utf-8"}) except UnicodeDecodeError: return ReadResult( error=None, file_data={"content": self._files[path], "encoding": "binary"} ) async def als(self, path: str) -> LsResult: prefix = path.rstrip("/") + "/" entries = [] now = datetime.now(UTC).isoformat() seen_dirs: set[str] = set() for fpath, data in self._files.items(): if not fpath.startswith(prefix): continue rel = fpath[len(prefix) :] if "/" in rel: subdir = prefix + rel.split("/")[0] if subdir not in seen_dirs: seen_dirs.add(subdir) entries.append({"path": subdir, "is_dir": True, "size": 0, "modified_at": now}) else: entries.append( {"path": fpath, "is_dir": False, "size": len(data), "modified_at": now} ) return LsResult(error=None, entries=entries) async def adownload_files(self, paths: list[str]) -> list[FileDownloadResponse]: results = [] for path in paths: if path in self._files: results.append( FileDownloadResponse(path=path, content=self._files[path], error=None) ) else: results.append(FileDownloadResponse(path=path, content=b"", error="not_found")) return results async def aglob(self, pattern: str, path: str = "/") -> GlobResult: import fnmatch now = datetime.now(UTC).isoformat() matches = [] for fpath, data in self._files.items(): fname = fpath.rsplit("/", 1)[-1] if fnmatch.fnmatch(fname, pattern) or fnmatch.fnmatch(fpath, pattern): matches.append( {"path": fpath, "is_dir": False, "size": len(data), "modified_at": now} ) return GlobResult(error=None, matches=matches) async def agrep(self, pattern: str, path: str = "/") -> GrepResult: import re matches = [] try: rx = re.compile(pattern) except re.error: return GrepResult(error=f"invalid pattern: {pattern}", matches=None) for fpath, data in self._files.items(): try: text = data.decode("utf-8") except UnicodeDecodeError: continue for lineno, line in enumerate(text.splitlines(), 1): if rx.search(line): matches.append({"path": fpath, "line": lineno, "text": line}) return GrepResult(error=None, matches=matches) class LoopbackChannel(BaseChannel): """In-memory channel: tests push InboundMessage and collect MessageEvents.""" channel_kind = "loopback" def __init__(self, channel_id: str = "loopback-1", **kwargs: Any) -> None: async def _noop_processor( msg: InboundMessage, ) -> AsyncIterator[MessageEvent]: return yield # make it an async generator super().__init__(kwargs.get("processor", _noop_processor)) self.channel_id = channel_id self._inbound: list[InboundMessage] = [] self._outbound: list[MessageEvent] = [] async def start(self) -> None: pass async def stop(self) -> None: pass async def _send_text(self, session_id: str, text: str, **kwargs: Any) -> None: pass async def _send_content(self, session_id: str, content: Any, **kwargs: Any) -> None: pass async def _send_media(self, session_id: str, media_key: str, **kwargs: Any) -> None: pass def parse_inbound(self, payload: Any) -> InboundMessage | None: return None