Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1) with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo. Brand: Starfire → Molecule AI. Slug: starfire / agent-molecule → molecule. Env vars: STARFIRE_* → MOLECULE_*. Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform. Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent. DB: agentmolecule → molecule. History truncated; see public repo for prior commits and contributor attribution. Verified green: go test -race ./... (platform), pytest (workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
216 lines
6.7 KiB
Python
216 lines
6.7 KiB
Python
"""Unit tests for the external workspace bridge."""
|
|
|
|
import json
|
|
import io
|
|
import os
|
|
import sys
|
|
from pathlib import Path
|
|
from unittest.mock import MagicMock, patch
|
|
import pytest
|
|
|
|
# Add scripts/ to path so bridge package is importable
|
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
|
|
from bridge.processor import (
|
|
MessageProcessor,
|
|
ClaudeCodeProcessor,
|
|
OpenAIProcessor,
|
|
AnthropicProcessor,
|
|
HTTPForwardProcessor,
|
|
EchoProcessor,
|
|
PROCESSORS,
|
|
create_processor,
|
|
)
|
|
|
|
|
|
# ─── Processor registry ───
|
|
|
|
class TestProcessorRegistry:
|
|
def test_all_processors_registered(self):
|
|
expected = {"claude-code", "openai", "anthropic", "http", "echo"}
|
|
assert set(PROCESSORS.keys()) == expected
|
|
|
|
def test_create_processor_valid(self):
|
|
p = create_processor("echo")
|
|
assert isinstance(p, EchoProcessor)
|
|
|
|
def test_create_processor_invalid(self):
|
|
with pytest.raises(ValueError, match="Unknown processor"):
|
|
create_processor("nonexistent")
|
|
|
|
def test_create_processor_with_kwargs(self):
|
|
p = create_processor("openai", model="gpt-4o", api_key="test-key")
|
|
assert p.model == "gpt-4o"
|
|
assert p.api_key == "test-key"
|
|
|
|
|
|
# ─── EchoProcessor ───
|
|
|
|
class TestEchoProcessor:
|
|
def test_echoes_message(self):
|
|
p = EchoProcessor()
|
|
result = p.process("hello", "PM", {})
|
|
assert result == "Echo from bridge: hello"
|
|
|
|
def test_name(self):
|
|
assert EchoProcessor.name == "echo"
|
|
|
|
def test_empty_message(self):
|
|
p = EchoProcessor()
|
|
result = p.process("", "PM", {})
|
|
assert result == "Echo from bridge: "
|
|
|
|
|
|
# ─── OpenAIProcessor ───
|
|
|
|
class TestOpenAIProcessor:
|
|
def test_missing_api_key_returns_error(self):
|
|
p = OpenAIProcessor(api_key="")
|
|
result = p.process("hello", "PM", {})
|
|
assert "not configured" in result
|
|
|
|
def test_init_reads_env(self, monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "sk-test-123")
|
|
p = OpenAIProcessor()
|
|
assert p.api_key == "sk-test-123"
|
|
|
|
def test_init_explicit_key_overrides_env(self, monkeypatch):
|
|
monkeypatch.setenv("OPENAI_API_KEY", "from-env")
|
|
p = OpenAIProcessor(api_key="explicit")
|
|
assert p.api_key == "explicit"
|
|
|
|
@patch("httpx.post")
|
|
def test_successful_response(self, mock_post):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {
|
|
"choices": [{"message": {"content": "Hello from GPT"}}]
|
|
}
|
|
mock_post.return_value = mock_resp
|
|
|
|
p = OpenAIProcessor(api_key="test-key")
|
|
result = p.process("hi", "PM", {})
|
|
assert result == "Hello from GPT"
|
|
|
|
@patch("httpx.post", side_effect=Exception("connection refused"))
|
|
def test_api_error(self, mock_post):
|
|
p = OpenAIProcessor(api_key="test-key")
|
|
result = p.process("hi", "PM", {})
|
|
assert "OpenAI API error" in result
|
|
|
|
|
|
# ─── AnthropicProcessor ───
|
|
|
|
class TestAnthropicProcessor:
|
|
def test_missing_api_key_returns_error(self):
|
|
p = AnthropicProcessor(api_key="")
|
|
result = p.process("hello", "PM", {})
|
|
assert "not configured" in result
|
|
|
|
def test_init_reads_env(self, monkeypatch):
|
|
monkeypatch.setenv("ANTHROPIC_API_KEY", "sk-ant-test")
|
|
p = AnthropicProcessor()
|
|
assert p.api_key == "sk-ant-test"
|
|
|
|
@patch("httpx.post")
|
|
def test_successful_response(self, mock_post):
|
|
mock_resp = MagicMock()
|
|
mock_resp.json.return_value = {
|
|
"content": [{"text": "Hello from Claude"}]
|
|
}
|
|
mock_post.return_value = mock_resp
|
|
|
|
p = AnthropicProcessor(api_key="test-key")
|
|
result = p.process("hi", "PM", {})
|
|
assert result == "Hello from Claude"
|
|
|
|
|
|
# ─── HTTPForwardProcessor ───
|
|
|
|
class TestHTTPForwardProcessor:
|
|
def test_no_url_returns_error(self):
|
|
p = HTTPForwardProcessor(url="")
|
|
result = p.process("hi", "PM", {})
|
|
assert "not configured" in result
|
|
|
|
@patch("httpx.post")
|
|
def test_forwards_message(self, mock_post):
|
|
mock_resp = MagicMock()
|
|
mock_resp.text = "forwarded response"
|
|
mock_post.return_value = mock_resp
|
|
|
|
p = HTTPForwardProcessor(url="http://my-agent:8000")
|
|
result = p.process("hello", "PM", {"sender_id": "ws-1"})
|
|
assert result == "forwarded response"
|
|
mock_post.assert_called_once()
|
|
call_kwargs = mock_post.call_args
|
|
assert call_kwargs[1]["json"]["message"] == "hello"
|
|
assert call_kwargs[1]["json"]["sender"] == "PM"
|
|
|
|
|
|
# ─── ClaudeCodeProcessor ───
|
|
|
|
class TestClaudeCodeProcessor:
|
|
def test_name(self):
|
|
assert ClaudeCodeProcessor.name == "claude-code"
|
|
|
|
@patch("subprocess.run")
|
|
def test_successful_response(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0, stdout="The answer is 42", stderr=""
|
|
)
|
|
p = ClaudeCodeProcessor(cwd="/tmp")
|
|
result = p.process("what is the answer?", "PM", {})
|
|
assert result == "The answer is 42"
|
|
|
|
@patch("subprocess.run")
|
|
def test_json_output_parsed(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=0,
|
|
stdout=json.dumps({"result": "parsed answer", "session_id": "abc"}),
|
|
stderr="",
|
|
)
|
|
p = ClaudeCodeProcessor(cwd="/tmp")
|
|
result = p.process("test", "PM", {})
|
|
assert result == "parsed answer"
|
|
|
|
@patch("subprocess.run")
|
|
def test_error_exit_code(self, mock_run):
|
|
mock_run.return_value = MagicMock(
|
|
returncode=1, stdout="", stderr="auth error"
|
|
)
|
|
p = ClaudeCodeProcessor(cwd="/tmp")
|
|
result = p.process("test", "PM", {})
|
|
assert "auth error" in result
|
|
|
|
@patch("subprocess.run", side_effect=FileNotFoundError)
|
|
def test_claude_not_installed(self, mock_run):
|
|
p = ClaudeCodeProcessor(cwd="/tmp")
|
|
result = p.process("test", "PM", {})
|
|
assert "not found" in result.lower()
|
|
|
|
def test_model_flag(self):
|
|
p = ClaudeCodeProcessor(model="opus")
|
|
assert p.model == "opus"
|
|
|
|
|
|
# ─── MessageProcessor interface ───
|
|
|
|
class TestMessageProcessorInterface:
|
|
def test_cannot_instantiate_abstract(self):
|
|
with pytest.raises(TypeError):
|
|
MessageProcessor()
|
|
|
|
def test_subclass_must_implement_process(self):
|
|
class Incomplete(MessageProcessor):
|
|
name = "incomplete"
|
|
with pytest.raises(TypeError):
|
|
Incomplete()
|
|
|
|
def test_valid_subclass(self):
|
|
class Valid(MessageProcessor):
|
|
name = "valid"
|
|
def process(self, message, sender, context):
|
|
return "ok"
|
|
v = Valid()
|
|
assert v.process("x", "y", {}) == "ok"
|