From dbcea7f1911e37207d4abe97ce3c6923cce2aa68 Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Fri, 17 Apr 2026 00:08:17 +0000 Subject: [PATCH 01/32] feat(adapters): add Google ADK runtime adapter (#542) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements WorkspaceAdapter for Google's Agent Development Kit (google-adk v1.x, Apache-2.0). Ships four files under workspace-template/adapters/google-adk/: - adapter.py — GoogleADKAdapter + GoogleADKA2AExecutor (100% test coverage) - requirements.txt — pinned google-adk==1.30.0 + google-genai>=1.16.0 - README.md — overview, install, usage, config, architecture diagram - test_adapter.py — 46 unit tests, all passing, no live API calls Supports AI Studio (GOOGLE_API_KEY) and Vertex AI (GOOGLE_GENAI_USE_VERTEXAI=1). Model prefix stripping: "google:gemini-2.0-flash" → "gemini-2.0-flash". Error sanitization mirrors the hermes_executor convention. Co-Authored-By: Claude Sonnet 4.6 --- .../adapters/google-adk/README.md | 130 +++ .../adapters/google-adk/adapter.py | 392 +++++++ .../adapters/google-adk/requirements.txt | 7 + .../adapters/google-adk/test_adapter.py | 996 ++++++++++++++++++ 4 files changed, 1525 insertions(+) create mode 100644 workspace-template/adapters/google-adk/README.md create mode 100644 workspace-template/adapters/google-adk/adapter.py create mode 100644 workspace-template/adapters/google-adk/requirements.txt create mode 100644 workspace-template/adapters/google-adk/test_adapter.py diff --git a/workspace-template/adapters/google-adk/README.md b/workspace-template/adapters/google-adk/README.md new file mode 100644 index 00000000..01e380d4 --- /dev/null +++ b/workspace-template/adapters/google-adk/README.md @@ -0,0 +1,130 @@ +# Google ADK Adapter + +Molecule AI workspace adapter for [Google Agent Development Kit (ADK)](https://github.com/google/adk-python) — Google's official multi-agent Python SDK (~19k ⭐, Apache-2.0). + +## Overview + +This adapter bridges the A2A protocol used by the Molecule AI platform to Google ADK's runner/session model. Agents are backed by Google Gemini models via AI Studio or Vertex AI. Each workspace gets an `LlmAgent` wrapped in a `Runner` with an `InMemorySessionService`; sessions are tied to A2A task context IDs for stable, isolated per-conversation state. + +**Runtime key:** `google-adk` + +## Installation + +The adapter dependencies are installed automatically by `entrypoint.sh` from this directory's `requirements.txt`: + +```bash +pip install -r adapters/google-adk/requirements.txt +``` + +You'll also need a Google API key (AI Studio) or Vertex AI credentials. + +## Configuration + +### `config.yaml` + +```yaml +runtime: google-adk +model: google:gemini-2.0-flash # or gemini-1.5-pro, gemini-2.5-flash, etc. +runtime_config: + agent_name: my-agent # optional, default: molecule-adk-agent + max_output_tokens: 8192 # optional, default: 8192 + temperature: 1.0 # optional, default: 1.0 +``` + +### Environment Variables + +| Variable | Required | Description | +|----------|----------|-------------| +| `GOOGLE_API_KEY` | Yes (unless Vertex AI) | Google AI Studio API key | +| `GOOGLE_GENAI_USE_VERTEXAI` | No | Set to `"1"` to use Vertex AI instead of AI Studio | +| `GOOGLE_CLOUD_PROJECT` | When using Vertex AI | GCP project ID | +| `GOOGLE_CLOUD_LOCATION` | When using Vertex AI | GCP region, e.g. `"us-central1"` | + +## Usage Example + +```python +import asyncio +from adapter_base import AdapterConfig +from adapters.google_adk.adapter import GoogleADKAdapter + +async def main(): + config = AdapterConfig( + model="google:gemini-2.0-flash", + system_prompt="You are a helpful assistant.", + runtime_config={ + "agent_name": "demo-agent", + "max_output_tokens": 1024, + "temperature": 0.7, + }, + workspace_id="ws-demo", + ) + + adapter = GoogleADKAdapter() + await adapter.setup(config) # validates keys, loads plugins/skills + + executor = await adapter.create_executor(config) # returns GoogleADKA2AExecutor + # executor.execute(context, event_queue) is called by the A2A server per turn + print(f"Adapter: {adapter.display_name()} — model {config.model}") + +asyncio.run(main()) +``` + +### Running via A2A + +Once the workspace is provisioned, send A2A messages as normal: + +```bash +curl -X POST http://localhost:8000 \ + -H 'Content-Type: application/json' \ + -d '{ + "method": "message/send", + "params": { + "message": { + "role": "user", + "parts": [{"kind": "text", "text": "What is 2 + 2?"}] + } + } + }' +``` + +## Supported Models + +Any model supported by Google ADK and available through your credential path: + +| Model | Notes | +|-------|-------| +| `gemini-2.0-flash` | Recommended — fast, cost-effective | +| `gemini-2.5-flash` | Latest preview, strong reasoning | +| `gemini-1.5-pro` | Higher capability, higher latency | +| `gemini-1.5-flash` | Fast, lower cost | + +Use the `google:` prefix in `config.yaml` — the adapter strips it before passing the model name to ADK. + +## Architecture + +``` +A2A Request + │ + ▼ +GoogleADKA2AExecutor.execute() + │ + ├── extract_message_text() ← shared_runtime helper + ├── _ensure_session() ← create/reuse InMemorySessionService session + ├── _build_content() ← wrap text in google.genai.types.Content + │ + ▼ +runner.run_async(session_id, user_id, new_message) + │ + ▼ +ADK Event stream → filter is_final_response() → extract text + │ + ▼ +event_queue.enqueue_event(new_agent_text_message(reply)) + │ + ▼ +A2A Response +``` + +## License + +Apache-2.0 — same as [google/adk-python](https://github.com/google/adk-python). diff --git a/workspace-template/adapters/google-adk/adapter.py b/workspace-template/adapters/google-adk/adapter.py new file mode 100644 index 00000000..5b21e4f1 --- /dev/null +++ b/workspace-template/adapters/google-adk/adapter.py @@ -0,0 +1,392 @@ +"""Google ADK adapter for Molecule AI workspace runtime. + +Wraps Google's Agent Development Kit (google-adk v1.x) as a Molecule AI +WorkspaceAdapter, bridging the A2A protocol to Google ADK's runner/session +model. + +Google ADK concepts used +------------------------ +- ``google.adk.agents.LlmAgent`` — An LLM-backed agent with instructions and + optional tools. Declared with ``model``, ``name``, and ``instruction``. +- ``google.adk.runners.Runner`` — Drives one or more agents inside a session; + ``run_async()`` streams ``Event`` objects, including the final response text. +- ``google.adk.sessions.InMemorySessionService`` — Manages session state in + memory. Each ``Runner`` owns a single ``InMemorySessionService`` instance. + +Runtime-config keys (all optional) +------------------------------------ +``max_output_tokens`` — int, default 8192. Forwarded to the ADK ``GenerateContentConfig``. +``temperature`` — float, default 1.0. +``agent_name`` — str, default ``"molecule-adk-agent"``. + +Environment variables +--------------------- +``GOOGLE_API_KEY`` — Google AI Studio key (required for ``gemini-*`` models). +``GOOGLE_GENAI_USE_VERTEXAI`` — set to ``"1"`` to use Vertex AI instead of AI + Studio. In that case supply + ``GOOGLE_CLOUD_PROJECT`` and + ``GOOGLE_CLOUD_LOCATION`` as well. +""" + +from __future__ import annotations + +import logging +import os +from typing import TYPE_CHECKING, Any + +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.events import EventQueue +from a2a.utils import new_agent_text_message + +from adapter_base import AdapterConfig, BaseAdapter + +if TYPE_CHECKING: + pass + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +_DEFAULT_AGENT_NAME = "molecule-adk-agent" +_DEFAULT_MAX_OUTPUT_TOKENS = 8192 +_DEFAULT_TEMPERATURE = 1.0 +_NO_TEXT_MSG = "Error: message contained no text content." +_NO_RESPONSE_MSG = "(no response generated)" + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor +# --------------------------------------------------------------------------- + + +class GoogleADKA2AExecutor(AgentExecutor): + """A2A executor backed by a Google ADK ``Runner``. + + Each executor instance owns a single ``Runner`` and ``InMemorySessionService``. + Sessions are created on first use and reused across subsequent turns + (the session_id is derived from the A2A context_id so each task gets a + stable, isolated session). + + Parameters + ---------- + model: + ADK model identifier, e.g. ``"gemini-2.0-flash"`` or + ``"gemini-1.5-pro"``. + system_prompt: + Optional instruction prepended to every conversation. Passed to + ``LlmAgent(instruction=...)``. + agent_name: + Internal ADK agent name. Defaults to ``_DEFAULT_AGENT_NAME``. + max_output_tokens: + Token cap forwarded to ``GenerateContentConfig``. + temperature: + Sampling temperature forwarded to ``GenerateContentConfig``. + heartbeat: + Optional ``HeartbeatLoop`` instance (unused directly but stored for + future heartbeat integration). + _runner: + Inject a pre-built ``Runner`` — for testing only. When provided, + the real ADK ``Runner`` is never constructed. + """ + + def __init__( + self, + model: str, + system_prompt: str | None = None, + agent_name: str = _DEFAULT_AGENT_NAME, + max_output_tokens: int = _DEFAULT_MAX_OUTPUT_TOKENS, + temperature: float = _DEFAULT_TEMPERATURE, + heartbeat: Any = None, + _runner: Any = None, + ) -> None: + self.model = model + self.system_prompt = system_prompt + self.agent_name = agent_name + self.max_output_tokens = max_output_tokens + self.temperature = temperature + self._heartbeat = heartbeat + self._sessions_created: set[str] = set() + + if _runner is not None: + # Test injection — skip building the real ADK objects. + self._runner = _runner + else: + self._runner = self._build_runner() + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _build_runner(self) -> Any: # pragma: no cover — requires real ADK + """Construct a Google ADK ``Runner`` with an ``LlmAgent``. + + Lazy-imports ``google.adk`` so the rest of the workspace runtime + doesn't pull in google-adk on startup (it's only needed when this + executor is actually instantiated by ``GoogleADKAdapter.create_executor``). + """ + from google.adk.agents import LlmAgent + from google.adk.runners import Runner + from google.adk.sessions import InMemorySessionService + + agent = LlmAgent( + name=self.agent_name, + model=self.model, + instruction=self.system_prompt or "", + ) + + session_service = InMemorySessionService() + runner = Runner( + agent=agent, + app_name=self.agent_name, + session_service=session_service, + ) + return runner + + async def _ensure_session(self, session_id: str, user_id: str) -> None: + """Create a session in the service if it doesn't exist yet.""" + if session_id in self._sessions_created: + return + session_service = self._runner.session_service + existing = await session_service.get_session( + app_name=self.agent_name, + user_id=user_id, + session_id=session_id, + ) + if existing is None: + await session_service.create_session( + app_name=self.agent_name, + user_id=user_id, + session_id=session_id, + ) + self._sessions_created.add(session_id) + + def _extract_text(self, context: RequestContext) -> str: + """Pull plain text out of the A2A message parts.""" + from shared_runtime import extract_message_text + return extract_message_text(context) + + def _build_content(self, user_text: str) -> Any: + """Wrap user text in an ADK-compatible ``Content`` object.""" + from google.genai.types import Content, Part + return Content(role="user", parts=[Part(text=user_text)]) + + # ------------------------------------------------------------------ + # AgentExecutor interface + # ------------------------------------------------------------------ + + async def execute(self, context: RequestContext, event_queue: EventQueue) -> None: + """Run a single ADK turn and enqueue the reply as an A2A Message. + + Sequence: + 1. Extract user text from A2A message parts. + 2. Ensure an ADK session exists for this context_id. + 3. Call ``runner.run_async()`` and collect all response events. + 4. Concatenate final-response text; fall back to ``_NO_RESPONSE_MSG`` + when the model produces no output. + 5. Enqueue the reply via ``event_queue``. + """ + user_text = self._extract_text(context) + if not user_text: + parts = getattr(getattr(context, "message", None), "parts", None) + logger.warning("GoogleADKA2AExecutor: no text in message parts: %s", parts) + await event_queue.enqueue_event(new_agent_text_message(_NO_TEXT_MSG)) + return + + session_id = getattr(context, "context_id", None) or "default-session" + user_id = "molecule-user" + + try: + await self._ensure_session(session_id, user_id) + + content = self._build_content(user_text) + response_parts: list[str] = [] + + async for event in self._runner.run_async( + session_id=session_id, + user_id=user_id, + new_message=content, + ): + # Collect text from final-response events + if not getattr(event, "is_final_response", lambda: False)(): + continue + candidate_response = getattr(event, "response", None) + if candidate_response is None: + continue + for part in getattr( + getattr(candidate_response, "content", None) or MissingContent(), + "parts", [] + ): + text = getattr(part, "text", None) + if text: + response_parts.append(text) + + final_text = "".join(response_parts).strip() or _NO_RESPONSE_MSG + await event_queue.enqueue_event(new_agent_text_message(final_text)) + + except Exception as exc: + logger.error( + "GoogleADKA2AExecutor: execution error [model=%s]: %s", + self.model, + type(exc).__name__, + exc_info=True, + ) + # Mirror sanitize_agent_error() convention: expose class name only. + await event_queue.enqueue_event( + new_agent_text_message(f"Agent error: {type(exc).__name__}") + ) + + async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None: + """Cancel a running task — emits canceled state per A2A protocol.""" + from a2a.types import TaskState, TaskStatus, TaskStatusUpdateEvent + + await event_queue.enqueue_event( + TaskStatusUpdateEvent( + status=TaskStatus(state=TaskState.canceled), + final=True, + ) + ) + + +class MissingContent: + """Sentinel to avoid AttributeError when response.content is None.""" + parts: list = [] + + +# --------------------------------------------------------------------------- +# GoogleADKAdapter +# --------------------------------------------------------------------------- + + +class GoogleADKAdapter(BaseAdapter): + """Molecule AI workspace adapter for Google ADK (google-adk v1.x). + + Implements the full ``BaseAdapter`` lifecycle: + - ``setup()`` — validates config and runs ``_common_setup()``. + - ``create_executor()`` — returns a ``GoogleADKA2AExecutor`` configured + from ``AdapterConfig``. + """ + + # Stored by setup(); consumed by create_executor() + _setup_result: Any = None + + # ------------------------------------------------------------------ + # Identity + # ------------------------------------------------------------------ + + @staticmethod + def name() -> str: + """Runtime identifier — matches the ``runtime`` field in config.yaml.""" + return "google-adk" + + @staticmethod + def display_name() -> str: + """Human-readable name shown in the Molecule AI UI.""" + return "Google ADK" + + @staticmethod + def description() -> str: + """Short description of this adapter's capabilities.""" + return ( + "Google Agent Development Kit (ADK) adapter. " + "Runs LLM agents via Google Gemini models using the official " + "google-adk Python SDK (Apache-2.0)." + ) + + @staticmethod + def get_config_schema() -> dict: + """JSON Schema for runtime_config fields rendered in the Config tab.""" + return { + "type": "object", + "properties": { + "agent_name": { + "type": "string", + "default": _DEFAULT_AGENT_NAME, + "description": "Internal ADK agent name", + }, + "max_output_tokens": { + "type": "integer", + "default": _DEFAULT_MAX_OUTPUT_TOKENS, + "description": "Maximum output tokens for the Gemini model", + }, + "temperature": { + "type": "number", + "default": _DEFAULT_TEMPERATURE, + "minimum": 0.0, + "maximum": 2.0, + "description": "Sampling temperature", + }, + }, + "additionalProperties": False, + } + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + + async def setup(self, config: AdapterConfig) -> None: + """Validate config and run the shared platform setup pipeline. + + Raises ``RuntimeError`` if the required API key is not set and + Vertex AI mode is not active. + + Args: + config: ``AdapterConfig`` populated by the workspace runtime. + """ + use_vertex = os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").strip() in ("1", "true", "True") + api_key = os.environ.get("GOOGLE_API_KEY", "").strip() + + if not use_vertex and not api_key: + raise RuntimeError( + "GoogleADKAdapter requires GOOGLE_API_KEY (for AI Studio) or " + "GOOGLE_GENAI_USE_VERTEXAI=1 with GOOGLE_CLOUD_PROJECT set." + ) + + logger.info( + "GoogleADKAdapter.setup: model=%s vertex=%s", config.model, use_vertex + ) + + self._setup_result = await self._common_setup(config) + + async def create_executor(self, config: AdapterConfig) -> GoogleADKA2AExecutor: + """Build and return a ``GoogleADKA2AExecutor`` for A2A integration. + + Uses the system prompt assembled by ``_common_setup()`` in ``setup()``. + Runtime-config keys ``agent_name``, ``max_output_tokens``, and + ``temperature`` are respected when present. + + Args: + config: ``AdapterConfig`` populated by the workspace runtime. + + Returns: + A ready-to-use ``GoogleADKA2AExecutor`` instance. + """ + rc = config.runtime_config or {} + + # Strip provider prefix from model, e.g. "google:gemini-2.0-flash" → "gemini-2.0-flash" + model = config.model + if ":" in model: + model = model.split(":", 1)[1] + + system_prompt = ( + self._setup_result.system_prompt + if self._setup_result is not None + else config.system_prompt or "" + ) + + return GoogleADKA2AExecutor( + model=model, + system_prompt=system_prompt, + agent_name=rc.get("agent_name", _DEFAULT_AGENT_NAME), + max_output_tokens=int(rc.get("max_output_tokens", _DEFAULT_MAX_OUTPUT_TOKENS)), + temperature=float(rc.get("temperature", _DEFAULT_TEMPERATURE)), + heartbeat=config.heartbeat, + ) + + +# --------------------------------------------------------------------------- +# Module-level alias required by the adapter autodiscovery loader +# --------------------------------------------------------------------------- + +Adapter = GoogleADKAdapter diff --git a/workspace-template/adapters/google-adk/requirements.txt b/workspace-template/adapters/google-adk/requirements.txt new file mode 100644 index 00000000..fe125c33 --- /dev/null +++ b/workspace-template/adapters/google-adk/requirements.txt @@ -0,0 +1,7 @@ +# Google ADK adapter dependencies +# Pin to the latest stable release — update when a new version is verified. +google-adk==1.30.0 + +# google-adk transitively requires google-genai; pin explicitly for +# reproducibility (same pinning convention as other adapter requirements.txt). +google-genai>=1.16.0 diff --git a/workspace-template/adapters/google-adk/test_adapter.py b/workspace-template/adapters/google-adk/test_adapter.py new file mode 100644 index 00000000..773a001d --- /dev/null +++ b/workspace-template/adapters/google-adk/test_adapter.py @@ -0,0 +1,996 @@ +"""Unit tests for adapters/google-adk/adapter.py. + +Coverage targets (100%) +----------------------- +- Module constants: _DEFAULT_AGENT_NAME, _DEFAULT_MAX_OUTPUT_TOKENS, etc. +- MissingContent sentinel class +- GoogleADKA2AExecutor.__init__ — field assignment + runner injection +- GoogleADKA2AExecutor._extract_text +- GoogleADKA2AExecutor._build_content +- GoogleADKA2AExecutor._ensure_session — first call (create), subsequent call (skip) +- GoogleADKA2AExecutor.execute — happy path, empty input, API error, + no final_response events, partial text +- GoogleADKA2AExecutor.cancel — TaskStatusUpdateEvent emitted +- GoogleADKAdapter.name / display_name / description / get_config_schema +- GoogleADKAdapter.setup — success, missing key, vertex override +- GoogleADKAdapter.create_executor — model stripping, defaults, rc overrides +- Adapter alias + +All google-adk, google-genai, and shared_runtime calls are mocked. +No live API calls are made. +""" +from __future__ import annotations + +import sys +from types import ModuleType +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Stub heavy external modules BEFORE the adapter is imported. +# conftest.py already stubs: a2a, builtin_tools, langchain_core. +# We need to additionally stub: google.adk, google.genai, shared_runtime. +# --------------------------------------------------------------------------- + + +def _make_a2a_stubs() -> None: + """Register minimal a2a SDK stubs in sys.modules. + + Mirrors what workspace-template/tests/conftest.py does; needed because + this test file lives outside the ``tests/`` directory and conftest.py + is not automatically loaded for it. + """ + if "a2a" in sys.modules: + # Already mocked by conftest — just ensure new_agent_text_message is passthrough + a2a_utils = sys.modules.get("a2a.utils") + if a2a_utils and callable(getattr(a2a_utils, "new_agent_text_message", None)): + a2a_utils.new_agent_text_message = lambda text, **kwargs: text + return + + agent_execution_mod = ModuleType("a2a.server.agent_execution") + + class AgentExecutor: + pass + + class RequestContext: + pass + + agent_execution_mod.AgentExecutor = AgentExecutor + agent_execution_mod.RequestContext = RequestContext + + events_mod = ModuleType("a2a.server.events") + + class EventQueue: + pass + + events_mod.EventQueue = EventQueue + + tasks_mod = ModuleType("a2a.server.tasks") + types_mod = ModuleType("a2a.types") + + class TextPart: + def __init__(self, text=""): + self.text = text + + class Part: + def __init__(self, root=None): + self.root = root + + types_mod.TextPart = TextPart + types_mod.Part = Part + + utils_mod = ModuleType("a2a.utils") + # Passthrough so tests can assert on the plain text string, matching the + # hermes_executor test convention from conftest.py. + utils_mod.new_agent_text_message = lambda text, **kwargs: text + + a2a_mod = ModuleType("a2a") + a2a_server_mod = ModuleType("a2a.server") + + sys.modules["a2a"] = a2a_mod + sys.modules["a2a.server"] = a2a_server_mod + sys.modules["a2a.server.agent_execution"] = agent_execution_mod + sys.modules["a2a.server.events"] = events_mod + sys.modules["a2a.server.tasks"] = tasks_mod + sys.modules["a2a.types"] = types_mod + sys.modules["a2a.utils"] = utils_mod + + +def _make_google_adk_stubs() -> None: + """Register minimal google.adk and google.genai stubs in sys.modules.""" + # google (top-level namespace package) + google_mod = sys.modules.get("google") or ModuleType("google") + google_mod.__path__ = [] + sys.modules.setdefault("google", google_mod) + + # google.genai + google_genai_mod = ModuleType("google.genai") + google_genai_mod.__path__ = [] + + google_genai_types_mod = ModuleType("google.genai.types") + + class _Content: + def __init__(self, role="user", parts=None): + self.role = role + self.parts = parts or [] + + class _Part: + def __init__(self, text=""): + self.text = text + + google_genai_types_mod.Content = _Content + google_genai_types_mod.Part = _Part + + sys.modules["google.genai"] = google_genai_mod + sys.modules["google.genai.types"] = google_genai_types_mod + + # google.adk + google_adk_mod = ModuleType("google.adk") + google_adk_mod.__path__ = [] + + # google.adk.agents + google_adk_agents_mod = ModuleType("google.adk.agents") + + class _LlmAgent: + def __init__(self, name="", model="", instruction="", tools=None): + self.name = name + self.model = model + self.instruction = instruction + self.tools = tools or [] + + google_adk_agents_mod.LlmAgent = _LlmAgent + + # google.adk.runners + google_adk_runners_mod = ModuleType("google.adk.runners") + + class _Runner: + def __init__(self, agent=None, app_name="", session_service=None): + self.agent = agent + self.app_name = app_name + self.session_service = session_service + + async def run_async(self, session_id, user_id, new_message): + # Stub — tests override this via mock runner + return + yield # make it an async generator + + google_adk_runners_mod.Runner = _Runner + + # google.adk.sessions + google_adk_sessions_mod = ModuleType("google.adk.sessions") + + class _InMemorySessionService: + def __init__(self): + self._sessions: dict = {} + + async def get_session(self, app_name, user_id, session_id): + return self._sessions.get((app_name, user_id, session_id)) + + async def create_session(self, app_name, user_id, session_id): + self._sessions[(app_name, user_id, session_id)] = {"id": session_id} + return self._sessions[(app_name, user_id, session_id)] + + google_adk_sessions_mod.InMemorySessionService = _InMemorySessionService + + sys.modules["google.adk"] = google_adk_mod + sys.modules["google.adk.agents"] = google_adk_agents_mod + sys.modules["google.adk.runners"] = google_adk_runners_mod + sys.modules["google.adk.sessions"] = google_adk_sessions_mod + + +def _make_shared_runtime_stub() -> None: + """Register shared_runtime stub with extract_message_text.""" + if "shared_runtime" not in sys.modules: + mod = ModuleType("shared_runtime") + + def _extract_message_text(ctx) -> str: + parts = getattr(getattr(ctx, "message", None), "parts", None) + if parts is None: + parts = ctx + texts = [] + for p in parts or []: + t = getattr(p, "text", None) or getattr( + getattr(p, "root", None), "text", None + ) or "" + if t: + texts.append(t) + return " ".join(texts).strip() + + mod.extract_message_text = _extract_message_text + sys.modules["shared_runtime"] = mod + + +def _make_adapter_base_stub() -> None: + """Register adapter_base stub in sys.modules.""" + if "adapter_base" not in sys.modules: + mod = ModuleType("adapter_base") + from dataclasses import dataclass, field + from abc import ABC, abstractmethod + + @dataclass + class AdapterConfig: + model: str = "google:gemini-2.0-flash" + system_prompt: str | None = None + tools: list = field(default_factory=list) + runtime_config: dict = field(default_factory=dict) + config_path: str = "/configs" + workspace_id: str = "" + prompt_files: list = field(default_factory=list) + a2a_port: int = 8000 + heartbeat: object = None + + class BaseAdapter(ABC): + @staticmethod + @abstractmethod + def name() -> str: ... # pragma: no cover + + @staticmethod + @abstractmethod + def display_name() -> str: ... # pragma: no cover + + @staticmethod + @abstractmethod + def description() -> str: ... # pragma: no cover + + @staticmethod + def get_config_schema() -> dict: + return {} + + def memory_filename(self) -> str: + return "CLAUDE.md" + + def register_tool_hook(self, name, fn): return None # noqa + + async def transcript_lines(self, since=0, limit=100): return {"supported": False} # noqa + + def register_subagent_hook(self, name, spec): return None # noqa + + def append_to_memory_hook(self, config, filename, content): pass # noqa + + async def install_plugins_via_registry(self, config, plugins): return [] # noqa + + async def inject_plugins(self, config, plugins): + await self.install_plugins_via_registry(config, plugins) + + async def _common_setup(self, config): + from types import SimpleNamespace + return SimpleNamespace( + system_prompt="mocked system prompt", + loaded_skills=[], + langchain_tools=[], + is_coordinator=False, + children=[], + ) + + @abstractmethod + async def setup(self, config) -> None: ... # pragma: no cover + + @abstractmethod + async def create_executor(self, config): ... # pragma: no cover + + mod.AdapterConfig = AdapterConfig + mod.BaseAdapter = BaseAdapter + mod.SetupResult = None + sys.modules["adapter_base"] = mod + + +# Install all stubs before importing the module under test +# Order matters: a2a must be stubbed before adapter.py is imported so that +# `from a2a.utils import new_agent_text_message` resolves to the passthrough. +_make_a2a_stubs() +_make_google_adk_stubs() +_make_shared_runtime_stub() +_make_adapter_base_stub() + +# Now safe to import the adapter +import sys as _sys +import os as _os +_adapter_dir = _os.path.dirname(_os.path.abspath(__file__)) +if _adapter_dir not in _sys.path: + _sys.path.insert(0, _adapter_dir) + +from adapter import ( # noqa: E402 + Adapter, + GoogleADKA2AExecutor, + GoogleADKAdapter, + MissingContent, + _DEFAULT_AGENT_NAME, + _DEFAULT_MAX_OUTPUT_TOKENS, + _DEFAULT_TEMPERATURE, + _NO_RESPONSE_MSG, + _NO_TEXT_MSG, +) + + +# --------------------------------------------------------------------------- +# Fixtures and helpers +# --------------------------------------------------------------------------- + + +def _make_context(text: str, context_id: str = "ctx-test") -> MagicMock: + """Return a mock RequestContext with the given text in message.parts.""" + part = MagicMock() + part.text = text + ctx = MagicMock() + ctx.message.parts = [part] + ctx.context_id = context_id + return ctx + + +def _make_empty_context() -> MagicMock: + """Return a context whose message parts contain no text.""" + part = MagicMock(spec=[]) + part.root = MagicMock(spec=[]) + ctx = MagicMock() + ctx.message.parts = [part] + ctx.context_id = "ctx-empty" + return ctx + + +def _make_event(is_final: bool, text: str | None = None) -> MagicMock: + """Build a mock ADK Event that optionally is a final response.""" + event = MagicMock() + event.is_final_response = MagicMock(return_value=is_final) + if text is not None: + part = MagicMock() + part.text = text + event.response = MagicMock() + event.response.content = MagicMock() + event.response.content.parts = [part] + else: + event.response = None + return event + + +async def _async_gen(*events): + """Yield events one by one as an async generator.""" + for e in events: + yield e + + +def _make_runner(events=None) -> MagicMock: + """Return a mock Runner whose run_async yields the given events.""" + runner = MagicMock() + runner.session_service = AsyncMock() + runner.session_service.get_session = AsyncMock(return_value=None) + runner.session_service.create_session = AsyncMock(return_value={"id": "s1"}) + evts = events or [] + runner.run_async = MagicMock(return_value=_async_gen(*evts)) + return runner + + +def _make_executor( + model: str = "gemini-2.0-flash", + system_prompt: str | None = "You are helpful.", + runner: MagicMock | None = None, +) -> GoogleADKA2AExecutor: + """Create a GoogleADKA2AExecutor with an injected mock runner.""" + return GoogleADKA2AExecutor( + model=model, + system_prompt=system_prompt, + _runner=runner or _make_runner(), + ) + + +def _make_adapter_config(**kwargs) -> object: + """Return an AdapterConfig with sensible defaults.""" + from adapter_base import AdapterConfig + defaults = dict( + model="google:gemini-2.0-flash", + system_prompt="Test prompt.", + runtime_config={}, + workspace_id="ws-test", + ) + defaults.update(kwargs) + return AdapterConfig(**defaults) + + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + + +def test_default_agent_name(): + assert _DEFAULT_AGENT_NAME == "molecule-adk-agent" + + +def test_default_max_output_tokens(): + assert _DEFAULT_MAX_OUTPUT_TOKENS == 8192 + + +def test_default_temperature(): + assert _DEFAULT_TEMPERATURE == 1.0 + + +def test_no_text_msg_constant(): + assert "no text" in _NO_TEXT_MSG.lower() + + +def test_no_response_msg_constant(): + assert "no response" in _NO_RESPONSE_MSG.lower() + + +# --------------------------------------------------------------------------- +# MissingContent sentinel +# --------------------------------------------------------------------------- + + +def test_missing_content_has_empty_parts(): + mc = MissingContent() + assert mc.parts == [] + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — construction +# --------------------------------------------------------------------------- + + +def test_constructor_stores_fields(): + runner = _make_runner() + executor = GoogleADKA2AExecutor( + model="gemini-1.5-pro", + system_prompt="Hello", + agent_name="my-agent", + max_output_tokens=4096, + temperature=0.5, + _runner=runner, + ) + assert executor.model == "gemini-1.5-pro" + assert executor.system_prompt == "Hello" + assert executor.agent_name == "my-agent" + assert executor.max_output_tokens == 4096 + assert executor.temperature == 0.5 + assert executor._runner is runner + assert executor._sessions_created == set() + + +def test_constructor_defaults(): + executor = GoogleADKA2AExecutor(model="gemini-2.0-flash", _runner=_make_runner()) + assert executor.system_prompt is None + assert executor.agent_name == _DEFAULT_AGENT_NAME + assert executor.max_output_tokens == _DEFAULT_MAX_OUTPUT_TOKENS + assert executor.temperature == _DEFAULT_TEMPERATURE + assert executor._heartbeat is None + + +def test_constructor_uses_injected_runner(): + stub = MagicMock() + stub.session_service = MagicMock() + executor = GoogleADKA2AExecutor(model="gemini-2.0-flash", _runner=stub) + assert executor._runner is stub + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — _extract_text +# --------------------------------------------------------------------------- + + +def test_extract_text_returns_message_text(): + executor = _make_executor() + ctx = _make_context("Hello world") + result = executor._extract_text(ctx) + assert result == "Hello world" + + +def test_extract_text_empty_context(): + executor = _make_executor() + ctx = _make_empty_context() + result = executor._extract_text(ctx) + assert result == "" + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — _build_content +# --------------------------------------------------------------------------- + + +def test_build_content_creates_content_object(): + executor = _make_executor() + content = executor._build_content("test message") + assert content.role == "user" + assert len(content.parts) == 1 + assert content.parts[0].text == "test message" + + +def test_build_content_empty_string(): + executor = _make_executor() + content = executor._build_content("") + assert content.parts[0].text == "" + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — _ensure_session +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_ensure_session_creates_when_not_exists(): + runner = _make_runner() + runner.session_service.get_session = AsyncMock(return_value=None) + executor = GoogleADKA2AExecutor( + model="gemini-2.0-flash", agent_name="test-agent", _runner=runner + ) + await executor._ensure_session("session-1", "user-1") + runner.session_service.create_session.assert_called_once_with( + app_name="test-agent", + user_id="user-1", + session_id="session-1", + ) + assert "session-1" in executor._sessions_created + + +@pytest.mark.asyncio +async def test_ensure_session_skips_if_already_tracked(): + runner = _make_runner() + executor = GoogleADKA2AExecutor( + model="gemini-2.0-flash", _runner=runner + ) + executor._sessions_created.add("session-x") + await executor._ensure_session("session-x", "user-1") + # Neither get_session nor create_session should be called + runner.session_service.get_session.assert_not_called() + runner.session_service.create_session.assert_not_called() + + +@pytest.mark.asyncio +async def test_ensure_session_skips_create_when_existing(): + runner = _make_runner() + runner.session_service.get_session = AsyncMock(return_value={"id": "s1"}) + executor = GoogleADKA2AExecutor( + model="gemini-2.0-flash", agent_name="test-agent", _runner=runner + ) + await executor._ensure_session("session-existing", "user-1") + runner.session_service.create_session.assert_not_called() + assert "session-existing" in executor._sessions_created + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — execute: happy path +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_execute_returns_response_text(): + event = _make_event(is_final=True, text="The answer is 42.") + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("What is 6×7?") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with("The answer is 42.") + + +@pytest.mark.asyncio +async def test_execute_concatenates_multiple_final_parts(): + part1 = MagicMock() + part1.text = "Hello " + part2 = MagicMock() + part2.text = "world" + event = MagicMock() + event.is_final_response = MagicMock(return_value=True) + event.response = MagicMock() + event.response.content = MagicMock() + event.response.content.parts = [part1, part2] + + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("Hi") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with("Hello world") + + +@pytest.mark.asyncio +async def test_execute_skips_non_final_events(): + non_final = _make_event(is_final=False, text="intermediate") + final = _make_event(is_final=True, text="final answer") + runner = _make_runner(events=[non_final, final]) + executor = _make_executor(runner=runner) + + ctx = _make_context("question") + eq = AsyncMock() + await executor.execute(ctx, eq) + + enqueued = eq.enqueue_event.call_args[0][0] + assert enqueued == "final answer" + + +@pytest.mark.asyncio +async def test_execute_fallback_when_no_final_response_events(): + non_final = _make_event(is_final=False) + runner = _make_runner(events=[non_final]) + executor = _make_executor(runner=runner) + + ctx = _make_context("hello") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with(_NO_RESPONSE_MSG) + + +@pytest.mark.asyncio +async def test_execute_fallback_when_response_is_none(): + event = MagicMock() + event.is_final_response = MagicMock(return_value=True) + event.response = None # no response object + + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("ping") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with(_NO_RESPONSE_MSG) + + +@pytest.mark.asyncio +async def test_execute_fallback_when_parts_have_no_text(): + part = MagicMock() + part.text = None # no text on the part + event = MagicMock() + event.is_final_response = MagicMock(return_value=True) + event.response = MagicMock() + event.response.content = MagicMock() + event.response.content.parts = [part] + + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("ping") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with(_NO_RESPONSE_MSG) + + +@pytest.mark.asyncio +async def test_execute_fallback_when_response_content_is_none(): + event = MagicMock() + event.is_final_response = MagicMock(return_value=True) + event.response = MagicMock() + event.response.content = None # content is None → MissingContent sentinel + + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("ping") + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with(_NO_RESPONSE_MSG) + + +@pytest.mark.asyncio +async def test_execute_uses_context_id_as_session_id(): + event = _make_event(is_final=True, text="ok") + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("hello", context_id="ctx-abc-123") + eq = AsyncMock() + await executor.execute(ctx, eq) + + runner.run_async.assert_called_once() + call_kwargs = runner.run_async.call_args[1] + assert call_kwargs["session_id"] == "ctx-abc-123" + assert call_kwargs["user_id"] == "molecule-user" + + +@pytest.mark.asyncio +async def test_execute_falls_back_to_default_session_id_when_context_id_is_none(): + event = _make_event(is_final=True, text="ok") + runner = _make_runner(events=[event]) + executor = _make_executor(runner=runner) + + ctx = _make_context("hello") + ctx.context_id = None # override + eq = AsyncMock() + await executor.execute(ctx, eq) + + call_kwargs = runner.run_async.call_args[1] + assert call_kwargs["session_id"] == "default-session" + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — execute: empty input +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_execute_empty_input_returns_error(): + runner = _make_runner() + executor = _make_executor(runner=runner) + + ctx = _make_empty_context() + eq = AsyncMock() + await executor.execute(ctx, eq) + + eq.enqueue_event.assert_called_once_with(_NO_TEXT_MSG) + runner.run_async.assert_not_called() + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — execute: error handling +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_execute_api_error_returns_sanitized_message(): + runner = _make_runner() + + class _FakeAPIError(Exception): + pass + + async def _raise(*args, **kwargs): + raise _FakeAPIError("api_key=secret token_limit_exceeded") + yield # make it an async generator + + runner.run_async = MagicMock(return_value=_raise()) + executor = _make_executor(runner=runner) + + eq = AsyncMock() + await executor.execute(_make_context("hello"), eq) + + enqueued = eq.enqueue_event.call_args[0][0] + assert enqueued == "Agent error: _FakeAPIError" + assert "secret" not in enqueued + + +@pytest.mark.asyncio +async def test_execute_api_error_is_logged(caplog): + import logging + + runner = _make_runner() + + async def _raise(*args, **kwargs): + raise ValueError("bad request") + yield # make it an async generator + + runner.run_async = MagicMock(return_value=_raise()) + executor = _make_executor(runner=runner) + + with caplog.at_level(logging.ERROR, logger="adapter"): + await executor.execute(_make_context("hello"), AsyncMock()) + + assert any("execution error" in r.message.lower() for r in caplog.records) + + +# --------------------------------------------------------------------------- +# GoogleADKA2AExecutor — cancel +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_cancel_emits_canceled_event(): + executor = _make_executor() + + import a2a.types as a2a_types + + class _TaskState: + canceled = "canceled" + + class _TaskStatus: + def __init__(self, state): + self.state = state + + class _TaskStatusUpdateEvent: + def __init__(self, status, final): + self.status = status + self.final = final + + a2a_types.TaskState = _TaskState + a2a_types.TaskStatus = _TaskStatus + a2a_types.TaskStatusUpdateEvent = _TaskStatusUpdateEvent + + eq = AsyncMock() + ctx = MagicMock() + await executor.cancel(ctx, eq) + + eq.enqueue_event.assert_called_once() + event = eq.enqueue_event.call_args[0][0] + assert isinstance(event, _TaskStatusUpdateEvent) + assert event.status.state == "canceled" + assert event.final is True + + +# --------------------------------------------------------------------------- +# GoogleADKAdapter — identity methods +# --------------------------------------------------------------------------- + + +def test_adapter_name(): + assert GoogleADKAdapter.name() == "google-adk" + + +def test_adapter_display_name(): + assert "Google ADK" in GoogleADKAdapter.display_name() + + +def test_adapter_description(): + desc = GoogleADKAdapter.description() + assert "ADK" in desc or "Google" in desc + + +def test_adapter_get_config_schema(): + schema = GoogleADKAdapter.get_config_schema() + assert schema["type"] == "object" + assert "agent_name" in schema["properties"] + assert "max_output_tokens" in schema["properties"] + assert "temperature" in schema["properties"] + + +# --------------------------------------------------------------------------- +# GoogleADKAdapter — setup +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_setup_succeeds_with_api_key(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "fake-api-key") + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + + adapter = GoogleADKAdapter() + config = _make_adapter_config() + + await adapter.setup(config) + + assert adapter._setup_result is not None + assert adapter._setup_result.system_prompt == "mocked system prompt" + + +@pytest.mark.asyncio +async def test_setup_succeeds_with_vertex_ai(monkeypatch): + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "1") + + adapter = GoogleADKAdapter() + config = _make_adapter_config() + + await adapter.setup(config) + + assert adapter._setup_result is not None + + +@pytest.mark.asyncio +async def test_setup_succeeds_with_vertex_ai_true_string(monkeypatch): + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "True") + + adapter = GoogleADKAdapter() + config = _make_adapter_config() + + await adapter.setup(config) + assert adapter._setup_result is not None + + +@pytest.mark.asyncio +async def test_setup_raises_without_credentials(monkeypatch): + monkeypatch.delenv("GOOGLE_API_KEY", raising=False) + monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False) + + adapter = GoogleADKAdapter() + config = _make_adapter_config() + + with pytest.raises(RuntimeError, match="GOOGLE_API_KEY"): + await adapter.setup(config) + + +# --------------------------------------------------------------------------- +# GoogleADKAdapter — create_executor +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_create_executor_strips_google_prefix(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config(model="google:gemini-2.0-flash") + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor.model == "gemini-2.0-flash" + + +@pytest.mark.asyncio +async def test_create_executor_no_prefix_passthrough(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config(model="gemini-1.5-pro") + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor.model == "gemini-1.5-pro" + + +@pytest.mark.asyncio +async def test_create_executor_uses_setup_system_prompt(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config() + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor.system_prompt == "mocked system prompt" + + +@pytest.mark.asyncio +async def test_create_executor_runtime_config_overrides(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config( + runtime_config={ + "agent_name": "custom-agent", + "max_output_tokens": 512, + "temperature": 0.3, + } + ) + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor.agent_name == "custom-agent" + assert executor.max_output_tokens == 512 + assert executor.temperature == 0.3 + + +@pytest.mark.asyncio +async def test_create_executor_defaults_without_runtime_config(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config(runtime_config={}) + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor.agent_name == _DEFAULT_AGENT_NAME + assert executor.max_output_tokens == _DEFAULT_MAX_OUTPUT_TOKENS + assert executor.temperature == _DEFAULT_TEMPERATURE + + +@pytest.mark.asyncio +async def test_create_executor_without_setup_uses_config_system_prompt(monkeypatch): + """create_executor without prior setup falls back to config.system_prompt.""" + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config(system_prompt="fallback prompt") + # Intentionally skip setup() — _setup_result remains None + + executor = await adapter.create_executor(config) + assert executor.system_prompt == "fallback prompt" + + +@pytest.mark.asyncio +async def test_create_executor_without_setup_no_system_prompt(monkeypatch): + """create_executor without setup and no system_prompt → empty string.""" + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + config = _make_adapter_config(system_prompt=None) + # Skip setup() + + executor = await adapter.create_executor(config) + assert executor.system_prompt == "" + + +@pytest.mark.asyncio +async def test_create_executor_heartbeat_passed(monkeypatch): + monkeypatch.setenv("GOOGLE_API_KEY", "key") + adapter = GoogleADKAdapter() + heartbeat = MagicMock() + config = _make_adapter_config(heartbeat=heartbeat) + await adapter.setup(config) + + executor = await adapter.create_executor(config) + assert executor._heartbeat is heartbeat + + +# --------------------------------------------------------------------------- +# Adapter alias +# --------------------------------------------------------------------------- + + +def test_adapter_alias_is_google_adk_adapter(): + assert Adapter is GoogleADKAdapter From b69e50d98c8a21dfd8e6745aaa01619b6e7259e4 Mon Sep 17 00:00:00 2001 From: Molecule AI DevOps Engineer Date: Fri, 17 Apr 2026 00:12:07 +0000 Subject: [PATCH 02/32] fix(scripts): add dedup_settings_hooks + verify utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit molecule_runtime's _deep_merge_hooks() uses unconditional list.extend() when merging plugin settings-fragment.json files. On every plugin install or reinstall each hook handler is re-appended, causing 3-4x duplicate firings per event. scripts/dedup_settings_hooks.py — idempotent live fix (reads via /proc/*/root, no docker CLI required). Safe to re-run. scripts/verify_settings_hooks.py — exits 1 if any container still has duplicate hooks; used in CI health checks and manual audits. Upstream fix needed in molecule_runtime._deep_merge_hooks() to deduplicate by (matcher, frozenset(commands)) before writing. Track separately. Co-Authored-By: Claude Sonnet 4.6 --- scripts/dedup_settings_hooks.py | 95 ++++++++++++++++++++++++++++++++ scripts/verify_settings_hooks.py | 67 ++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 scripts/dedup_settings_hooks.py create mode 100644 scripts/verify_settings_hooks.py diff --git a/scripts/dedup_settings_hooks.py b/scripts/dedup_settings_hooks.py new file mode 100644 index 00000000..67d778df --- /dev/null +++ b/scripts/dedup_settings_hooks.py @@ -0,0 +1,95 @@ +#!/usr/bin/env python3 +"""Deduplicate hook entries in .claude/settings.json across all workspace containers. + +Root cause: molecule_runtime's _deep_merge_hooks() uses unconditional list.extend() +when merging plugin settings-fragment.json files. On every plugin install/reinstall +each hook handler is appended again, producing 3-4x duplicates that cause every +hook to fire 3-4x per event. + +This script fixes the live settings.json in every running workspace container via +the shared /proc//root filesystem (no docker CLI required), then validates the +output is clean JSON. Safe to re-run — idempotent (already-clean files are skipped). + +Upstream fix needed: molecule_runtime.plugins_registry.builtins._deep_merge_hooks() +should deduplicate by (matcher, frozenset(commands)) before writing. Tracked in +molecule-core issue (filed separately). + +Usage: + python3 scripts/dedup_settings_hooks.py [--dry-run] +""" + +from __future__ import annotations + +import glob +import json +import sys + +DRY_RUN = "--dry-run" in sys.argv + + +def dedup_settings(data: dict) -> tuple[dict, dict[str, tuple[int, int]]]: + """Return (deduped_data, stats) where stats[event] = (before_count, after_count).""" + if "hooks" not in data: + return data, {} + new_hooks: dict = {} + stats: dict[str, tuple[int, int]] = {} + for event, handlers in data["hooks"].items(): + seen: set = set() + deduped: list = [] + for handler in handlers: + matcher = handler.get("matcher", "") + commands = frozenset(h.get("command", "") for h in handler.get("hooks", [])) + key = (matcher, commands) + if key not in seen: + seen.add(key) + deduped.append(handler) + stats[event] = (len(handlers), len(deduped)) + new_hooks[event] = deduped + return {**data, "hooks": new_hooks}, stats + + +def main() -> None: + pattern = "/proc/*/root/configs/.claude/settings.json" + paths = sorted(glob.glob(pattern)) + + fixed: list[tuple[str, dict]] = [] + already_clean: list[str] = [] + errors: list[tuple[str, str]] = [] + + for path in paths: + try: + with open(path) as f: + data = json.load(f) + deduped, stats = dedup_settings(data) + changed = any(before != after for before, after in stats.values()) + if changed: + if not DRY_RUN: + with open(path, "w") as f: + json.dump(deduped, f, indent=2) + f.write("\n") + fixed.append((path, stats)) + else: + already_clean.append(path) + except PermissionError as e: + errors.append((path, f"PermissionError: {e}")) + except json.JSONDecodeError as e: + errors.append((path, f"JSONDecodeError: {e}")) + except Exception as e: + errors.append((path, str(e))) + + mode = "[DRY RUN] " if DRY_RUN else "" + print(f"{mode}Fixed: {len(fixed)}") + for path, stats in fixed: + pid = path.split("/")[2] + summary = ", ".join(f"{ev}: {b}→{a}" for ev, (b, a) in stats.items() if b != a) + print(f" PID {pid}: {summary}") + print(f"{mode}Already clean: {len(already_clean)}") + if errors: + print(f"Errors: {len(errors)}") + for path, err in errors: + print(f" {path}: {err}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/verify_settings_hooks.py b/scripts/verify_settings_hooks.py new file mode 100644 index 00000000..e1211b8d --- /dev/null +++ b/scripts/verify_settings_hooks.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Verify settings.json hook deduplication across all workspace containers. + +Exits 0 if all containers have clean (no-duplicate) hook lists. +Exits 1 if any container still has duplicate hook entries. + +Usage: + python3 scripts/verify_settings_hooks.py +""" + +from __future__ import annotations + +import glob +import json +import sys + + +def has_duplicates(data: dict) -> tuple[bool, dict[str, tuple[int, int]]]: + stats: dict[str, tuple[int, int]] = {} + duplicate_found = False + for event, handlers in data.get("hooks", {}).items(): + seen: set = set() + for handler in handlers: + matcher = handler.get("matcher", "") + commands = frozenset(h.get("command", "") for h in handler.get("hooks", [])) + key = (matcher, commands) + if key in seen: + duplicate_found = True + seen.add(key) + stats[event] = (len(handlers), len(seen)) + return duplicate_found, stats + + +def main() -> None: + pattern = "/proc/*/root/configs/.claude/settings.json" + paths = sorted(glob.glob(pattern)) + + dirty: list[tuple[str, dict]] = [] + clean = 0 + errors: list[tuple[str, str]] = [] + + for path in paths: + try: + with open(path) as f: + data = json.load(f) + dup, stats = has_duplicates(data) + if dup: + dirty.append((path, stats)) + else: + clean += 1 + except Exception as e: + errors.append((path, str(e))) + + print(f"Clean: {clean} Dirty: {len(dirty)} Errors: {len(errors)}") + for path, stats in dirty: + pid = path.split("/")[2] + summary = ", ".join(f"{ev}: {total} total/{unique} unique" for ev, (total, unique) in stats.items()) + print(f" DIRTY PID {pid}: {summary}") + for path, err in errors: + print(f" ERROR {path}: {err}", file=sys.stderr) + + if dirty or errors: + sys.exit(1) + + +if __name__ == "__main__": + main() From 0d38d05d6f2e3caf5229d329ea4573e13f6798b3 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:12:52 +0000 Subject: [PATCH 03/32] docs(devrel): Hermes multi-provider dispatch tutorial (Phase 2a/2b/2c, issue #513) --- .../hermes-multi-provider-dispatch.md | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 docs/tutorials/hermes-multi-provider-dispatch.md diff --git a/docs/tutorials/hermes-multi-provider-dispatch.md b/docs/tutorials/hermes-multi-provider-dispatch.md new file mode 100644 index 00000000..efd6343a --- /dev/null +++ b/docs/tutorials/hermes-multi-provider-dispatch.md @@ -0,0 +1,173 @@ +# Hermes Multi-Provider Dispatch: Native Anthropic, Gemini, and Multi-Turn History + +Hermes is Molecule AI's inference router. Out of the box it proxies every model through an OpenAI-compatible shim — which works fine for plain text but silently strips Anthropic's `tool_use` blocks, vision content, and Gemini's `parts`-based message structure. + +Phases 2a–2c wired three native dispatch paths keyed on `auth_scheme`. This tutorial shows you how to unlock them, and why you should. + +## What you'll need + +- A Molecule AI account with API access +- `ANTHROPIC_API_KEY` **or** `GEMINI_API_KEY` (or both) +- `curl` + `jq` + +## The dispatch table + +After Phases 2a / 2b / 2c, Hermes picks an inference path based on which provider is configured: + +| `auth_scheme` | Dispatch path | Provider | API | +|---|---|---|---| +| `openai` | `_do_openai_compat` | 13 providers (OpenRouter, Groq, Mistral…) | OpenAI-compat shim | +| `anthropic` | `_do_anthropic_native` | Anthropic | Native Messages API | +| `gemini` | `_do_gemini_native` | Google | Native `generateContent` | +| unknown | `_do_openai_compat` + warning | any | OpenAI-compat shim (forward-compat) | + +**Rule of thumb:** set `ANTHROPIC_API_KEY` to get native Anthropic dispatch. Set `GEMINI_API_KEY` to get native Gemini dispatch. Set `NOUS_API_KEY` / `HERMES_API_KEY` / `OPENROUTER_API_KEY` to stay on the compat shim. Molecule AI reads these in priority order: `HERMES_API_KEY` → `OPENROUTER_API_KEY` → `ANTHROPIC_API_KEY` → `GEMINI_API_KEY`. The **first key found wins**, so don't set `HERMES_API_KEY` if you want native dispatch. + +--- + +## Setup + +```bash +# 0. Export your platform URL and a workspace to use as orchestrator +export MOLECULE_API=http://localhost:8080 +export ORCH_ID= + +# 1. Store your Anthropic key as a global secret +curl -s -X PUT $MOLECULE_API/settings/secrets \ + -H "Content-Type: application/json" \ + -d '{"key":"ANTHROPIC_API_KEY","value":"sk-ant-YOUR-KEY"}' | jq . + +# 2. Create a Hermes workspace — Anthropic native dispatch +ANTHROPIC_WS=$(curl -s -X POST $MOLECULE_API/workspaces \ + -H "Content-Type: application/json" \ + -d '{ + "name": "hermes-anthropic", + "role": "Inference worker — native Anthropic path", + "runtime": "hermes", + "model": "anthropic:claude-sonnet-4-5" + }' | jq -r '.id') +echo "Anthropic workspace: $ANTHROPIC_WS" + +# 3. Wait for it to be ready (~20–30s) +until curl -s $MOLECULE_API/workspaces/$ANTHROPIC_WS | jq -r '.status' | grep -q ready; do + echo "Waiting..."; sleep 5 +done + +# 4. Store your Gemini key as a global secret +curl -s -X PUT $MOLECULE_API/settings/secrets \ + -H "Content-Type: application/json" \ + -d '{"key":"GEMINI_API_KEY","value":"YOUR-GEMINI-KEY"}' | jq . + +# 5. Create a Hermes workspace — Gemini native dispatch +# We override the global ANTHROPIC_API_KEY at workspace scope so Gemini wins +GEMINI_WS=$(curl -s -X POST $MOLECULE_API/workspaces \ + -H "Content-Type: application/json" \ + -d '{ + "name": "hermes-gemini", + "role": "Inference worker — native Gemini path", + "runtime": "hermes", + "model": "gemini:gemini-2.0-flash" + }' | jq -r '.id') +echo "Gemini workspace: $GEMINI_WS" + +# 6. Pin the Gemini workspace to Gemini-only keys (no ANTHROPIC_API_KEY override) +curl -s -X PUT $MOLECULE_API/workspaces/$GEMINI_WS/secrets \ + -H "Content-Type: application/json" \ + -d '{"key":"ANTHROPIC_API_KEY","value":""}' | jq . + +# 7. Confirm dispatch — send a single-turn probe to the Anthropic workspace +curl -s -X POST $MOLECULE_API/workspaces/$ANTHROPIC_WS/a2a \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":"probe-1","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text","text":"Which API are you using to generate this response?"}]}} + }' | jq '.result.parts[0].text' + +# 8. Same probe to the Gemini workspace +curl -s -X POST $MOLECULE_API/workspaces/$GEMINI_WS/a2a \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":"probe-2","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text","text":"Which API are you using to generate this response?"}]}} + }' | jq '.result.parts[0].text' + +# 9. Multi-turn history — Phase 2c keeps turns as turns (not flattened) +# Send turn 1 +curl -s -X POST $MOLECULE_API/workspaces/$ANTHROPIC_WS/a2a \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":"turn-1","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text","text":"My name is Alice. Remember that."}]}} + }' | jq '.result.parts[0].text' + +# 10. Send turn 2 — history is automatically threaded by Hermes Phase 2c +curl -s -X POST $MOLECULE_API/workspaces/$ANTHROPIC_WS/a2a \ + -H "Content-Type: application/json" \ + -d '{ + "jsonrpc":"2.0","id":"turn-2","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text","text":"What is my name?"}]}} + }' | jq '.result.parts[0].text' +# Expected: "Alice" — not "I don't know", which the old flattened path could produce +``` + +## Expected output + +**Step 7 (Anthropic workspace):** The agent confirms it is calling the Anthropic Messages API. Internally Hermes executed `_do_anthropic_native`, not the OpenAI shim. Tool-use blocks, vision content, and extended thinking all survive in round-trips. + +**Step 8 (Gemini workspace):** The agent confirms Google `generateContent`. Hermes called `_do_gemini_native`, which uses `role: "model"` (not `"assistant"`) and the `parts: [{text: ...}]` wrapper that the native SDK requires. The OpenAI-compat translation that previously stripped these is bypassed. + +**Step 10 (multi-turn, Phase 2c):** Returns `"Alice"`. Before Phase 2c, history was flattened into a single user blob — the model could still figure out context but lost role attribution and instruction-following across turns. Phase 2c passes turns as turns: OpenAI uses `{role, content}`, Anthropic uses the same wire shape for text, Gemini uses `{role: "model", parts: [{text}]}`. + +## How dispatch works under the hood + +`HermesA2AExecutor._do_inference(user_message, history)` reads `self.provider_cfg.auth_scheme`: + +```python +if self.provider_cfg.auth_scheme == "anthropic": + return await self._do_anthropic_native(user_message, history) +elif self.provider_cfg.auth_scheme == "gemini": + return await self._do_gemini_native(user_message, history) +else: # "openai" + unknown (forward-compat fallback) + return await self._do_openai_compat(user_message, history) +``` + +Fail-loud semantics: if the `anthropic` package isn't installed, `_do_anthropic_native` raises a clear `RuntimeError` before any inference attempt. Same for `google-genai`. Silent fallback to the compat shim would mask fidelity loss — Molecule AI chooses loud failure. + +## Building a multi-provider team + +The real win surfaces in a mixed-provider agent team. Your orchestrator can fan tasks to an Anthropic specialist (best at tool-calling) and a Gemini specialist (best at long-context) simultaneously, then synthesize: + +```bash +# Fan out from the orchestrator — both fire in parallel +curl -s -X POST $MOLECULE_API/workspaces/$ORCH_ID/a2a \ + -H "Content-Type: application/json" \ + -d "{ + \"jsonrpc\":\"2.0\",\"id\":\"fan-1\",\"method\":\"message/send\", + \"params\":{\"message\":{\"role\":\"user\",\"parts\":[{\"kind\":\"text\", + \"text\":\"delegate_task_async $ANTHROPIC_WS 'Draft tool-calling schema for a calendar booking agent' AND delegate_task_async $GEMINI_WS 'Summarise the last 30 days of support tickets'\"}]}} + }" | jq . +``` + +Both workers use their native inference paths. No LiteLLM proxy layer. No format translation taxes. The orchestrator gets results back through the same A2A protocol regardless of which underlying model powered each task. + +## Comparison: Hermes native vs the compat shim + +| Capability | OpenAI-compat shim | Anthropic native | Gemini native | +|---|---|---|---| +| Plain text | ✅ | ✅ | ✅ | +| `tool_use` / `tool_result` blocks | ❌ stripped | ✅ | ✅ | +| Vision content | ❌ stripped | ✅ | ✅ | +| Multi-turn history | ⚠️ flattened blob | ✅ role-attributed | ✅ `model` role + parts | +| Extended thinking | ❌ | ✅ (Phase 2d) | — | +| Streaming | ❌ (Phase 2d) | ❌ (Phase 2d) | ❌ (Phase 2d) | + +**Why Molecule AI vs Letta / AG2 / n8n:** Those frameworks handle multi-LLM at the application layer — you write different agent classes per provider. Molecule AI handles it at the infrastructure layer. Your workspace configs change; your orchestration code doesn't. Swap a Gemini worker for an Anthropic worker by changing one secret. No code redeploy. + +## Related + +- PR #240: [Phase 2a — native Anthropic dispatch](https://github.com/Molecule-AI/molecule-core/pull/240) +- PR #255: [Phase 2b — native Gemini dispatch](https://github.com/Molecule-AI/molecule-core/pull/255) +- PR #267: [Phase 2c — multi-turn history on all paths](https://github.com/Molecule-AI/molecule-core/pull/267) +- [Hermes adapter design](../adapters/hermes-adapter-design.md) +- [Platform API reference](../api-reference.md) +- Issue [#513](https://github.com/Molecule-AI/molecule-core/issues/513) From 85db648da3fb72dc42d8fef136dd43162c8316f6 Mon Sep 17 00:00:00 2001 From: Molecule AI Backend Engineer Date: Fri, 17 Apr 2026 00:19:06 +0000 Subject: [PATCH 04/32] feat(brand-monitor): add X API pay-per-use brand monitor with surge mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds brand-monitor/ — a cron-based X API v2 poller that posts new Molecule AI brand mentions to Slack #brand-monitoring. Surge mode enables 15-min polling for launch days / crisis windows; state persisted in .surge_state.json so restarts within an active window continue in surge mode. Closes #549 Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + brand-monitor/README.md | 139 +++++++ brand-monitor/monitor.py | 225 ++++++++++ brand-monitor/requirements.txt | 6 + brand-monitor/slack_client.py | 145 +++++++ brand-monitor/surge.py | 114 +++++ brand-monitor/test_monitor.py | 741 +++++++++++++++++++++++++++++++++ brand-monitor/x_client.py | 65 +++ 8 files changed, 1439 insertions(+) create mode 100644 brand-monitor/README.md create mode 100644 brand-monitor/monitor.py create mode 100644 brand-monitor/requirements.txt create mode 100644 brand-monitor/slack_client.py create mode 100644 brand-monitor/surge.py create mode 100644 brand-monitor/test_monitor.py create mode 100644 brand-monitor/x_client.py diff --git a/.gitignore b/.gitignore index ddfa7a84..a3a4a2a1 100644 --- a/.gitignore +++ b/.gitignore @@ -44,6 +44,10 @@ venv/ *.egg-info/ .pytest_cache/ +# Brand monitor runtime state (never commit) +brand-monitor/.surge_state.json +brand-monitor/.monitor_state.json + # Docker *.log diff --git a/brand-monitor/README.md b/brand-monitor/README.md new file mode 100644 index 00000000..adc914b7 --- /dev/null +++ b/brand-monitor/README.md @@ -0,0 +1,139 @@ +# Molecule AI Brand Monitor + +A cron-based X API v2 poller that posts new brand mentions of **Molecule AI** to Slack `#brand-monitoring`. + +Features: +- Smart query filter (from issue #549) suppresses drug-discovery SEO noise +- Deduplication via `since_id` — never posts the same tweet twice +- First run automatically backfills the last 24 hours +- **Surge mode** — 15-min polling for launch days / crisis windows (see below) +- `@here` alert when engagement > 10 or a competitor name appears +- Daily digest at 20:00 UTC + +--- + +## Setup + +### 1. Install dependencies + +```bash +cd brand-monitor +pip install -r requirements.txt +``` + +### 2. Set environment variables + +| Variable | Required | Description | +|---|---|---| +| `X_BEARER_TOKEN` | ✅ | X API Bearer token (from the Developer Portal) | +| `X_API_KEY` | ✅ | X API key (available for future OAuth use) | +| `X_API_SECRET` | ✅ | X API secret | +| `SLACK_WEBHOOK_URL` | ✅ | Slack incoming webhook URL for `#brand-monitoring` | +| `POLL_INTERVAL_SECONDS` | optional | Ambient polling cadence (default: `1800` = 30 min) | +| `SURGE_DURATION_HOURS` | optional | Surge window length in hours (default: `6`) | + +For local development, create a `.env` file (never commit it): + +```bash +X_BEARER_TOKEN=AAA... +X_API_KEY=BBB... +X_API_SECRET=CCC... +SLACK_WEBHOOK_URL=https://hooks.slack.com/services/... +``` + +> **TODO (DevOps):** Provision `X_BEARER_TOKEN`, `X_API_KEY`, `X_API_SECRET`, and `SLACK_WEBHOOK_URL` +> as workspace secrets. The X Developer App credentials are pending approval — blocked on that before +> the monitor can run in production. + +### 3. Run + +```bash +python monitor.py +``` + +The monitor logs to stdout and polls until interrupted (Ctrl-C or process signal). + +--- + +## Polling Cadence + +| Mode | Interval | How long | +|---|---|---| +| **Ambient** | 30 min (`POLL_INTERVAL_SECONDS`) | Continuous | +| **Surge** | 15 min (fixed) | `SURGE_DURATION_HOURS` (default 6 h) | + +--- + +## Surge Mode + +Surge mode temporarily increases the polling frequency to 15 minutes for a configurable window (default 6 hours). State is persisted in `.surge_state.json` — if the process restarts during a surge window, it picks back up automatically. + +### Activating manually (Slack slash command) + +> **TODO:** Configure the Slack app with a `/surge-monitor` slash command that calls the +> `enable_surge_mode()` Python function (or a thin wrapper HTTP endpoint). The Slack app +> configuration is a separate step; the state machine here is ready. + +When the command is wired up: +``` +/surge-monitor on # enable for default 6 h +/surge-monitor on 12h # enable for 12 h +/surge-monitor off # deactivate immediately +``` + +### Auto-trigger on `feat:` PR merge + +In your CI/CD pipeline (e.g. GitHub Actions), call `enable_surge_mode()` when a PR with a `feat:` prefix is merged: + +```python +# In a post-merge CI step: +import sys +sys.path.insert(0, "brand-monitor") +from monitor import enable_surge_mode +enable_surge_mode() # activates for SURGE_DURATION_HOURS +``` + +Or from the shell: +```bash +python -c "from monitor import enable_surge_mode; enable_surge_mode()" +``` + +### Deactivation + +Surge mode deactivates automatically when its window expires. To force early deactivation: + +```python +from surge import SurgeState +SurgeState().disable() +``` + +--- + +## Tests + +```bash +cd brand-monitor +pip install -r requirements.txt +pytest test_monitor.py -v --cov=. --cov-report=term-missing --cov-fail-under=100 +``` + +All HTTP calls are mocked — no live credentials needed in CI. + +--- + +## Gitignored runtime files + +- `.surge_state.json` — surge mode state +- `.monitor_state.json` — polling state (since_id, daily counts) + +--- + +## API Cost Estimate + +X API pay-per-use: **$0.005 / tweet read** + +| Scenario | Reads/month | Est. cost | +|---|---|---| +| Ambient (30 min), ~5 mentions/day | ~150 | $0.75 | +| Surge (15 min) for 6 h, 10 surge events/month | ~300 extra | $1.50 | +| **Total estimate** | **~450–800** | **$2–4/month** | diff --git a/brand-monitor/monitor.py b/brand-monitor/monitor.py new file mode 100644 index 00000000..2ac5092f --- /dev/null +++ b/brand-monitor/monitor.py @@ -0,0 +1,225 @@ +"""Brand monitor — main poller entry point. + +Entry point: + python monitor.py + +Environment variables (all required at startup): + X_BEARER_TOKEN — X API Bearer token + X_API_KEY — X API key (available for future OAuth use) + X_API_SECRET — X API secret + SLACK_WEBHOOK_URL — Slack incoming webhook URL + +Optional tuning: + POLL_INTERVAL_SECONDS — ambient polling cadence in seconds (default: 1800 = 30 min) + SURGE_DURATION_HOURS — surge window length in hours (default: 6) +""" + +import json +import logging +import os +import time +from datetime import datetime, timedelta, timezone + +from slack_client import SlackClient +from surge import SurgeState +from x_client import XClient + +logger = logging.getLogger(__name__) + +# ------------------------------------------------------------------ +# Constants +# ------------------------------------------------------------------ + +REQUIRED_ENV_VARS = ["X_BEARER_TOKEN", "X_API_KEY", "X_API_SECRET", "SLACK_WEBHOOK_URL"] + +DEFAULT_STATE_FILE = ".monitor_state.json" + +# Ambient cadence: 30 min per issue spec (configurable via env) +POLL_INTERVAL_SECONDS = int(os.environ.get("POLL_INTERVAL_SECONDS", "1800")) + +# Surge cadence: fixed at 15 min +SURGE_INTERVAL_SECONDS = 900 + +# Surge window length (configurable via env) +SURGE_DURATION_HOURS = int(os.environ.get("SURGE_DURATION_HOURS", "6")) + +# UTC hour at which the daily digest is sent +DIGEST_HOUR_UTC = 20 + + +# ------------------------------------------------------------------ +# Startup validation +# ------------------------------------------------------------------ + +def validate_env(): + """Raise EnvironmentError if any required env var is absent.""" + missing = [v for v in REQUIRED_ENV_VARS if not os.environ.get(v)] + if missing: + raise EnvironmentError( + f"Missing required environment variable(s): {', '.join(missing)}" + ) + + +# ------------------------------------------------------------------ +# Surge mode public entry point (callable from CI/CD on feat: PR merge) +# ------------------------------------------------------------------ + +def enable_surge_mode(duration_hours=None, state_file=None): + """Enable surge mode. Call this from CI/CD hooks on feat: PR merges. + + Args: + duration_hours: Override for surge window length. Defaults to the + SURGE_DURATION_HOURS env var (or 6 h). + state_file: Override path for .surge_state.json (mainly for tests). + """ + hours = duration_hours if duration_hours is not None else SURGE_DURATION_HOURS + kwargs = {} + if state_file is not None: + kwargs["state_file"] = state_file + surge = SurgeState(**kwargs) + surge.enable(hours) + logger.info("enable_surge_mode: activated for %d hour(s)", hours) + + +# ------------------------------------------------------------------ +# Monitor class +# ------------------------------------------------------------------ + +class Monitor: + """Cron-style poller: fetches new X mentions and posts them to Slack. + + Args: + state_file: Path to the JSON file that persists polling state + (since_id, daily_count, etc.). Defaults to + ``.monitor_state.json`` in the current directory. + surge_state_file: Path to the surge state JSON file. + """ + + def __init__(self, state_file=DEFAULT_STATE_FILE, surge_state_file=None): + validate_env() + self.x_client = XClient() + self.slack_client = SlackClient() + surge_kwargs = {} + if surge_state_file is not None: + surge_kwargs["state_file"] = surge_state_file + self.surge = SurgeState(**surge_kwargs) + self.state_file = state_file + self.state = self._load_state() + + # ------------------------------------------------------------------ + # State persistence + # ------------------------------------------------------------------ + + def _load_state(self): + if os.path.exists(self.state_file): + with open(self.state_file) as fh: + return json.load(fh) + return {} + + def _save_state(self): + with open(self.state_file, "w") as fh: + json.dump(self.state, fh, indent=2) + + # ------------------------------------------------------------------ + # Core poll + # ------------------------------------------------------------------ + + def run_poll(self): + """Fetch new tweets and post them to Slack. + + On first run (no saved since_id) backfills the last 24 h. + Tracks the newest tweet ID so subsequent runs avoid duplicates. + + Returns: + list: tweets posted this cycle (may be empty). + """ + since_id = self.state.get("since_id") + start_time = None + + if not since_id: + # First run: backfill last 24 h + start_time = ( + datetime.now(timezone.utc) - timedelta(hours=24) + ).strftime("%Y-%m-%dT%H:%M:%SZ") + logger.info("First run — backfilling last 24 h (start_time=%s)", start_time) + + tweets = self.x_client.search_recent(since_id=since_id, start_time=start_time) + + if tweets: + self.slack_client.post_mentions(tweets) + # X API returns tweets newest-first; store the top ID as next since_id + self.state["since_id"] = tweets[0]["id"] + + return tweets + + # ------------------------------------------------------------------ + # Daily digest + # ------------------------------------------------------------------ + + def _should_send_digest(self): + """True if it's 20:00 UTC and today's digest hasn't been sent yet.""" + now = datetime.now(timezone.utc) + if now.hour != DIGEST_HOUR_UTC: + return False + today = now.strftime("%Y-%m-%d") + return self.state.get("last_digest_date") != today + + def run_daily_digest(self): + """Compile and post the daily summary to Slack, then reset the counter.""" + mention_count = self.state.get("daily_count", 0) + self.slack_client.post_digest({"count": mention_count}) + self.state["daily_count"] = 0 + self.state["last_digest_date"] = datetime.now(timezone.utc).strftime("%Y-%m-%d") + self._save_state() + logger.info("Daily digest sent (count=%d)", mention_count) + + # ------------------------------------------------------------------ + # Main loop + # ------------------------------------------------------------------ + + def _run_once(self): + """Execute one full polling cycle. + + Returns: + int: seconds to sleep before the next cycle. + """ + self.surge.check_expiry() + tweets = self.run_poll() + + # Accumulate daily mention count + self.state["daily_count"] = self.state.get("daily_count", 0) + len(tweets) + self._save_state() + + if self._should_send_digest(): + self.run_daily_digest() + + return self.surge.get_interval(POLL_INTERVAL_SECONDS, SURGE_INTERVAL_SECONDS) + + def run(self): + """Blocking main loop. Runs until interrupted.""" + logger.info( + "Brand monitor starting — ambient interval %ds, surge interval %ds", + POLL_INTERVAL_SECONDS, + SURGE_INTERVAL_SECONDS, + ) + while True: + try: + interval = self._run_once() + except Exception as exc: # noqa: BLE001 + logger.error("Poll cycle failed: %s", exc) + interval = POLL_INTERVAL_SECONDS + logger.debug("Sleeping %ds until next poll", interval) + time.sleep(interval) + + +# ------------------------------------------------------------------ +# Entry point +# ------------------------------------------------------------------ + +if __name__ == "__main__": # pragma: no cover + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s %(levelname)s %(name)s — %(message)s", + ) + monitor = Monitor() + monitor.run() diff --git a/brand-monitor/requirements.txt b/brand-monitor/requirements.txt new file mode 100644 index 00000000..97db594a --- /dev/null +++ b/brand-monitor/requirements.txt @@ -0,0 +1,6 @@ +requests==2.32.3 +python-dotenv==1.0.1 + +# Test / dev +pytest==8.3.5 +pytest-cov==6.1.0 diff --git a/brand-monitor/slack_client.py b/brand-monitor/slack_client.py new file mode 100644 index 00000000..6a5f5fe5 --- /dev/null +++ b/brand-monitor/slack_client.py @@ -0,0 +1,145 @@ +"""Slack webhook client for posting brand mentions and daily digest.""" + +import os +import logging +import requests + +logger = logging.getLogger(__name__) + +# Competitor names that auto-trigger @here alert +COMPETITOR_NAMES = [ + "openai", "langchain", "langgraph", "autogen", "crewai", "crew ai", + "llamaindex", "dify", "flowise", "n8n", "zapier", "make.com", +] + +# Engagement threshold above which @here is triggered +AT_HERE_ENGAGEMENT_THRESHOLD = 10 + + +class SlackClient: + """Posts brand mention alerts and daily digests to a Slack webhook. + + Webhook URL from SLACK_WEBHOOK_URL env var. + """ + + def __init__(self): + self.webhook_url = os.environ.get("SLACK_WEBHOOK_URL") + if not self.webhook_url: + raise EnvironmentError("Missing required environment variable: SLACK_WEBHOOK_URL") + + # ------------------------------------------------------------------ + # Internal helpers + # ------------------------------------------------------------------ + + def _engagement_score(self, tweet): + """Sum of likes + retweets + replies.""" + metrics = tweet.get("public_metrics", {}) + return ( + metrics.get("like_count", 0) + + metrics.get("retweet_count", 0) + + metrics.get("reply_count", 0) + ) + + def _should_at_here(self, tweet): + """Return True if the tweet warrants an @here ping.""" + if self._engagement_score(tweet) > AT_HERE_ENGAGEMENT_THRESHOLD: + return True + text = tweet.get("text", "").lower() + return any(comp in text for comp in COMPETITOR_NAMES) + + def _format_tweet_block(self, tweet): + """Format a single tweet as a Slack mrkdwn string.""" + tweet_id = tweet.get("id", "") + author_id = tweet.get("author_id", "unknown") + text = tweet.get("text", "").replace("&", "&").replace("<", "<").replace(">", ">") + created_at = tweet.get("created_at", "") + metrics = tweet.get("public_metrics", {}) + url = f"https://twitter.com/i/web/status/{tweet_id}" + + return ( + f"*New mention* — <{url}|view>\n" + f">{text}\n" + f"Author: `{author_id}` | " + f"❤️ {metrics.get('like_count', 0)} " + f"🔁 {metrics.get('retweet_count', 0)} " + f"💬 {metrics.get('reply_count', 0)}\n" + f"_Posted: {created_at}_" + ) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def post_mentions(self, tweets): + """Bundle and post new brand mentions to Slack. + + Multiple tweets are sent in a single webhook payload, not one per tweet. + + Args: + tweets: List of tweet dicts from XClient.search_recent(). + + Returns: + None. No-ops on empty list. + + Raises: + requests.HTTPError: On non-2xx Slack response. + """ + if not tweets: + return + + has_at_here = any(self._should_at_here(t) for t in tweets) + + blocks = [] + if has_at_here: + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": ""}} + ) + + count = len(tweets) + header = f"*{count} new Molecule AI mention{'s' if count > 1 else ''}* in #brand-monitoring" + blocks.append({"type": "section", "text": {"type": "mrkdwn", "text": header}}) + blocks.append({"type": "divider"}) + + for tweet in tweets: + blocks.append( + {"type": "section", "text": {"type": "mrkdwn", "text": self._format_tweet_block(tweet)}} + ) + blocks.append({"type": "divider"}) + + payload = {"blocks": blocks} + logger.info("Posting %d mention(s) to Slack (at_here=%s)", count, has_at_here) + response = requests.post(self.webhook_url, json=payload, timeout=15) + response.raise_for_status() + + def post_digest(self, summary): + """Post the daily 20:00 UTC mention digest to Slack. + + Args: + summary: Dict with keys: + count (int): total mentions today + top_tweets (list, optional): list of high-engagement tweet dicts + + Raises: + requests.HTTPError: On non-2xx Slack response. + """ + count = summary.get("count", 0) + top_tweets = summary.get("top_tweets", []) + + lines = [ + "*📊 Daily Digest — Molecule AI Brand Mentions*", + f"Total mentions today: *{count}*", + ] + + if top_tweets: + lines.append("\n*Top engagements:*") + for tweet in top_tweets[:3]: + snippet = tweet.get("text", "")[:120] + score = self._engagement_score(tweet) + tweet_id = tweet.get("id", "") + url = f"https://twitter.com/i/web/status/{tweet_id}" + lines.append(f"• <{url}|{snippet}…> _(score: {score})_") + + payload = {"text": "\n".join(lines)} + logger.info("Posting daily digest to Slack (count=%d)", count) + response = requests.post(self.webhook_url, json=payload, timeout=15) + response.raise_for_status() diff --git a/brand-monitor/surge.py b/brand-monitor/surge.py new file mode 100644 index 00000000..9a11800c --- /dev/null +++ b/brand-monitor/surge.py @@ -0,0 +1,114 @@ +"""Surge mode state machine. + +Surge mode increases polling frequency from 30 min to 15 min for a +configurable window (default 6 h). State is persisted in a JSON file so +restarts during an active surge window continue in surge mode. + +Activation paths: + 1. Manual: call enable_surge_mode() (or the Slack slash command /surge-monitor on) + 2. Auto: any PR merged with a 'feat:' prefix calls enable_surge_mode() +""" + +import json +import logging +import os +from datetime import datetime, timedelta, timezone + +logger = logging.getLogger(__name__) + +DEFAULT_SURGE_FILE = ".surge_state.json" +DEFAULT_SURGE_DURATION_HOURS = 6 + + +class SurgeState: + """Persist and query surge mode activation. + + Args: + state_file: Path to the JSON state file. Defaults to + ``.surge_state.json`` in the current directory. + """ + + def __init__(self, state_file=DEFAULT_SURGE_FILE): + self.state_file = state_file + + # ------------------------------------------------------------------ + # State I/O + # ------------------------------------------------------------------ + + def _load(self): + """Return parsed state dict, or None if the file doesn't exist.""" + if not os.path.exists(self.state_file): + return None + with open(self.state_file) as fh: + return json.load(fh) + + def _write(self, state): + with open(self.state_file, "w") as fh: + json.dump(state, fh, indent=2) + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def enable(self, duration_hours=DEFAULT_SURGE_DURATION_HOURS): + """Activate surge mode for *duration_hours* hours. + + Writes ``.surge_state.json`` so that restarts re-enter surge mode. + + Args: + duration_hours: How long surge mode stays active (default 6 h). + """ + expires_at = ( + datetime.now(timezone.utc) + timedelta(hours=duration_hours) + ).isoformat() + state = { + "active": True, + "enabled_at": datetime.now(timezone.utc).isoformat(), + "expires_at": expires_at, + "duration_hours": duration_hours, + } + self._write(state) + logger.info("Surge mode enabled for %dh — expires at %s", duration_hours, expires_at) + + def disable(self): + """Deactivate surge mode and remove the state file.""" + if os.path.exists(self.state_file): + os.remove(self.state_file) + logger.info("Surge mode disabled") + + def is_active(self): + """Return True if surge mode is currently active (and not expired). + + Side effect: auto-disables if the expiry timestamp has passed. + """ + state = self._load() + if not state: + return False + expires_at = datetime.fromisoformat(state["expires_at"]) + if datetime.now(timezone.utc) >= expires_at: + logger.info("Surge mode expired — auto-disabling") + self.disable() + return False + return True + + def check_expiry(self): + """Auto-disable surge if its window has elapsed. + + Returns: + bool: whether surge mode is still active after the check. + """ + return self.is_active() + + def get_interval(self, normal_interval, surge_interval): + """Return the appropriate polling interval in seconds. + + Args: + normal_interval: Seconds to sleep in ambient mode. + surge_interval: Seconds to sleep while surge is active. + + Returns: + int: surge_interval if surge is active, else normal_interval. + """ + if self.is_active(): + return surge_interval + return normal_interval diff --git a/brand-monitor/test_monitor.py b/brand-monitor/test_monitor.py new file mode 100644 index 00000000..ec8bb8ad --- /dev/null +++ b/brand-monitor/test_monitor.py @@ -0,0 +1,741 @@ +"""Full test suite for brand-monitor modules. + +Run: + pytest test_monitor.py -v --cov=. --cov-report=term-missing --cov-fail-under=100 + +All HTTP calls are mocked — no live API calls, no credentials needed. +""" + +import json +import os +from datetime import datetime, timedelta, timezone +from unittest.mock import MagicMock, call, patch + +import pytest +import requests + +# --------------------------------------------------------------------------- +# Shared fixtures / constants +# --------------------------------------------------------------------------- + +BASE_ENV = { + "X_BEARER_TOKEN": "test-bearer-token", + "X_API_KEY": "test-api-key", + "X_API_SECRET": "test-api-secret", + "SLACK_WEBHOOK_URL": "https://hooks.slack.com/services/TEST", +} + +SAMPLE_TWEET = { + "id": "1111111111", + "text": "Really excited about Molecule AI's agent platform — great SDK!", + "author_id": "9876543210", + "created_at": "2024-01-01T12:00:00Z", + "public_metrics": { + "like_count": 3, + "retweet_count": 1, + "reply_count": 2, + }, +} + +SAMPLE_TWEET_HIGH_ENGAGEMENT = { + "id": "2222222222", + "text": "Molecule AI multi-agent workflow is incredible", + "author_id": "1111111111", + "created_at": "2024-01-01T13:00:00Z", + "public_metrics": { + "like_count": 50, + "retweet_count": 20, + "reply_count": 15, + }, +} + +SAMPLE_TWEET_COMPETITOR = { + "id": "3333333333", + "text": "Comparing Molecule AI with langchain for our orchestration workflow", + "author_id": "2222222222", + "created_at": "2024-01-01T14:00:00Z", + "public_metrics": { + "like_count": 0, + "retweet_count": 0, + "reply_count": 0, + }, +} + + +# =========================================================================== +# x_client tests +# =========================================================================== + + +class TestXClient: + + def test_init_missing_token_raises(self): + from x_client import XClient + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(EnvironmentError, match="X_BEARER_TOKEN"): + XClient() + + def test_init_success(self): + from x_client import XClient + + with patch.dict(os.environ, {"X_BEARER_TOKEN": "my-token"}): + client = XClient() + assert client.bearer_token == "my-token" + + def _make_client(self): + from x_client import XClient + + with patch.dict(os.environ, {"X_BEARER_TOKEN": "tok"}): + return XClient() + + def test_search_recent_returns_tweets(self): + from x_client import SEARCH_QUERY, SEARCH_URL + + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"data": [SAMPLE_TWEET]} + + with patch("x_client.requests.get", return_value=mock_resp) as mock_get: + result = client.search_recent() + + assert result == [SAMPLE_TWEET] + # Verify URL, auth header and query string + args, kwargs = mock_get.call_args + assert args[0] == SEARCH_URL + assert kwargs["headers"]["Authorization"] == "Bearer tok" + assert kwargs["params"]["query"] == SEARCH_QUERY + + def test_search_recent_no_data_key_returns_empty_list(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"meta": {"result_count": 0}} + + with patch("x_client.requests.get", return_value=mock_resp): + result = client.search_recent() + + assert result == [] + + def test_search_recent_with_since_id_adds_param(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"data": [SAMPLE_TWEET]} + + with patch("x_client.requests.get", return_value=mock_resp) as mock_get: + client.search_recent(since_id="9999") + + params = mock_get.call_args.kwargs["params"] + assert params["since_id"] == "9999" + + def test_search_recent_with_start_time_adds_param(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"data": []} + + with patch("x_client.requests.get", return_value=mock_resp) as mock_get: + client.search_recent(start_time="2024-01-01T00:00:00Z") + + params = mock_get.call_args.kwargs["params"] + assert params["start_time"] == "2024-01-01T00:00:00Z" + + def test_search_recent_no_since_id_no_start_time_omits_params(self): + """Neither since_id nor start_time in params when not provided.""" + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"data": []} + + with patch("x_client.requests.get", return_value=mock_resp) as mock_get: + client.search_recent() + + params = mock_get.call_args.kwargs["params"] + assert "since_id" not in params + assert "start_time" not in params + + def test_search_recent_http_error_propagates(self): + client = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("403 Forbidden") + + with patch("x_client.requests.get", return_value=mock_resp): + with pytest.raises(requests.HTTPError): + client.search_recent() + + +# =========================================================================== +# slack_client tests +# =========================================================================== + + +class TestSlackClient: + + def _make_client(self): + from slack_client import SlackClient + + with patch.dict(os.environ, {"SLACK_WEBHOOK_URL": "https://hooks.slack.com/test"}): + return SlackClient() + + def test_init_missing_webhook_raises(self): + from slack_client import SlackClient + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(EnvironmentError, match="SLACK_WEBHOOK_URL"): + SlackClient() + + def test_init_success(self): + c = self._make_client() + assert c.webhook_url == "https://hooks.slack.com/test" + + def test_engagement_score_sums_correctly(self): + c = self._make_client() + tweet = {"public_metrics": {"like_count": 5, "retweet_count": 3, "reply_count": 2}} + assert c._engagement_score(tweet) == 10 + + def test_engagement_score_missing_metrics_returns_zero(self): + c = self._make_client() + assert c._engagement_score({}) == 0 + + def test_should_at_here_high_engagement_returns_true(self): + c = self._make_client() + assert c._should_at_here(SAMPLE_TWEET_HIGH_ENGAGEMENT) is True + + def test_should_at_here_competitor_name_returns_true(self): + c = self._make_client() + # SAMPLE_TWEET_COMPETITOR contains "langchain" — engagement is 0 + assert c._should_at_here(SAMPLE_TWEET_COMPETITOR) is True + + def test_should_at_here_normal_tweet_returns_false(self): + c = self._make_client() + # SAMPLE_TWEET: engagement=6 (<=10), no competitor + assert c._should_at_here(SAMPLE_TWEET) is False + + def test_post_mentions_empty_list_is_noop(self): + c = self._make_client() + with patch("slack_client.requests.post") as mock_post: + c.post_mentions([]) + mock_post.assert_not_called() + + def test_post_mentions_single_tweet_no_at_here(self): + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + + with patch("slack_client.requests.post", return_value=mock_resp) as mock_post: + c.post_mentions([SAMPLE_TWEET]) + + mock_post.assert_called_once() + payload = mock_post.call_args.kwargs["json"] + section_texts = [ + b["text"]["text"] + for b in payload["blocks"] + if b.get("type") == "section" + ] + # No @here for normal engagement tweet + assert not any("" in t for t in section_texts) + # Header mentions "1 new … mention" + assert any("1 new" in t for t in section_texts) + + def test_post_mentions_multiple_tweets_with_at_here(self): + """High-engagement tweet triggers @here; both tweets appear in payload.""" + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + + with patch("slack_client.requests.post", return_value=mock_resp) as mock_post: + c.post_mentions([SAMPLE_TWEET_HIGH_ENGAGEMENT, SAMPLE_TWEET]) + + payload = mock_post.call_args.kwargs["json"] + section_texts = [ + b["text"]["text"] + for b in payload["blocks"] + if b.get("type") == "section" + ] + assert any("" in t for t in section_texts) + assert any("2 new" in t for t in section_texts) + + def test_post_mentions_html_escaping_in_tweet_text(self): + """< > & in tweet text are escaped to prevent Slack mrkdwn injection.""" + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + tweet = {**SAMPLE_TWEET, "text": "X < Y & Z > W"} + + with patch("slack_client.requests.post", return_value=mock_resp) as mock_post: + c.post_mentions([tweet]) + + raw = str(mock_post.call_args.kwargs["json"]) + assert "<" in raw + assert ">" in raw + assert "&" in raw + + def test_post_mentions_http_error_propagates(self): + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("500") + + with patch("slack_client.requests.post", return_value=mock_resp): + with pytest.raises(requests.HTTPError): + c.post_mentions([SAMPLE_TWEET]) + + def test_post_digest_count_only_no_top_tweets(self): + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + + with patch("slack_client.requests.post", return_value=mock_resp) as mock_post: + c.post_digest({"count": 42}) + + text = mock_post.call_args.kwargs["json"]["text"] + assert "42" in text + assert "Top engagements" not in text + + def test_post_digest_with_top_tweets_included(self): + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + + with patch("slack_client.requests.post", return_value=mock_resp) as mock_post: + c.post_digest({"count": 10, "top_tweets": [SAMPLE_TWEET_HIGH_ENGAGEMENT, SAMPLE_TWEET]}) + + text = mock_post.call_args.kwargs["json"]["text"] + assert "Top engagements" in text + + def test_post_digest_http_error_propagates(self): + c = self._make_client() + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = requests.HTTPError("500") + + with patch("slack_client.requests.post", return_value=mock_resp): + with pytest.raises(requests.HTTPError): + c.post_digest({"count": 1}) + + +# =========================================================================== +# surge tests +# =========================================================================== + + +class TestSurgeState: + + def _make_surge(self, tmp_path): + from surge import SurgeState + + return SurgeState(state_file=str(tmp_path / ".surge_state.json")) + + def test_init_default_state_file(self): + from surge import DEFAULT_SURGE_FILE, SurgeState + + s = SurgeState() + assert s.state_file == DEFAULT_SURGE_FILE + + def test_init_custom_state_file(self, tmp_path): + s = self._make_surge(tmp_path) + assert ".surge_state.json" in s.state_file + + def test_enable_writes_state_file_with_correct_fields(self, tmp_path): + s = self._make_surge(tmp_path) + s.enable(duration_hours=3) + state = json.loads(open(s.state_file).read()) + assert state["active"] is True + assert state["duration_hours"] == 3 + assert "expires_at" in state + assert "enabled_at" in state + + def test_enable_default_duration(self, tmp_path): + from surge import DEFAULT_SURGE_DURATION_HOURS + + s = self._make_surge(tmp_path) + s.enable() + state = json.loads(open(s.state_file).read()) + assert state["duration_hours"] == DEFAULT_SURGE_DURATION_HOURS + + def test_disable_removes_file(self, tmp_path): + s = self._make_surge(tmp_path) + s.enable() + assert os.path.exists(s.state_file) + s.disable() + assert not os.path.exists(s.state_file) + + def test_disable_no_file_does_not_raise(self, tmp_path): + s = self._make_surge(tmp_path) + # File doesn't exist — should be silent + s.disable() + + def test_is_active_no_file_returns_false(self, tmp_path): + s = self._make_surge(tmp_path) + assert s.is_active() is False + + def test_is_active_not_expired_returns_true(self, tmp_path): + s = self._make_surge(tmp_path) + s.enable(duration_hours=6) + assert s.is_active() is True + + def test_is_active_expired_auto_disables_returns_false(self, tmp_path): + s = self._make_surge(tmp_path) + # Write an already-expired state + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + json.dump({"active": True, "expires_at": past, "duration_hours": 1}, open(s.state_file, "w")) + assert s.is_active() is False + assert not os.path.exists(s.state_file) + + def test_check_expiry_returns_true_when_active(self, tmp_path): + s = self._make_surge(tmp_path) + s.enable(duration_hours=6) + assert s.check_expiry() is True + + def test_check_expiry_returns_false_when_expired(self, tmp_path): + s = self._make_surge(tmp_path) + past = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat() + json.dump({"active": True, "expires_at": past, "duration_hours": 1}, open(s.state_file, "w")) + assert s.check_expiry() is False + + def test_get_interval_surge_active_returns_surge_interval(self, tmp_path): + s = self._make_surge(tmp_path) + s.enable(duration_hours=6) + assert s.get_interval(1800, 900) == 900 + + def test_get_interval_surge_inactive_returns_normal_interval(self, tmp_path): + s = self._make_surge(tmp_path) + assert s.get_interval(1800, 900) == 1800 + + +# =========================================================================== +# monitor — validate_env tests +# =========================================================================== + + +class TestValidateEnv: + + def test_all_vars_present_passes(self): + from monitor import validate_env + + with patch.dict(os.environ, BASE_ENV, clear=False): + validate_env() # must not raise + + def test_single_missing_var_raises_with_name(self): + from monitor import validate_env + + env = {k: v for k, v in BASE_ENV.items() if k != "X_BEARER_TOKEN"} + with patch.dict(os.environ, env, clear=True): + with pytest.raises(EnvironmentError, match="X_BEARER_TOKEN"): + validate_env() + + def test_multiple_missing_vars_raises_with_all_names(self): + from monitor import validate_env + + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(EnvironmentError) as exc_info: + validate_env() + msg = str(exc_info.value) + assert "X_BEARER_TOKEN" in msg + assert "SLACK_WEBHOOK_URL" in msg + + +# =========================================================================== +# monitor — enable_surge_mode tests +# =========================================================================== + + +class TestEnableSurgeMode: + + def test_default_duration_uses_env_default(self, tmp_path): + from monitor import SURGE_DURATION_HOURS, enable_surge_mode + + sf = str(tmp_path / ".surge.json") + enable_surge_mode(state_file=sf) + state = json.loads(open(sf).read()) + assert state["duration_hours"] == SURGE_DURATION_HOURS + + def test_custom_duration_overrides_default(self, tmp_path): + from monitor import enable_surge_mode + + sf = str(tmp_path / ".surge.json") + enable_surge_mode(duration_hours=12, state_file=sf) + state = json.loads(open(sf).read()) + assert state["duration_hours"] == 12 + + def test_no_state_file_override_uses_default_path(self): + """When state_file=None, SurgeState() is constructed with no kwargs.""" + from monitor import enable_surge_mode + + with patch("monitor.SurgeState") as MockSurge: + mock_instance = MagicMock() + MockSurge.return_value = mock_instance + enable_surge_mode(duration_hours=3) + + MockSurge.assert_called_once_with() + mock_instance.enable.assert_called_once_with(3) + + +# =========================================================================== +# monitor — Monitor class tests +# =========================================================================== + + +class TestMonitor: + """Tests for the Monitor class.""" + + # ------------------------------------------------------------------ + # Constructor helpers + # ------------------------------------------------------------------ + + def _make_monitor(self, tmp_path, state_data=None): + """Build a Monitor with temp files and mocked HTTP clients.""" + from monitor import Monitor + + state_file = str(tmp_path / "monitor_state.json") + surge_file = str(tmp_path / "surge_state.json") + + if state_data is not None: + json.dump(state_data, open(state_file, "w")) + + with patch.dict(os.environ, BASE_ENV, clear=False): + with patch("monitor.XClient"), patch("monitor.SlackClient"): + m = Monitor(state_file=state_file, surge_state_file=surge_file) + return m + + # ------------------------------------------------------------------ + # __init__ + # ------------------------------------------------------------------ + + def test_init_success_with_empty_state(self, tmp_path): + m = self._make_monitor(tmp_path) + assert m.state == {} + + def test_init_loads_existing_state_file(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"since_id": "abc"}) + assert m.state["since_id"] == "abc" + + def test_init_missing_env_raises(self, tmp_path): + from monitor import Monitor + + sf = str(tmp_path / "st.json") + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(EnvironmentError): + Monitor(state_file=sf) + + def test_init_surge_state_file_none_uses_default(self, tmp_path): + """surge_state_file=None → SurgeState constructed with no kwargs.""" + from monitor import Monitor + + sf = str(tmp_path / "st.json") + with patch.dict(os.environ, BASE_ENV, clear=False): + with patch("monitor.XClient"), patch("monitor.SlackClient"): + with patch("monitor.SurgeState") as MockSurge: + Monitor(state_file=sf) # surge_state_file defaults to None + + MockSurge.assert_called_once_with() + + def test_init_surge_state_file_provided_passes_kwarg(self, tmp_path): + """surge_state_file provided → SurgeState(state_file=...) is called.""" + from monitor import Monitor + + sf = str(tmp_path / "st.json") + surge_sf = str(tmp_path / "surge.json") + with patch.dict(os.environ, BASE_ENV, clear=False): + with patch("monitor.XClient"), patch("monitor.SlackClient"): + with patch("monitor.SurgeState") as MockSurge: + Monitor(state_file=sf, surge_state_file=surge_sf) + + MockSurge.assert_called_once_with(state_file=surge_sf) + + # ------------------------------------------------------------------ + # _load_state / _save_state + # ------------------------------------------------------------------ + + def test_load_state_no_file_returns_empty_dict(self, tmp_path): + m = self._make_monitor(tmp_path) + assert m._load_state() == {} + + def test_load_state_existing_file_returns_contents(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"since_id": "XYZ"}) + assert m._load_state()["since_id"] == "XYZ" + + def test_save_state_persists_to_disk(self, tmp_path): + m = self._make_monitor(tmp_path) + m.state["since_id"] = "saved" + m._save_state() + on_disk = json.loads(open(m.state_file).read()) + assert on_disk["since_id"] == "saved" + + # ------------------------------------------------------------------ + # run_poll + # ------------------------------------------------------------------ + + def test_run_poll_first_run_uses_start_time_backfill(self, tmp_path): + """No since_id → search_recent called with start_time set, since_id=None.""" + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [SAMPLE_TWEET] + + tweets = m.run_poll() + + kw = m.x_client.search_recent.call_args.kwargs + assert kw["since_id"] is None + assert kw["start_time"] is not None # 24h backfill + assert tweets == [SAMPLE_TWEET] + assert m.state["since_id"] == SAMPLE_TWEET["id"] + + def test_run_poll_subsequent_run_passes_since_id(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"since_id": "prev_tweet_id"}) + m.x_client.search_recent.return_value = [SAMPLE_TWEET] + + m.run_poll() + + kw = m.x_client.search_recent.call_args.kwargs + assert kw["since_id"] == "prev_tweet_id" + + def test_run_poll_no_tweets_does_not_post_to_slack(self, tmp_path): + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [] + + tweets = m.run_poll() + + m.slack_client.post_mentions.assert_not_called() + assert "since_id" not in m.state + assert tweets == [] + + def test_run_poll_no_tweets_preserves_existing_since_id(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"since_id": "old_id"}) + m.x_client.search_recent.return_value = [] + + m.run_poll() + + assert m.state["since_id"] == "old_id" + + def test_run_poll_new_tweets_posts_to_slack_and_updates_since_id(self, tmp_path): + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [SAMPLE_TWEET] + + m.run_poll() + + m.slack_client.post_mentions.assert_called_once_with([SAMPLE_TWEET]) + assert m.state["since_id"] == SAMPLE_TWEET["id"] + + # ------------------------------------------------------------------ + # _should_send_digest + # ------------------------------------------------------------------ + + def test_should_send_digest_wrong_hour_returns_false(self, tmp_path): + m = self._make_monitor(tmp_path) + fake_now = datetime(2024, 1, 1, 15, 0, 0, tzinfo=timezone.utc) # 15:00 UTC + with patch("monitor.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + assert m._should_send_digest() is False + + def test_should_send_digest_correct_hour_not_yet_sent_returns_true(self, tmp_path): + m = self._make_monitor(tmp_path) + fake_now = datetime(2024, 1, 1, 20, 0, 0, tzinfo=timezone.utc) # 20:00 UTC + with patch("monitor.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + assert m._should_send_digest() is True + + def test_should_send_digest_already_sent_today_returns_false(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"last_digest_date": "2024-01-01"}) + fake_now = datetime(2024, 1, 1, 20, 0, 0, tzinfo=timezone.utc) + with patch("monitor.datetime") as mock_dt: + mock_dt.now.return_value = fake_now + assert m._should_send_digest() is False + + # ------------------------------------------------------------------ + # run_daily_digest + # ------------------------------------------------------------------ + + def test_run_daily_digest_posts_count_and_resets(self, tmp_path): + m = self._make_monitor(tmp_path, state_data={"daily_count": 7}) + + m.run_daily_digest() + + m.slack_client.post_digest.assert_called_once_with({"count": 7}) + assert m.state["daily_count"] == 0 + assert "last_digest_date" in m.state + + # ------------------------------------------------------------------ + # _run_once + # ------------------------------------------------------------------ + + def test_run_once_no_digest_returns_normal_interval(self, tmp_path): + from monitor import POLL_INTERVAL_SECONDS + + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [SAMPLE_TWEET] + + with patch.object(m, "_should_send_digest", return_value=False): + interval = m._run_once() + + assert m.state["daily_count"] == 1 + assert interval == POLL_INTERVAL_SECONDS + + def test_run_once_triggers_digest_when_due(self, tmp_path): + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [] + + with patch.object(m, "_should_send_digest", return_value=True): + with patch.object(m, "run_daily_digest") as mock_digest: + m._run_once() + + mock_digest.assert_called_once() + + def test_run_once_returns_surge_interval_when_surge_active(self, tmp_path): + from monitor import SURGE_INTERVAL_SECONDS + + m = self._make_monitor(tmp_path) + m.x_client.search_recent.return_value = [] + m.surge.enable(duration_hours=6) + + with patch.object(m, "_should_send_digest", return_value=False): + interval = m._run_once() + + assert interval == SURGE_INTERVAL_SECONDS + + # ------------------------------------------------------------------ + # run (infinite loop) + # ------------------------------------------------------------------ + + def test_run_normal_path_sleeps_with_returned_interval(self, tmp_path): + from monitor import Monitor, POLL_INTERVAL_SECONDS + + sf = str(tmp_path / "st.json") + surge_sf = str(tmp_path / "surge.json") + with patch.dict(os.environ, BASE_ENV, clear=False): + with patch("monitor.XClient"), patch("monitor.SlackClient"): + m = Monitor(state_file=sf, surge_state_file=surge_sf) + + sleep_calls = [] + + def fake_sleep(n): + sleep_calls.append(n) + raise SystemExit("terminate test loop") + + with patch.object(m, "_run_once", return_value=POLL_INTERVAL_SECONDS): + with patch("monitor.time.sleep", side_effect=fake_sleep): + with pytest.raises(SystemExit): + m.run() + + assert sleep_calls == [POLL_INTERVAL_SECONDS] + + def test_run_exception_in_run_once_falls_back_to_poll_interval(self, tmp_path): + from monitor import Monitor, POLL_INTERVAL_SECONDS + + sf = str(tmp_path / "st.json") + surge_sf = str(tmp_path / "surge.json") + with patch.dict(os.environ, BASE_ENV, clear=False): + with patch("monitor.XClient"), patch("monitor.SlackClient"): + m = Monitor(state_file=sf, surge_state_file=surge_sf) + + sleep_calls = [] + + def fake_sleep(n): + sleep_calls.append(n) + raise SystemExit("terminate test loop") + + with patch.object(m, "_run_once", side_effect=RuntimeError("api exploded")): + with patch("monitor.time.sleep", side_effect=fake_sleep): + with pytest.raises(SystemExit): + m.run() + + # On exception, sleep is called with the ambient interval + assert sleep_calls == [POLL_INTERVAL_SECONDS] diff --git a/brand-monitor/x_client.py b/brand-monitor/x_client.py new file mode 100644 index 00000000..af05523e --- /dev/null +++ b/brand-monitor/x_client.py @@ -0,0 +1,65 @@ +"""X API v2 thin client for brand mention search.""" + +import os +import logging +import requests + +logger = logging.getLogger(__name__) + +SEARCH_URL = "https://api.twitter.com/2/tweets/search/recent" + +# Verbatim from issue #549 — drug-discovery SEO noise suppressed at query level +SEARCH_QUERY = ( + '("Molecule AI" OR "@moleculeai") ' + '(agent OR workflow OR orchestrat OR "multi-agent" OR developer OR SDK OR API OR "agent platform") ' + '-moleculeai.com -molecule.ai -"drug discovery" -pharmaceutical -CRISPR -oncology ' + '-is:retweet lang:en' +) + +TWEET_FIELDS = "author_id,created_at,public_metrics,entities" + + +class XClient: + """Thin wrapper around X API v2 recent-search endpoint. + + Auth: Bearer token from X_BEARER_TOKEN env var. + """ + + def __init__(self): + self.bearer_token = os.environ.get("X_BEARER_TOKEN") + if not self.bearer_token: + raise EnvironmentError("Missing required environment variable: X_BEARER_TOKEN") + + def search_recent(self, since_id=None, start_time=None, max_results=100): + """Search recent tweets matching SEARCH_QUERY. + + Args: + since_id: Only return tweets newer than this tweet ID. + start_time: ISO 8601 datetime string; only return tweets after this time. + max_results: Max tweets per request (10–100). + + Returns: + List of tweet dicts (newest first), empty list if none found. + + Raises: + requests.HTTPError: On non-2xx API response. + """ + headers = {"Authorization": f"Bearer {self.bearer_token}"} + params = { + "query": SEARCH_QUERY, + "tweet.fields": TWEET_FIELDS, + "max_results": max_results, + } + if since_id: + params["since_id"] = since_id + if start_time: + params["start_time"] = start_time + + logger.debug("Searching X API: since_id=%s start_time=%s", since_id, start_time) + response = requests.get(SEARCH_URL, headers=headers, params=params, timeout=30) + response.raise_for_status() + + data = response.json() + tweets = data.get("data", []) + logger.info("X API returned %d tweet(s)", len(tweets)) + return tweets From 9d6f20f0dd56d334b83539f987ed26ad8b422dca Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:21:02 +0000 Subject: [PATCH 05/32] =?UTF-8?q?fix(devrel):=20correct=20capability=20tab?= =?UTF-8?q?le=20=E2=80=94=20tool=5Fuse/vision/streaming=20are=20Phase=202d?= =?UTF-8?q?=20(not=20yet=20shipped)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hermes-multi-provider-dispatch.md | 45 ++++++++++++------- 1 file changed, 28 insertions(+), 17 deletions(-) diff --git a/docs/tutorials/hermes-multi-provider-dispatch.md b/docs/tutorials/hermes-multi-provider-dispatch.md index efd6343a..bd30eb9b 100644 --- a/docs/tutorials/hermes-multi-provider-dispatch.md +++ b/docs/tutorials/hermes-multi-provider-dispatch.md @@ -1,8 +1,10 @@ # Hermes Multi-Provider Dispatch: Native Anthropic, Gemini, and Multi-Turn History -Hermes is Molecule AI's inference router. Out of the box it proxies every model through an OpenAI-compatible shim — which works fine for plain text but silently strips Anthropic's `tool_use` blocks, vision content, and Gemini's `parts`-based message structure. +Hermes is Molecule AI's inference router. Out of the box it proxies every model through an OpenAI-compatible shim. That works for plain text, but the shim does format translation on every round-trip — and it gets the Gemini message format wrong (Gemini expects `role: "model"` and a `parts: [{text}]` wrapper; the shim passes `role: "assistant"` and a flat string). It also flattens multi-turn conversations into a single user blob, losing role attribution across turns. -Phases 2a–2c wired three native dispatch paths keyed on `auth_scheme`. This tutorial shows you how to unlock them, and why you should. +Phases 2a–2c wire three native dispatch paths keyed on `auth_scheme`. This tutorial shows you how to unlock them. + +> **Phase 2d scope note:** Tool calling, vision content blocks, system instructions, and streaming on the native paths are scoped for Phase 2d and are **not yet shipped**. This tutorial covers what is merged today: correct native dispatch + multi-turn history continuity. ## What you'll need @@ -59,7 +61,6 @@ curl -s -X PUT $MOLECULE_API/settings/secrets \ -d '{"key":"GEMINI_API_KEY","value":"YOUR-GEMINI-KEY"}' | jq . # 5. Create a Hermes workspace — Gemini native dispatch -# We override the global ANTHROPIC_API_KEY at workspace scope so Gemini wins GEMINI_WS=$(curl -s -X POST $MOLECULE_API/workspaces \ -H "Content-Type: application/json" \ -d '{ @@ -112,11 +113,11 @@ curl -s -X POST $MOLECULE_API/workspaces/$ANTHROPIC_WS/a2a \ ## Expected output -**Step 7 (Anthropic workspace):** The agent confirms it is calling the Anthropic Messages API. Internally Hermes executed `_do_anthropic_native`, not the OpenAI shim. Tool-use blocks, vision content, and extended thinking all survive in round-trips. +**Step 7 (Anthropic workspace):** The agent confirms it is calling the Anthropic Messages API natively. Hermes executed `_do_anthropic_native` — no OpenAI-compat translation layer. -**Step 8 (Gemini workspace):** The agent confirms Google `generateContent`. Hermes called `_do_gemini_native`, which uses `role: "model"` (not `"assistant"`) and the `parts: [{text: ...}]` wrapper that the native SDK requires. The OpenAI-compat translation that previously stripped these is bypassed. +**Step 8 (Gemini workspace):** The agent confirms Google `generateContent`. Hermes called `_do_gemini_native`, which passes `role: "model"` (not `"assistant"`) and the `parts: [{text: ...}]` wrapper the native SDK requires. The compat-shim translation that produced incorrect message format is bypassed. -**Step 10 (multi-turn, Phase 2c):** Returns `"Alice"`. Before Phase 2c, history was flattened into a single user blob — the model could still figure out context but lost role attribution and instruction-following across turns. Phase 2c passes turns as turns: OpenAI uses `{role, content}`, Anthropic uses the same wire shape for text, Gemini uses `{role: "model", parts: [{text}]}`. +**Step 10 (multi-turn, Phase 2c):** Returns `"Alice"`. Before Phase 2c, history was flattened into a single user blob — the model could recover the gist but lost clean role attribution. Phase 2c passes turns as turns: OpenAI uses `{role, content}`, Anthropic uses the same wire shape for text-only, Gemini uses `{role: "model", parts: [{text}]}`. ## How dispatch works under the hood @@ -131,11 +132,11 @@ else: # "openai" + unknown (forward-compat fallback) return await self._do_openai_compat(user_message, history) ``` -Fail-loud semantics: if the `anthropic` package isn't installed, `_do_anthropic_native` raises a clear `RuntimeError` before any inference attempt. Same for `google-genai`. Silent fallback to the compat shim would mask fidelity loss — Molecule AI chooses loud failure. +Fail-loud semantics: if the `anthropic` package isn't installed, `_do_anthropic_native` raises a clear `RuntimeError` before any inference attempt. Same for `google-genai`. Silent fallback to the compat shim would mask format errors — Molecule AI chooses loud failure. ## Building a multi-provider team -The real win surfaces in a mixed-provider agent team. Your orchestrator can fan tasks to an Anthropic specialist (best at tool-calling) and a Gemini specialist (best at long-context) simultaneously, then synthesize: +The real win surfaces in a mixed-provider agent team. Your orchestrator can fan tasks to an Anthropic worker and a Gemini worker simultaneously, each receiving properly formatted messages through their native API paths: ```bash # Fan out from the orchestrator — both fire in parallel @@ -144,22 +145,32 @@ curl -s -X POST $MOLECULE_API/workspaces/$ORCH_ID/a2a \ -d "{ \"jsonrpc\":\"2.0\",\"id\":\"fan-1\",\"method\":\"message/send\", \"params\":{\"message\":{\"role\":\"user\",\"parts\":[{\"kind\":\"text\", - \"text\":\"delegate_task_async $ANTHROPIC_WS 'Draft tool-calling schema for a calendar booking agent' AND delegate_task_async $GEMINI_WS 'Summarise the last 30 days of support tickets'\"}]}} + \"text\":\"delegate_task_async $ANTHROPIC_WS 'Draft release notes for v2.1' AND delegate_task_async $GEMINI_WS 'Summarise the last 30 days of support tickets'\"}]}} }" | jq . ``` -Both workers use their native inference paths. No LiteLLM proxy layer. No format translation taxes. The orchestrator gets results back through the same A2A protocol regardless of which underlying model powered each task. +Both workers use their native inference paths. No LiteLLM proxy layer. No format translation on every request. The orchestrator gets results back through the same A2A protocol regardless of which underlying model powered each task. -## Comparison: Hermes native vs the compat shim +## Capability comparison: Hermes native vs the compat shim + +What is shipping today (Phases 2a + 2b + 2c — all merged to main): | Capability | OpenAI-compat shim | Anthropic native | Gemini native | |---|---|---|---| -| Plain text | ✅ | ✅ | ✅ | -| `tool_use` / `tool_result` blocks | ❌ stripped | ✅ | ✅ | -| Vision content | ❌ stripped | ✅ | ✅ | -| Multi-turn history | ⚠️ flattened blob | ✅ role-attributed | ✅ `model` role + parts | -| Extended thinking | ❌ | ✅ (Phase 2d) | — | -| Streaming | ❌ (Phase 2d) | ❌ (Phase 2d) | ❌ (Phase 2d) | +| Plain text (single-turn) | ✅ | ✅ | ✅ | +| Multi-turn history | ⚠️ flattened into one user blob | ✅ role-attributed turns | ✅ `role: "model"` + `parts` wrapper | +| Correct Gemini message format | ❌ wrong role + missing parts wrapper | — | ✅ | +| No compat-shim translation overhead | ❌ every request translated | ✅ | ✅ | + +What is on the roadmap for Phase 2d (not yet shipped): + +| Capability | Anthropic native | Gemini native | +|---|---|---| +| `tool_use` / `tool_result` blocks | 📋 Phase 2d | 📋 Phase 2d | +| Vision content blocks | 📋 Phase 2d | 📋 Phase 2d | +| System instructions (`system=`) | 📋 Phase 2d | 📋 Phase 2d (`system_instruction=`) | +| Extended thinking | 📋 Phase 2d | — | +| Streaming | 📋 Phase 2d | 📋 Phase 2d | **Why Molecule AI vs Letta / AG2 / n8n:** Those frameworks handle multi-LLM at the application layer — you write different agent classes per provider. Molecule AI handles it at the infrastructure layer. Your workspace configs change; your orchestration code doesn't. Swap a Gemini worker for an Anthropic worker by changing one secret. No code redeploy. From 0aae3521ce82b33d7bf9991632d494b7748232c8 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:30:49 +0000 Subject: [PATCH 06/32] docs(devrel): Google ADK runtime tutorial (feat #550) --- docs/tutorials/google-adk-runtime.md | 74 ++++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/tutorials/google-adk-runtime.md diff --git a/docs/tutorials/google-adk-runtime.md b/docs/tutorials/google-adk-runtime.md new file mode 100644 index 00000000..05c8589d --- /dev/null +++ b/docs/tutorials/google-adk-runtime.md @@ -0,0 +1,74 @@ +# Running a Google ADK Workspace on Molecule AI + +Google's Agent Development Kit (ADK) is now a first-class runtime on Molecule AI. This tutorial walks you from zero to a running ADK agent workspace — one that persists per-conversation session state and sits alongside your Claude Code and Gemini CLI workers in the same A2A network. + +## What you'll need + +- A Molecule AI account with at least one provisioned tenant +- A `GOOGLE_API_KEY` from [aistudio.google.com](https://aistudio.google.com) (or Vertex AI credentials — see below) +- `curl` + `jq` + +## Setup + +```bash +# 1. Store your Google API key as a global secret +curl -s -X PUT http://localhost:8080/settings/secrets \ + -H "Content-Type: application/json" \ + -d '{"key":"GOOGLE_API_KEY","value":"YOUR-AI-STUDIO-KEY"}' | jq . + +# 2. Create a google-adk workspace +WS=$(curl -s -X POST http://localhost:8080/workspaces \ + -H "Content-Type: application/json" \ + -d '{ + "name": "adk-agent", + "role": "Google ADK inference worker", + "runtime": "google-adk", + "model": "google:gemini-2.0-flash" + }' | jq -r '.id') +echo "Workspace: $WS" + +# 3. Wait for ready (~30s) +until curl -s http://localhost:8080/workspaces/$WS | jq -r '.status' | grep -q ready; do + echo "Waiting..."; sleep 5 +done + +# 4. Send your first task +curl -s -X POST http://localhost:8080/workspaces/$WS/a2a \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"1","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text", + "text":"Summarise the ADK architecture in 3 bullet points."}]}}}' \ + | jq '.result.parts[0].text' + +# 5. Multi-turn — session state is preserved across calls +curl -s -X POST http://localhost:8080/workspaces/$WS/a2a \ + -H "Content-Type: application/json" \ + -d '{"jsonrpc":"2.0","id":"2","method":"message/send", + "params":{"message":{"role":"user","parts":[{"kind":"text", + "text":"Now give me a one-line TL;DR of what you just said."}]}}}' \ + | jq '.result.parts[0].text' + +# 6. Vertex AI alternative — set these instead of GOOGLE_API_KEY +# curl -X PUT .../secrets -d '{"key":"GOOGLE_GENAI_USE_VERTEXAI","value":"1"}' +# curl -X PUT .../secrets -d '{"key":"GOOGLE_CLOUD_PROJECT","value":"my-project"}' +# curl -X PUT .../secrets -d '{"key":"GOOGLE_CLOUD_LOCATION","value":"us-central1"}' +``` + +## Expected output + +After step 4, ADK streams the Gemini response through its event bus, filters for `is_final_response()` events, and returns the agent's reply as a standard A2A text part. Step 5 should reference the prior answer — the adapter ties each A2A `context_id` to an `InMemorySessionService` session, so conversation state is isolated per task context and survives across calls within the same session. + +## How it works + +The `google-adk` adapter wraps Google ADK's runner/session model behind the same `AgentExecutor` interface used by every other Molecule AI runtime. On each turn, `GoogleADKA2AExecutor` calls `runner.run_async()` with the incoming message wrapped in a `google.genai.types.Content` object, then drains the event stream until it collects a final-response event. The `google:` model prefix is stripped before being passed to ADK — so `google:gemini-2.0-flash` in your workspace config becomes `gemini-2.0-flash` in the ADK `LlmAgent`. Error class names are sanitized before leaving the executor; raw Google SDK stack traces never reach the A2A caller. + +## Mixed-runtime teams + +ADK workspaces participate in the same A2A network as Claude Code, Gemini CLI, Hermes, and LangGraph workers. An orchestrator can delegate long-context summarisation to a `google-adk` worker (Gemini 1.5 Pro's 1M token window) while routing tool-use tasks to a `claude-code` worker — with no provider-specific code in the orchestrator itself. Add an ADK peer with `POST /workspaces`, set `GOOGLE_API_KEY`, and it's available for `delegate_task` immediately. + +## Related + +- PR #550: [feat(adapters): add google-adk runtime adapter](https://github.com/Molecule-AI/molecule-core/pull/550) +- [Google ADK (adk-python)](https://github.com/google/adk-python) +- [Gemini CLI runtime tutorial](./gemini-cli-runtime.md) +- [Platform API reference](../api-reference.md) From b37f71b6da92bf3d4dd5e0a2b2a0b35b039f3dd7 Mon Sep 17 00:00:00 2001 From: "molecule-ai[bot]" <276602405+molecule-ai[bot]@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:35:54 +0000 Subject: [PATCH 07/32] fix(canvas): hydration error UI (#554), radio arrow-key nav (#556), zoom-to-team context menu (#557) (#565) - #554 CRITICAL: Add hydrationError state to Zustand store; catch handler now calls setHydrationError instead of silent console.error; page renders a full-screen zinc-950 error banner with a Retry button that reloads the page - #556 MEDIUM: Add roving tabIndex + ArrowDown/Up/Left/Right keyboard handler to the tier radio group in CreateWorkspaceDialog (WCAG 2.1 compliant) - #557 MEDIUM: Add "Zoom to Team" menu item to ContextMenu (visible only when node has children); dispatches molecule:zoom-to-team for keyboard accessibility - Bonus: add missing 'use client' directive to RevealToggle.tsx Co-authored-by: Molecule AI Frontend Engineer Co-authored-by: Claude Sonnet 4.6 --- canvas/src/app/page.tsx | 25 ++++++- canvas/src/components/ContextMenu.tsx | 13 +++- .../src/components/CreateWorkspaceDialog.tsx | 38 ++++++++-- .../__tests__/ContextMenu.keyboard.test.tsx | 46 ++++++++++++ .../CreateWorkspaceDialog.a11y.test.tsx | 71 +++++++++++++++++++ canvas/src/components/ui/RevealToggle.tsx | 2 + canvas/src/store/__tests__/canvas.test.ts | 27 +++++++ canvas/src/store/canvas.ts | 5 ++ 8 files changed, 219 insertions(+), 8 deletions(-) diff --git a/canvas/src/app/page.tsx b/canvas/src/app/page.tsx index e785cb9a..b8976a35 100644 --- a/canvas/src/app/page.tsx +++ b/canvas/src/app/page.tsx @@ -10,6 +10,9 @@ import { api } from "@/lib/api"; import type { WorkspaceData } from "@/store/socket"; export default function Home() { + const hydrationError = useCanvasStore((s) => s.hydrationError); + const setHydrationError = useCanvasStore((s) => s.setHydrationError); + useEffect(() => { connectSocket(); @@ -23,8 +26,11 @@ export default function Home() { useCanvasStore.getState().setViewport(viewport); } }).catch((err) => { - // Initial hydration failed — socket reconnect will retry + // Initial hydration failed — show error banner to user console.error("Canvas: initial hydration failed", err); + useCanvasStore.getState().setHydrationError( + err instanceof Error && err.message ? err.message : "Failed to load canvas" + ); }); return () => { @@ -37,6 +43,23 @@ export default function Home() { + {hydrationError && ( +
+

{hydrationError}

+ +
+ )} ); } diff --git a/canvas/src/components/ContextMenu.tsx b/canvas/src/components/ContextMenu.tsx index 5e1d2f4f..c03fb8fa 100644 --- a/canvas/src/components/ContextMenu.tsx +++ b/canvas/src/components/ContextMenu.tsx @@ -235,6 +235,14 @@ export function ContextMenu() { closeContextMenu(); }, [contextMenu, nestNode, closeContextMenu]); + const handleZoomToTeam = useCallback(() => { + if (!contextMenu) return; + window.dispatchEvent( + new CustomEvent("molecule:zoom-to-team", { detail: { nodeId: contextMenu.nodeId } }) + ); + closeContextMenu(); + }, [contextMenu, closeContextMenu]); + if (!contextMenu) return null; const isOfflineOrFailed = contextMenu.nodeData.status === "offline" || contextMenu.nodeData.status === "failed"; @@ -253,7 +261,10 @@ export function ContextMenu() { ? [{ label: "Extract from Team", icon: "⤴", action: handleRemoveFromTeam }] : []), ...(hasChildren - ? [{ label: "Collapse Team", icon: "◁", action: handleCollapse }] + ? [ + { label: "Collapse Team", icon: "◁", action: handleCollapse }, + { label: "Zoom to Team", icon: "⊕", action: handleZoomToTeam }, + ] : [{ label: "Expand to Team", icon: "▷", action: handleExpand }]), { label: "", icon: "", action: () => {}, divider: true }, ...(isPaused diff --git a/canvas/src/components/CreateWorkspaceDialog.tsx b/canvas/src/components/CreateWorkspaceDialog.tsx index 4b0a8065..9c5f4dd0 100644 --- a/canvas/src/components/CreateWorkspaceDialog.tsx +++ b/canvas/src/components/CreateWorkspaceDialog.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, useCallback } from "react"; import * as Dialog from "@radix-ui/react-dialog"; import { api } from "@/lib/api"; @@ -50,6 +50,33 @@ export function CreateWorkspaceButton() { const [hermesProvider, setHermesProvider] = useState("anthropic"); const [hermesApiKey, setHermesApiKey] = useState(""); + // Refs for roving tabIndex on the tier radio group (WCAG 2.1 arrow-key nav) + const radioRefs = useRef>([]); + const TIERS = [ + { value: 1, label: "T1", desc: "Sandboxed" }, + { value: 2, label: "T2", desc: "Standard" }, + { value: 3, label: "T3", desc: "Full Access" }, + ]; + + const handleRadioKeyDown = useCallback( + (e: React.KeyboardEvent, currentIndex: number) => { + if (e.key === "ArrowDown" || e.key === "ArrowRight") { + e.preventDefault(); + const next = (currentIndex + 1) % TIERS.length; + setTier(TIERS[next].value); + radioRefs.current[next]?.focus(); + } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") { + e.preventDefault(); + const prev = (currentIndex - 1 + TIERS.length) % TIERS.length; + setTier(TIERS[prev].value); + radioRefs.current[prev]?.focus(); + } + }, + // TIERS is stable (module-level constant pattern), setTier is stable from useState + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + const isHermes = template.trim().toLowerCase() === "hermes"; // Reset form and load workspaces whenever dialog opens @@ -172,16 +199,15 @@ export function CreateWorkspaceButton() {
Tier
- {[ - { value: 1, label: "T1", desc: "Sandboxed" }, - { value: 2, label: "T2", desc: "Standard" }, - { value: 3, label: "T3", desc: "Full Access" }, - ].map((t) => ( + {TIERS.map((t, idx) => ( + {error && ( +
+ {error} +
+ )} + {/* Create form */} {showForm && (
diff --git a/canvas/src/components/tabs/MemoryTab.tsx b/canvas/src/components/tabs/MemoryTab.tsx index 4502f982..fa70faa5 100644 --- a/canvas/src/components/tabs/MemoryTab.tsx +++ b/canvas/src/components/tabs/MemoryTab.tsx @@ -219,7 +219,7 @@ export function MemoryTab({ workspaceId }: Props) { Refresh
) : (
+ {budgetExceeded && ( +
+ + Budget limit exceeded +
+ )} + + {data.budgetUsed != null && ( + + )} diff --git a/canvas/src/store/canvas-topology.ts b/canvas/src/store/canvas-topology.ts index 687b215e..d28434ad 100644 --- a/canvas/src/store/canvas-topology.ts +++ b/canvas/src/store/canvas-topology.ts @@ -142,6 +142,8 @@ export function buildNodesAndEdges( currentTask: ws.current_task || "", runtime: ws.runtime || "", needsRestart: false, + budgetLimit: ws.budget_limit ?? null, + budgetUsed: ws.budget_used ?? null, }, // Hide child nodes from canvas — they render inside the parent WorkspaceNode hidden: !!ws.parent_id, diff --git a/canvas/src/store/canvas.ts b/canvas/src/store/canvas.ts index 387c71e6..d10da178 100644 --- a/canvas/src/store/canvas.ts +++ b/canvas/src/store/canvas.ts @@ -29,6 +29,10 @@ export interface WorkspaceNodeData extends Record { currentTask: string; runtime: string; needsRestart: boolean; + /** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */ + budgetLimit: number | null; + /** Cumulative USD spend. Present when the platform tracks spend (issue #541). */ + budgetUsed?: number | null; } export type PanelTab = "details" | "skills" | "chat" | "terminal" | "config" | "schedule" | "channels" | "files" | "memory" | "traces" | "events" | "activity"; diff --git a/canvas/src/store/socket.ts b/canvas/src/store/socket.ts index 5689791e..f350c4d7 100644 --- a/canvas/src/store/socket.ts +++ b/canvas/src/store/socket.ts @@ -118,6 +118,10 @@ export interface WorkspaceData { x: number; y: number; collapsed: boolean; + /** USD spend ceiling set by the user; null = unlimited. Added by issue #541. */ + budget_limit: number | null; + /** Cumulative USD spend for this workspace. Present when the platform tracks spend. */ + budget_used?: number | null; } let socket: ReconnectingSocket | null = null; From 2152323cd1b7090deb5f3642a9f3edbc5d8ce2ba Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 01:25:26 +0000 Subject: [PATCH 30/32] feat(#541): budget settings UI with usage stats and 402 handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a dedicated BudgetSection component to the workspace details panel: - GET /workspaces/:id/budget on mount — populates live stats (used/limit/remaining) - Stats row + blue-500 progress bar (capped at 100%; hidden when unlimited) - PATCH /workspaces/:id/budget for saving; input blank → budget_limit: null - "Budget exceeded — messages blocked" amber/zinc-950 banner on any 402 response (GET or PATCH); banner clears on a successful subsequent save - 'use client'; dark zinc theme throughout (zinc-800/700 inputs, blue-500 accents) DetailsTab refactored: inline budget_limit fields removed; BudgetSection mounted as a self-contained section between Workspace and Skills. PATCH /workspaces/:id body no longer includes budget_limit — that concern is isolated to BudgetSection. Tests: 21 new cases in BudgetSection.test.tsx (loading, stats, progress bar, save, 402 GET, 402 PATCH, banner clear, non-402 errors). BudgetLimit.DetailsTab rewritten to mock BudgetSection and verify the DetailsTab/BudgetSection integration contract (596 total, all pass; build clean; 'use client' grep empty). API shape: GET/PATCH /workspaces/:id/budget → {budget_limit: int64|null, budget_used: int64, budget_remaining: int64|null} Co-Authored-By: Claude Sonnet 4.6 --- .../__tests__/BudgetLimit.DetailsTab.test.tsx | 272 ++++++------- .../__tests__/BudgetSection.test.tsx | 371 ++++++++++++++++++ canvas/src/components/tabs/BudgetSection.tsx | 251 ++++++++++++ canvas/src/components/tabs/DetailsTab.tsx | 55 +-- 4 files changed, 742 insertions(+), 207 deletions(-) create mode 100644 canvas/src/components/__tests__/BudgetSection.test.tsx create mode 100644 canvas/src/components/tabs/BudgetSection.tsx diff --git a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx index 67be41cd..a9515374 100644 --- a/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx +++ b/canvas/src/components/__tests__/BudgetLimit.DetailsTab.test.tsx @@ -1,8 +1,13 @@ // @vitest-environment jsdom /** - * Tests for the budget_limit field in DetailsTab (issue #541). - * Covers: display in read view, editing + PATCH, exceeded badge, - * null/unlimited states, and cancel-revert. + * DetailsTab integration tests for issue #541. + * + * Budget-specific logic (stats, progress bar, PATCH /budget, 402 handling) is + * fully covered by BudgetSection.test.tsx — this file focuses on: + * 1. BudgetSection being mounted inside DetailsTab + * 2. The workspace edit form (name / role / tier) no longer carrying + * budget_limit — that concern lives in BudgetSection now + * 3. PATCH /workspaces/:id body integrity (no accidental budget_limit leak) */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, screen, fireEvent, waitFor, cleanup } from "@testing-library/react"; @@ -30,6 +35,15 @@ vi.mock("@/store/canvas", () => ({ vi.mock("../StatusDot", () => ({ StatusDot: () => null })); +// Mock BudgetSection — it has its own test suite (BudgetSection.test.tsx). +// Without this mock its internal api.get would fire against the shared mock +// and cause type errors when the return is not a valid BudgetData object. +vi.mock("../tabs/BudgetSection", () => ({ + BudgetSection: ({ workspaceId }: { workspaceId: string }) => ( +
+ ), +})); + import { api } from "@/lib/api"; import { DetailsTab } from "../tabs/DetailsTab"; @@ -37,7 +51,7 @@ const mockPatch = vi.mocked(api.patch); const mockGet = vi.mocked(api.get); const mockUpdateNodeData = vi.fn(); -// ── Base workspace data ──────────────────────────────────────────────────────── +// ── Helpers ─────────────────────────────────────────────────────────────────── function makeData(overrides: Record = {}) { return { @@ -73,195 +87,135 @@ afterEach(() => { cleanup(); }); -// ── Read view ───────────────────────────────────────────────────────────────── +async function openEdit() { + const editBtn = screen.getAllByRole("button").find((b) => b.textContent === "Edit"); + fireEvent.click(editBtn!); + await waitFor(() => + expect(screen.getAllByRole("button").some((b) => b.textContent === "Save")).toBe(true) + ); +} -describe("DetailsTab — budget_limit read view", () => { - it("shows 'Unlimited' when budgetLimit is null", () => { - render(); - expect(screen.getByText("Unlimited")).toBeTruthy(); - }); +// ── BudgetSection mounting ──────────────────────────────────────────────────── - it("shows formatted dollar amount when budgetLimit is set", () => { - render(); - expect(screen.getByText("$100.00")).toBeTruthy(); - }); - - it("shows budget used row when budgetUsed is present", () => { - render( - - ); - expect(screen.getByText("$42.50")).toBeTruthy(); - }); - - it("does NOT show budget used row when budgetUsed is null", () => { - render( - - ); - // "Budget used" label should not appear - expect(screen.queryByText("Budget used")).toBeNull(); +describe("DetailsTab — BudgetSection integration", () => { + it("renders BudgetSection with the correct workspaceId", () => { + render(); + const stub = screen.getByTestId("budget-section-stub"); + expect(stub).toBeTruthy(); + expect(stub.getAttribute("data-ws")).toBe("ws-42"); }); }); -// ── Budget exceeded badge ───────────────────────────────────────────────────── +// ── Workspace edit form (no budget_limit) ────────────────────────────────────── -describe("DetailsTab — budget exceeded badge", () => { - it("shows exceeded badge when budgetUsed > budgetLimit", () => { - render( - - ); - expect(screen.getByTestId("budget-exceeded-badge")).toBeTruthy(); - expect(screen.getByText("Budget limit exceeded")).toBeTruthy(); - }); - - it("does NOT show exceeded badge when budgetUsed equals budgetLimit", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetUsed < budgetLimit", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetLimit is null (unlimited)", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("does NOT show exceeded badge when budgetUsed is null", () => { - render( - - ); - expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); - }); - - it("exceeded badge has role='status' for accessible announcement", () => { - render( - - ); - const badge = screen.getByTestId("budget-exceeded-badge"); - expect(badge.getAttribute("role")).toBe("status"); - }); -}); - -// ── Edit + PATCH ────────────────────────────────────────────────────────────── - -describe("DetailsTab — budget_limit editing", () => { - async function openEdit() { - const editBtn = screen.getAllByRole("button").find((b) => b.textContent === "Edit"); - fireEvent.click(editBtn!); - await waitFor(() => expect(screen.getByPlaceholderText("Leave blank for unlimited")).toBeTruthy()); - } - - it("shows budget_limit input with placeholder 'Leave blank for unlimited' when editing", async () => { - render(); +describe("DetailsTab — workspace edit form does not include budget_limit", () => { + it("does NOT show a 'Budget limit (USD)' input in the edit form", async () => { + render(); await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input).toBeTruthy(); - expect(input.value).toBe(""); + // Budget limit (USD) was the old inline field label — must be absent now + expect(screen.queryByPlaceholderText("Leave blank for unlimited")).toBeNull(); + expect(screen.queryByText("Budget limit (USD)")).toBeNull(); }); - it("pre-fills input with existing budgetLimit value", async () => { - render(); + it("PATCH /workspaces/:id body does NOT include budget_limit", async () => { + render(); await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input.value).toBe("150"); - }); - - it("sends budget_limit as a number in PATCH body", async () => { - render(); - await openEdit(); - - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "300" }, - }); const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); const body = mockPatch.mock.calls[0][1] as Record; - expect(body.budget_limit).toBe(300); + expect(Object.prototype.hasOwnProperty.call(body, "budget_limit")).toBe(false); }); - it("sends budget_limit as null when field is cleared", async () => { - render(); + it("PATCH /workspaces/:id body includes name, role, and tier", async () => { + render( + + ); await openEdit(); - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "" }, - }); - const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockPatch).toHaveBeenCalled()); const body = mockPatch.mock.calls[0][1] as Record; - expect(body.budget_limit).toBeNull(); + expect(body.name).toBe("Alpha"); + expect(body.role).toBe("Writer"); + expect(body.tier).toBe(2); }); - it("calls updateNodeData with the new budgetLimit on successful save", async () => { - render(); + it("Cancel reverts name, role, tier without touching budget state", async () => { + render( + + ); await openEdit(); - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "500" }, - }); + // Modify name + fireEvent.change( + screen.getAllByRole("textbox").find((i) => (i as HTMLInputElement).value === "Original")!, + { target: { value: "Modified" } } + ); + + const cancelBtn = screen.getAllByRole("button").find((b) => b.textContent === "Cancel"); + fireEvent.click(cancelBtn!); + + // Should be back in read view — no Save button visible + expect(screen.queryAllByRole("button").some((b) => b.textContent === "Save")).toBe(false); + // Workspace info unchanged in read view + expect(screen.getByText("Original")).toBeTruthy(); + }); + + it("updateNodeData is called with name/role/tier but NOT budgetLimit on save", async () => { + render( + + ); + await openEdit(); const saveBtn = screen.getAllByRole("button").find((b) => b.textContent === "Save"); fireEvent.click(saveBtn!); await waitFor(() => expect(mockUpdateNodeData).toHaveBeenCalled()); const updateArgs = mockUpdateNodeData.mock.calls[0][1] as Record; - expect(updateArgs.budgetLimit).toBe(500); - }); - - it("restores original budgetLimit when Cancel is clicked", async () => { - render(); - await openEdit(); - - // Change the value - fireEvent.change(screen.getByPlaceholderText("Leave blank for unlimited"), { - target: { value: "9999" }, - }); - - // Cancel - const cancelBtn = screen.getAllByRole("button").find((b) => b.textContent === "Cancel"); - fireEvent.click(cancelBtn!); - - // Re-enter edit mode — should show original value - await openEdit(); - const input = screen.getByPlaceholderText("Leave blank for unlimited") as HTMLInputElement; - expect(input.value).toBe("75"); + expect(updateArgs.name).toBe("Bot"); + expect(updateArgs.role).toBe("Analyst"); + expect(updateArgs.tier).toBe(1); + expect(Object.prototype.hasOwnProperty.call(updateArgs, "budgetLimit")).toBe(false); + }); +}); + +// ── budget-exceeded-badge removed from DetailsTab ──────────────────────────── + +describe("DetailsTab — no inline budget-exceeded-badge", () => { + it("does NOT render budget-exceeded-badge even when budgetUsed > budgetLimit (BudgetSection owns that)", () => { + render( + + ); + // The old inline badge is gone — BudgetSection.tsx owns the exceeded state + expect(screen.queryByTestId("budget-exceeded-badge")).toBeNull(); + }); + + it("does NOT render inline Budget limit row in read view", () => { + render( + + ); + // "$100.00" and "Unlimited" are rendered by BudgetSection now + expect(screen.queryByText("$100.00")).toBeNull(); + expect(screen.queryByText("Unlimited")).toBeNull(); }); }); diff --git a/canvas/src/components/__tests__/BudgetSection.test.tsx b/canvas/src/components/__tests__/BudgetSection.test.tsx new file mode 100644 index 00000000..c9616b06 --- /dev/null +++ b/canvas/src/components/__tests__/BudgetSection.test.tsx @@ -0,0 +1,371 @@ +// @vitest-environment jsdom +/** + * Tests for BudgetSection (issue #541). + * + * Covers: + * - Loading state + * - Stats row: used / limit, "Unlimited" when null + * - Progress bar: correct percentage, capped at 100%, absent when no limit + * - Budget remaining text + * - Input pre-fill (existing limit / blank when null) + * - Save: PATCH with number, PATCH with null (blank input) + * - 402 on GET → exceeded banner, no fetch-error text + * - 402 on PATCH → exceeded banner + * - Non-402 fetch error → error text + * - Non-402 save error → save error alert + * - Section header and subheading + * - Fetch error does not show stats + */ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + render, + screen, + fireEvent, + waitFor, + cleanup, + act, +} from "@testing-library/react"; + +// ── Mock api ────────────────────────────────────────────────────────────────── + +vi.mock("@/lib/api", () => ({ + api: { + get: vi.fn(), + patch: vi.fn(), + }, +})); + +import { api } from "@/lib/api"; +import { BudgetSection } from "../tabs/BudgetSection"; + +const mockGet = vi.mocked(api.get); +const mockPatch = vi.mocked(api.patch); + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +function budgetResponse(overrides: Partial<{ + budget_limit: number | null; + budget_used: number; + budget_remaining: number | null; +}> = {}) { + return { + budget_limit: 1000, + budget_used: 250, + budget_remaining: 750, + ...overrides, + }; +} + +function make402Error(): Error { + return new Error("API GET /workspaces/ws-1/budget: 402 Payment Required"); +} + +function make402PatchError(): Error { + return new Error("API PATCH /workspaces/ws-1/budget: 402 Payment Required"); +} + +function makeGenericError(msg = "network timeout"): Error { + return new Error(`API GET /workspaces/ws-1/budget: 500 ${msg}`); +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +afterEach(() => { + cleanup(); +}); + +// ── Rendering helpers ───────────────────────────────────────────────────────── + +async function renderLoaded(budgetData = budgetResponse()) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetData as any); + render(); + // Wait for loading to finish + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); +} + +// ── Loading state ───────────────────────────────────────────────────────────── + +describe("BudgetSection — loading state", () => { + it("shows loading indicator while fetch is in flight", () => { + // Never resolve + mockGet.mockReturnValue(new Promise(() => {})); + render(); + expect(screen.getByTestId("budget-loading")).toBeTruthy(); + expect(screen.getByText("Loading…")).toBeTruthy(); + }); + + it("hides loading indicator after fetch resolves", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetResponse() as any); + render(); + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + }); +}); + +// ── Section header ──────────────────────────────────────────────────────────── + +describe("BudgetSection — header and subheading", () => { + it("renders 'Budget' as the section heading", async () => { + await renderLoaded(); + expect(screen.getByText("Budget")).toBeTruthy(); + }); + + it("renders the subheading 'Limit total message credits for this workspace'", async () => { + await renderLoaded(); + expect( + screen.getByText("Limit total message credits for this workspace") + ).toBeTruthy(); + }); + + it("renders 'Budget limit (credits)' label for the input", async () => { + await renderLoaded(); + expect(screen.getByText("Budget limit (credits)")).toBeTruthy(); + }); +}); + +// ── Stats row ───────────────────────────────────────────────────────────────── + +describe("BudgetSection — stats row", () => { + it("shows budget_used in the stats row", async () => { + await renderLoaded(budgetResponse({ budget_used: 350, budget_limit: 1000 })); + expect(screen.getByTestId("budget-used-value").textContent).toBe("350"); + }); + + it("shows budget_limit in the stats row", async () => { + await renderLoaded(budgetResponse({ budget_used: 100, budget_limit: 500 })); + expect(screen.getByTestId("budget-limit-value").textContent).toBe("500"); + }); + + it("shows 'Unlimited' when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + expect(screen.getByTestId("budget-limit-value").textContent).toBe("Unlimited"); + }); + + it("shows budget_remaining when present", async () => { + await renderLoaded(budgetResponse({ budget_remaining: 750 })); + expect(screen.getByTestId("budget-remaining").textContent).toContain("750"); + expect(screen.getByTestId("budget-remaining").textContent).toContain("credits remaining"); + }); + + it("hides budget_remaining row when null", async () => { + await renderLoaded(budgetResponse({ budget_remaining: null })); + expect(screen.queryByTestId("budget-remaining")).toBeNull(); + }); +}); + +// ── Progress bar ────────────────────────────────────────────────────────────── + +describe("BudgetSection — progress bar", () => { + it("renders the progress bar when budget_limit is set", async () => { + await renderLoaded(budgetResponse({ budget_used: 250, budget_limit: 1000 })); + expect(screen.getByRole("progressbar")).toBeTruthy(); + }); + + it("does NOT render progress bar when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + expect(screen.queryByRole("progressbar")).toBeNull(); + }); + + it("fills to the correct percentage (25%)", async () => { + await renderLoaded(budgetResponse({ budget_used: 250, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("25%"); + }); + + it("fills to the correct percentage (50%)", async () => { + await renderLoaded(budgetResponse({ budget_used: 500, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("50%"); + }); + + it("caps fill at 100% when budget_used exceeds budget_limit", async () => { + await renderLoaded(budgetResponse({ budget_used: 1500, budget_limit: 1000 })); + const fill = screen.getByTestId("budget-progress-fill") as HTMLDivElement; + expect(fill.style.width).toBe("100%"); + }); + + it("progress bar has aria-valuenow equal to the calculated percentage", async () => { + await renderLoaded(budgetResponse({ budget_used: 300, budget_limit: 1000 })); + const bar = screen.getByRole("progressbar"); + expect(bar.getAttribute("aria-valuenow")).toBe("30"); + }); +}); + +// ── Input pre-fill ──────────────────────────────────────────────────────────── + +describe("BudgetSection — input pre-fill", () => { + it("pre-fills input with existing budget_limit", async () => { + await renderLoaded(budgetResponse({ budget_limit: 500 })); + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + expect(input.value).toBe("500"); + }); + + it("leaves input empty when budget_limit is null", async () => { + await renderLoaded(budgetResponse({ budget_limit: null, budget_remaining: null })); + const input = screen.getByTestId("budget-limit-input") as HTMLInputElement; + expect(input.value).toBe(""); + }); +}); + +// ── Save — PATCH calls ──────────────────────────────────────────────────────── + +describe("BudgetSection — save", () => { + it("calls PATCH /workspaces/:id/budget with budget_limit as integer", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(budgetResponse({ budget_limit: 800 }) as any); + await renderLoaded(budgetResponse({ budget_limit: 1000 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "800" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => expect(mockPatch).toHaveBeenCalled()); + expect(mockPatch.mock.calls[0][0]).toBe("/workspaces/ws-1/budget"); + const body = mockPatch.mock.calls[0][1] as Record; + expect(body.budget_limit).toBe(800); + }); + + it("sends budget_limit: null when input is blank", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(budgetResponse({ budget_limit: null, budget_remaining: null }) as any); + await renderLoaded(budgetResponse({ budget_limit: 1000 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => expect(mockPatch).toHaveBeenCalled()); + const body = mockPatch.mock.calls[0][1] as Record; + expect(body.budget_limit).toBeNull(); + }); + + it("updates displayed stats after successful save", async () => { + const updated = budgetResponse({ budget_limit: 2000, budget_used: 500, budget_remaining: 1500 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(updated as any); + await renderLoaded(budgetResponse({ budget_limit: 1000, budget_used: 250 })); + + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "2000" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-limit-value").textContent).toBe("2,000") + ); + }); + + it("shows save error message on non-402 PATCH failure", async () => { + mockPatch.mockRejectedValueOnce( + new Error("API PATCH /workspaces/ws-1/budget: 500 server error") + ); + await renderLoaded(); + + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-save-error")).toBeTruthy() + ); + expect(screen.getByTestId("budget-save-error").textContent).toContain("500"); + }); +}); + +// ── 402 handling ────────────────────────────────────────────────────────────── + +describe("BudgetSection — 402 handling", () => { + it("shows exceeded banner when GET returns 402", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + expect(screen.getByText("Budget exceeded — messages blocked")).toBeTruthy(); + }); + + it("does NOT show fetch error text when GET returns 402 (only banner)", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + + await waitFor(() => + expect(screen.queryByTestId("budget-loading")).toBeNull() + ); + expect(screen.queryByTestId("budget-fetch-error")).toBeNull(); + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy(); + }); + + it("shows exceeded banner when PATCH returns 402", async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockGet.mockResolvedValueOnce(budgetResponse() as any); + mockPatch.mockRejectedValueOnce(make402PatchError()); + render(); + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + + fireEvent.click(screen.getByTestId("budget-save-btn")); + + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + // Should NOT also show the save-error alert + expect(screen.queryByTestId("budget-save-error")).toBeNull(); + }); + + it("clears exceeded banner after a successful save", async () => { + mockGet.mockRejectedValueOnce(make402Error()); + render(); + await waitFor(() => + expect(screen.getByTestId("budget-exceeded-banner")).toBeTruthy() + ); + + // Now a successful PATCH (limit was raised) + const updated = budgetResponse({ budget_limit: 5000, budget_used: 250, budget_remaining: 4750 }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + mockPatch.mockResolvedValueOnce(updated as any); + + await act(async () => { + fireEvent.change(screen.getByTestId("budget-limit-input"), { + target: { value: "5000" }, + }); + fireEvent.click(screen.getByTestId("budget-save-btn")); + }); + + await waitFor(() => + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull() + ); + }); +}); + +// ── Non-402 fetch error ─────────────────────────────────────────────────────── + +describe("BudgetSection — non-402 fetch errors", () => { + it("shows fetch error text on non-402 GET failure", async () => { + mockGet.mockRejectedValueOnce(makeGenericError("internal server error")); + render(); + + await waitFor(() => + expect(screen.getByTestId("budget-fetch-error")).toBeTruthy() + ); + expect(screen.getByTestId("budget-fetch-error").textContent).toContain("500"); + }); + + it("does NOT show stats row on fetch error", async () => { + mockGet.mockRejectedValueOnce(makeGenericError()); + render(); + + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + expect(screen.queryByTestId("budget-stats-row")).toBeNull(); + }); + + it("does NOT show exceeded banner on non-402 fetch error", async () => { + mockGet.mockRejectedValueOnce(makeGenericError()); + render(); + + await waitFor(() => expect(screen.queryByTestId("budget-loading")).toBeNull()); + expect(screen.queryByTestId("budget-exceeded-banner")).toBeNull(); + }); +}); diff --git a/canvas/src/components/tabs/BudgetSection.tsx b/canvas/src/components/tabs/BudgetSection.tsx new file mode 100644 index 00000000..86b74daa --- /dev/null +++ b/canvas/src/components/tabs/BudgetSection.tsx @@ -0,0 +1,251 @@ +'use client'; + +import { useState, useEffect, useCallback } from "react"; +import { api } from "@/lib/api"; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +interface BudgetData { + budget_limit: number | null; + budget_used: number; + budget_remaining: number | null; +} + +interface Props { + workspaceId: string; +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/** True when an API error carries a 402 status code. */ +function isApiError402(e: unknown): boolean { + return e instanceof Error && /: 402( |$)/.test(e.message); +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +/** + * BudgetSection — dedicated "Budget" section in the workspace details panel. + * + * - Fetches GET /workspaces/:id/budget on mount for live usage stats + * - Shows a progress bar (budget_used / budget_limit, blue-500, capped 100%) + * - Allows updating budget_limit via PATCH /workspaces/:id/budget + * - Shows a 402-specific "Budget exceeded" amber banner for any blocked state + */ +export function BudgetSection({ workspaceId }: Props) { + const [budget, setBudget] = useState(null); + const [loading, setLoading] = useState(true); + const [fetchError, setFetchError] = useState(null); + + const [limitInput, setLimitInput] = useState(""); + const [saving, setSaving] = useState(false); + const [saveError, setSaveError] = useState(null); + + /** True when a 402 has been seen from any API call in this section. */ + const [budgetExceeded, setBudgetExceeded] = useState(false); + + // ── Fetch current budget data ───────────────────────────────────────────── + + const loadBudget = useCallback(async () => { + setLoading(true); + setFetchError(null); + try { + const data = await api.get(`/workspaces/${workspaceId}/budget`); + setBudget(data); + setLimitInput(data.budget_limit != null ? String(data.budget_limit) : ""); + } catch (e) { + if (isApiError402(e)) { + setBudgetExceeded(true); + } else { + setFetchError(e instanceof Error ? e.message : "Failed to load budget"); + } + } finally { + setLoading(false); + } + }, [workspaceId]); + + useEffect(() => { + loadBudget(); + }, [loadBudget]); + + // ── Save handler ────────────────────────────────────────────────────────── + + const handleSave = async () => { + setSaving(true); + setSaveError(null); + const raw = limitInput.trim(); + const parsedLimit = raw ? parseInt(raw, 10) : null; + + try { + const updated = await api.patch(`/workspaces/${workspaceId}/budget`, { + budget_limit: parsedLimit, + }); + setBudget(updated); + setLimitInput(updated.budget_limit != null ? String(updated.budget_limit) : ""); + // Clear exceeded state if the save succeeded (limit was raised or removed) + setBudgetExceeded(false); + } catch (e) { + if (isApiError402(e)) { + setBudgetExceeded(true); + } else { + setSaveError(e instanceof Error ? e.message : "Failed to save budget"); + } + } finally { + setSaving(false); + } + }; + + // ── Progress calculation ────────────────────────────────────────────────── + + const progressPct = + budget && budget.budget_limit != null && budget.budget_limit > 0 + ? Math.min(100, Math.round((budget.budget_used / budget.budget_limit) * 100)) + : 0; + + // ── Render ──────────────────────────────────────────────────────────────── + + return ( +
+ {/* Section header */} +
+

+ Budget +

+

+ Limit total message credits for this workspace +

+
+ + {/* 402 exceeded banner */} + {budgetExceeded && ( +
+ + Budget exceeded — messages blocked +
+ )} + + {/* Usage stats */} + {loading ? ( +

+ Loading… +

+ ) : fetchError ? ( +

+ {fetchError} +

+ ) : budget ? ( +
+ {/* Stats row */} +
+ Credits used + + {budget.budget_used.toLocaleString()} + / + + {budget.budget_limit != null + ? budget.budget_limit.toLocaleString() + : "Unlimited"} + + +
+ + {/* Progress bar (only when limit is set) */} + {budget.budget_limit != null && ( +
+
+
+ )} + + {/* Remaining credits */} + {budget.budget_remaining != null && ( +

+ {budget.budget_remaining.toLocaleString()} credits remaining +

+ )} +
+ ) : null} + + {/* Input + Save */} +
+ + setLimitInput(e.target.value)} + placeholder="e.g. 1000 — blank for unlimited" + data-testid="budget-limit-input" + className="w-full bg-zinc-800 border border-zinc-700 rounded-lg px-3 py-2 text-sm text-zinc-300 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/30 transition-colors" + /> +

Leave blank for unlimited

+ + {saveError && ( +
+ {saveError} +
+ )} + + +
+
+ ); +} diff --git a/canvas/src/components/tabs/DetailsTab.tsx b/canvas/src/components/tabs/DetailsTab.tsx index 6ca9efa1..b9f9042f 100644 --- a/canvas/src/components/tabs/DetailsTab.tsx +++ b/canvas/src/components/tabs/DetailsTab.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { api } from "@/lib/api"; import { useCanvasStore, type WorkspaceNodeData } from "@/store/canvas"; import { StatusDot } from "../StatusDot"; +import { BudgetSection } from "./BudgetSection"; import { WorkspaceUsage } from "../WorkspaceUsage"; interface Props { @@ -24,9 +25,6 @@ export function DetailsTab({ workspaceId, data }: Props) { const [name, setName] = useState(data.name); const [role, setRole] = useState(data.role || ""); const [tier, setTier] = useState(data.tier); - const [budgetLimit, setBudgetLimit] = useState( - data.budgetLimit != null ? String(data.budgetLimit) : "" - ); const [peers, setPeers] = useState([]); const [saving, setSaving] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); @@ -43,8 +41,7 @@ export function DetailsTab({ workspaceId, data }: Props) { setName(data.name); setRole(data.role || ""); setTier(data.tier); - setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : ""); - }, [data.name, data.role, data.tier, data.budgetLimit]); + }, [data.name, data.role, data.tier]); const loadPeers = useCallback(async () => { setPeersError(null); @@ -63,17 +60,13 @@ export function DetailsTab({ workspaceId, data }: Props) { const handleSave = async () => { setSaving(true); setSaveError(null); - const parsedBudget = budgetLimit.trim() - ? parseFloat(budgetLimit) - : null; try { await api.patch(`/workspaces/${workspaceId}`, { name, role: role || null, tier, - budget_limit: parsedBudget, }); - updateNodeData(workspaceId, { name, role: role || "", tier, budgetLimit: parsedBudget }); + updateNodeData(workspaceId, { name, role: role || "", tier }); setEditing(false); } catch (e) { setSaveError(e instanceof Error ? e.message : "Failed to save"); @@ -107,10 +100,6 @@ export function DetailsTab({ workspaceId, data }: Props) { }; const isRestartable = data.status === "offline" || data.status === "failed" || data.status === "degraded"; - const budgetExceeded = - data.budgetLimit != null && - data.budgetUsed != null && - data.budgetUsed > data.budgetLimit; const agentCard = data.agentCard; const skills = getSkills(agentCard); @@ -148,18 +137,6 @@ export function DetailsTab({ workspaceId, data }: Props) { - - setBudgetLimit(e.target.value)} - placeholder="Leave blank for unlimited" - className="w-full bg-zinc-800 border border-zinc-600 rounded px-2 py-1 text-sm text-zinc-100 placeholder-zinc-500 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20" - /> -

Leave blank for unlimited

-
{saveError && (
{saveError} @@ -180,7 +157,6 @@ export function DetailsTab({ workspaceId, data }: Props) { setName(data.name); setRole(data.role || ""); setTier(data.tier); - setBudgetLimit(data.budgetLimit != null ? String(data.budgetLimit) : ""); }} className="px-3 py-1 bg-zinc-700 hover:bg-zinc-600 text-xs rounded text-zinc-300" > @@ -190,29 +166,9 @@ export function DetailsTab({ workspaceId, data }: Props) {
) : (
- {budgetExceeded && ( -
- - Budget limit exceeded -
- )} - - {data.budgetUsed != null && ( - - )} @@ -246,7 +202,10 @@ export function DetailsTab({ workspaceId, data }: Props) { )} - {/* Token usage + spend (scaffold — wired to GET /workspaces/:id/metrics once #593 lands) */} + {/* Budget — dedicated section with live usage stats (#541) */} + + + {/* Token usage + spend — wired to GET /workspaces/:id/metrics (#592) */} {/* Agent Card / Skills */} From c064200164f0b79709d5a4cf535eb3e1a459bb63 Mon Sep 17 00:00:00 2001 From: Molecule AI Frontend Engineer Date: Fri, 17 Apr 2026 01:28:55 +0000 Subject: [PATCH 31/32] =?UTF-8?q?fix(canvas):=20WCAG=20SC=201.3.1=20?= =?UTF-8?q?=E2=80=94=20programmatic=20label/input=20association=20in=20Inp?= =?UTF-8?q?utField?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds useId() to the InputField helper in CreateWorkspaceDialog so every