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 01/51] 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 02/51] 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 03/51] =?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 04/51] 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 05/51] 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 40/51] 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 41/51] =?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