mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-14 08:52:06 +00:00
fix(channels): wire channel→agent handler and fix Telegram send pipeline (#94)
* fix(channels): wire channel→agent handler and fix Telegram send pipeline * format code * add supported tests
This commit is contained in:
@@ -14,6 +14,7 @@ dependencies = [
|
||||
"ddgs>=9.11.4",
|
||||
"httpx>=0.27",
|
||||
"openai>=1.30",
|
||||
"python-telegram-bot>=22.6",
|
||||
"rich>=13",
|
||||
"tomli>=2.0; python_version < '3.11'",
|
||||
]
|
||||
|
||||
@@ -109,14 +109,13 @@ class TelegramChannel(BaseChannel):
|
||||
import httpx
|
||||
|
||||
url = f"https://api.telegram.org/bot{self._token}/sendMessage"
|
||||
chat_id = conversation_id or channel
|
||||
payload: Dict[str, Any] = {
|
||||
"chat_id": channel,
|
||||
"chat_id": chat_id,
|
||||
"text": content,
|
||||
}
|
||||
if self._parse_mode:
|
||||
payload["parse_mode"] = self._parse_mode
|
||||
if conversation_id:
|
||||
payload["reply_to_message_id"] = conversation_id
|
||||
|
||||
resp = httpx.post(url, json=payload, timeout=10.0)
|
||||
if resp.status_code < 300:
|
||||
@@ -164,6 +163,19 @@ class TelegramChannel(BaseChannel):
|
||||
message_id=str(msg.message_id),
|
||||
conversation_id=str(msg.chat.id),
|
||||
)
|
||||
# Enforce allow-list when configured
|
||||
if self._allowed_chat_ids:
|
||||
_allowed = {
|
||||
cid.strip()
|
||||
for cid in self._allowed_chat_ids.split(",")
|
||||
if cid.strip()
|
||||
}
|
||||
if cm.conversation_id not in _allowed:
|
||||
logger.debug(
|
||||
"Ignoring message from unlisted chat %s",
|
||||
cm.conversation_id,
|
||||
)
|
||||
return
|
||||
for handler in self._handlers:
|
||||
try:
|
||||
handler(cm)
|
||||
@@ -181,7 +193,7 @@ class TelegramChannel(BaseChannel):
|
||||
)
|
||||
|
||||
app.add_handler(MessageHandler(filters.TEXT, _handle_msg))
|
||||
app.run_polling(stop_signals=None)
|
||||
app.run_polling(stop_signals=None, drop_pending_updates=True)
|
||||
except Exception:
|
||||
logger.debug("Telegram poll loop error", exc_info=True)
|
||||
self._status = ChannelStatus.ERROR
|
||||
|
||||
@@ -230,6 +230,20 @@ def serve(
|
||||
console.print(f"[yellow]Channel failed to start: {exc}[/yellow]")
|
||||
channel_bridge = None
|
||||
|
||||
# Wire channel messages → agent / engine (per-chat session isolation)
|
||||
if channel_bridge is not None:
|
||||
from openjarvis.system import JarvisSystem
|
||||
|
||||
_wire_system = JarvisSystem(
|
||||
config=config,
|
||||
bus=bus,
|
||||
engine=engine,
|
||||
engine_key=engine_name,
|
||||
model=model_name,
|
||||
agent_name=agent_key or "",
|
||||
)
|
||||
_wire_system.wire_channel(channel_bridge)
|
||||
|
||||
# Set up speech backend
|
||||
speech_backend = None
|
||||
try:
|
||||
|
||||
@@ -66,7 +66,7 @@ class SessionStore:
|
||||
) -> None:
|
||||
self._db_path = Path(db_path)
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(str(self._db_path))
|
||||
self._conn = sqlite3.connect(str(self._db_path), check_same_thread=False)
|
||||
self._max_age_hours = max_age_hours
|
||||
self._consolidation_threshold = consolidation_threshold
|
||||
self._create_tables()
|
||||
|
||||
@@ -269,6 +269,85 @@ class JarvisSystem:
|
||||
logger.warning("Failed to build tool %r: %s", name, exc)
|
||||
return tools
|
||||
|
||||
def wire_channel(self, channel_bridge: Any) -> None:
|
||||
"""Register a message handler on *channel_bridge* that routes every
|
||||
incoming message through this system (agent or engine) and replies.
|
||||
|
||||
Sessions are isolated per ``"<channel>:<conversation_id>"`` key so
|
||||
each chat retains its own history.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
channel_bridge:
|
||||
A connected :class:`~openjarvis.channels._stubs.BaseChannel`
|
||||
instance whose ``on_message`` method accepts a callable.
|
||||
"""
|
||||
from openjarvis.agents._stubs import AgentContext
|
||||
from openjarvis.core.types import Message, Role
|
||||
from openjarvis.sessions.session import SessionStore
|
||||
|
||||
if self.session_store is None:
|
||||
from pathlib import Path
|
||||
self.session_store = SessionStore(
|
||||
db_path=Path(self.config.sessions.db_path).expanduser(),
|
||||
max_age_hours=self.config.sessions.max_age_hours,
|
||||
consolidation_threshold=self.config.sessions.consolidation_threshold,
|
||||
)
|
||||
|
||||
_system = self # capture for closure
|
||||
|
||||
def _on_channel_message(cm) -> None:
|
||||
session_key = f"{cm.channel}:{cm.conversation_id}"
|
||||
session = _system.session_store.get_or_create(
|
||||
session_key,
|
||||
channel=cm.channel,
|
||||
channel_user_id=cm.sender,
|
||||
)
|
||||
|
||||
# Rebuild prior conversation turns into AgentContext
|
||||
ctx = AgentContext()
|
||||
for sm in session.messages:
|
||||
try:
|
||||
role = Role(sm.role)
|
||||
except ValueError:
|
||||
role = Role.USER
|
||||
ctx.conversation.add(Message(role=role, content=sm.content))
|
||||
|
||||
reply = ""
|
||||
try:
|
||||
if _system.agent_name and _system.agent_name != "none":
|
||||
result = _system.ask(
|
||||
cm.content, context=False,
|
||||
agent=_system.agent_name,
|
||||
)
|
||||
reply = result.get("content", "")
|
||||
else:
|
||||
result = _system.ask(cm.content, context=False)
|
||||
reply = result.get("content", "")
|
||||
except Exception:
|
||||
logger.exception("Channel message handler error")
|
||||
reply = "Sorry, I encountered an error processing your message."
|
||||
|
||||
try:
|
||||
_system.session_store.save_message(
|
||||
session.session_id, "user", cm.content, channel=cm.channel,
|
||||
)
|
||||
_system.session_store.save_message(
|
||||
session.session_id, "assistant", reply, channel=cm.channel,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Session save error", exc_info=True)
|
||||
|
||||
if reply:
|
||||
try:
|
||||
channel_bridge.send(
|
||||
cm.channel, reply, conversation_id=cm.conversation_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Channel send error")
|
||||
|
||||
channel_bridge.on_message(_on_channel_message)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Release resources."""
|
||||
if self.scheduler and hasattr(self.scheduler, "stop"):
|
||||
|
||||
+81
-60
@@ -78,6 +78,7 @@ class TestA2AResponse:
|
||||
|
||||
def test_from_json(self):
|
||||
import json
|
||||
|
||||
data = json.dumps({"jsonrpc": "2.0", "result": "ok", "id": "3"})
|
||||
resp = A2AResponse.from_json(data)
|
||||
assert resp.result == "ok"
|
||||
@@ -88,24 +89,28 @@ class TestA2AServer:
|
||||
def test_task_send(self):
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card, handler=lambda x: f"Echo: {x}")
|
||||
response = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "Hello"},
|
||||
"id": "1",
|
||||
})
|
||||
response = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "Hello"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
assert response["result"]["state"] == "completed"
|
||||
assert "Echo: Hello" in response["result"]["output"]
|
||||
|
||||
def test_task_send_with_message_format(self):
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card, handler=lambda x: f"Got: {x}")
|
||||
response = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"message": {"role": "user", "parts": [{"text": "Hi"}]}},
|
||||
"id": "1",
|
||||
})
|
||||
response = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"message": {"role": "user", "parts": [{"text": "Hi"}]}},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
assert response["result"]["state"] == "completed"
|
||||
assert "Got: Hi" in response["result"]["output"]
|
||||
|
||||
@@ -113,62 +118,74 @@ class TestA2AServer:
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card, handler=lambda x: x)
|
||||
# First send a task
|
||||
send_resp = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
})
|
||||
send_resp = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
task_id = send_resp["result"]["id"]
|
||||
|
||||
# Now get it
|
||||
get_resp = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"params": {"id": task_id},
|
||||
"id": "2",
|
||||
})
|
||||
get_resp = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"params": {"id": task_id},
|
||||
"id": "2",
|
||||
}
|
||||
)
|
||||
assert get_resp["result"]["id"] == task_id
|
||||
|
||||
def test_task_get_not_found(self):
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card)
|
||||
response = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"params": {"id": "nonexistent"},
|
||||
"id": "1",
|
||||
})
|
||||
response = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/get",
|
||||
"params": {"id": "nonexistent"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
assert "error" in response
|
||||
|
||||
def test_task_cancel(self):
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card, handler=lambda x: x)
|
||||
send_resp = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
})
|
||||
send_resp = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
task_id = send_resp["result"]["id"]
|
||||
|
||||
cancel_resp = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/cancel",
|
||||
"params": {"id": task_id},
|
||||
"id": "2",
|
||||
})
|
||||
cancel_resp = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/cancel",
|
||||
"params": {"id": task_id},
|
||||
"id": "2",
|
||||
}
|
||||
)
|
||||
assert cancel_resp["result"]["state"] == "canceled"
|
||||
|
||||
def test_unknown_method(self):
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card)
|
||||
response = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "unknown/method",
|
||||
"params": {},
|
||||
"id": "1",
|
||||
})
|
||||
response = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "unknown/method",
|
||||
"params": {},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
assert "error" in response
|
||||
assert response["error"]["code"] == -32601
|
||||
|
||||
@@ -176,12 +193,14 @@ class TestA2AServer:
|
||||
bus = EventBus(record_history=True)
|
||||
card = AgentCard(name="Test")
|
||||
server = A2AServer(card, handler=lambda x: x, bus=bus)
|
||||
server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
})
|
||||
server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
event_types = {e.event_type for e in bus.history}
|
||||
assert EventType.A2A_TASK_RECEIVED in event_types
|
||||
assert EventType.A2A_TASK_COMPLETED in event_types
|
||||
@@ -193,10 +212,12 @@ class TestA2AServer:
|
||||
raise ValueError("boom")
|
||||
|
||||
server = A2AServer(card, handler=bad_handler)
|
||||
response = server.handle_request({
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
})
|
||||
response = server.handle_request(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "tasks/send",
|
||||
"params": {"input": "test"},
|
||||
"id": "1",
|
||||
}
|
||||
)
|
||||
assert response["result"]["state"] == "failed"
|
||||
|
||||
@@ -87,7 +87,11 @@ class TestBaseAgentInit:
|
||||
bus = EventBus()
|
||||
engine = MagicMock()
|
||||
agent = _ConcreteAgent(
|
||||
engine, "m", bus=bus, temperature=0.1, max_tokens=256,
|
||||
engine,
|
||||
"m",
|
||||
bus=bus,
|
||||
temperature=0.1,
|
||||
max_tokens=256,
|
||||
)
|
||||
assert agent._temperature == 0.1
|
||||
assert agent._max_tokens == 256
|
||||
@@ -176,7 +180,9 @@ class TestBuildMessages:
|
||||
conv.add(Message(role=Role.USER, content="prev"))
|
||||
ctx = AgentContext(conversation=conv)
|
||||
messages = agent._build_messages(
|
||||
"new", ctx, system_prompt="System.",
|
||||
"new",
|
||||
ctx,
|
||||
system_prompt="System.",
|
||||
)
|
||||
assert len(messages) == 3
|
||||
assert messages[0].role == Role.SYSTEM
|
||||
|
||||
@@ -20,8 +20,7 @@ def test_budget_exceeded_sets_status(tmp_path):
|
||||
assert updated["status"] == "budget_exceeded"
|
||||
|
||||
budget_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_BUDGET_EXCEEDED
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_BUDGET_EXCEEDED
|
||||
]
|
||||
assert len(budget_events) == 1
|
||||
mgr.close()
|
||||
@@ -54,7 +53,8 @@ def test_budget_unlimited_skips_check(tmp_path):
|
||||
mgr.start_tick(agent["id"])
|
||||
|
||||
result = AgentResult(
|
||||
content="done", metadata={"cost": 999.99, "tokens_used": 1000000},
|
||||
content="done",
|
||||
metadata={"cost": 999.99, "tokens_used": 1000000},
|
||||
)
|
||||
executor._finalize_tick(agent["id"], result, error=None, duration=1.0)
|
||||
|
||||
|
||||
@@ -141,16 +141,19 @@ class TestClaudeCodeRun:
|
||||
|
||||
def test_successful_run(self):
|
||||
agent = self._make_agent()
|
||||
output = _wrap_output({
|
||||
"content": "Hello from Claude Code!",
|
||||
"tool_results": [],
|
||||
"metadata": {"message_count": 3},
|
||||
})
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "Hello from Claude Code!",
|
||||
"tool_results": [],
|
||||
"metadata": {"message_count": 3},
|
||||
}
|
||||
)
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -165,22 +168,25 @@ class TestClaudeCodeRun:
|
||||
|
||||
def test_run_with_tool_results(self):
|
||||
agent = self._make_agent()
|
||||
output = _wrap_output({
|
||||
"content": "I read the file.",
|
||||
"tool_results": [
|
||||
{
|
||||
"tool_name": "Read",
|
||||
"content": "file contents",
|
||||
"success": True,
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
})
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "I read the file.",
|
||||
"tool_results": [
|
||||
{
|
||||
"tool_name": "Read",
|
||||
"content": "file contents",
|
||||
"success": True,
|
||||
},
|
||||
],
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -200,20 +206,24 @@ class TestClaudeCodeRun:
|
||||
allowed_tools=["Read", "Write"],
|
||||
system_prompt="Be helpful.",
|
||||
)
|
||||
output = _wrap_output({
|
||||
"content": "ok",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
})
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "ok",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch(
|
||||
"subprocess.run", return_value=proc,
|
||||
"subprocess.run",
|
||||
return_value=proc,
|
||||
) as mock_run,
|
||||
):
|
||||
agent.run("Do something")
|
||||
@@ -230,12 +240,14 @@ class TestClaudeCodeRun:
|
||||
def test_timeout_handling(self):
|
||||
agent = self._make_agent(timeout=5)
|
||||
exc = subprocess.TimeoutExpired(
|
||||
cmd="node", timeout=5,
|
||||
cmd="node",
|
||||
timeout=5,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", side_effect=exc),
|
||||
@@ -249,12 +261,14 @@ class TestClaudeCodeRun:
|
||||
def test_nonzero_exit_code(self):
|
||||
agent = self._make_agent()
|
||||
proc = _mock_proc(
|
||||
returncode=1, stderr="ENOENT: module not found",
|
||||
returncode=1,
|
||||
stderr="ENOENT: module not found",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -273,7 +287,8 @@ class TestClaudeCodeRun:
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -291,7 +306,8 @@ class TestClaudeCodeRun:
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -312,18 +328,24 @@ class TestClaudeCodeEvents:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", bus=bus, api_key="k",
|
||||
engine,
|
||||
"test-model",
|
||||
bus=bus,
|
||||
api_key="k",
|
||||
)
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "hi",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
output = _wrap_output({
|
||||
"content": "hi",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
})
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -339,18 +361,24 @@ class TestClaudeCodeEvents:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", bus=bus, api_key="k",
|
||||
engine,
|
||||
"test-model",
|
||||
bus=bus,
|
||||
api_key="k",
|
||||
)
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "hi",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
output = _wrap_output({
|
||||
"content": "hi",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
})
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -358,8 +386,7 @@ class TestClaudeCodeEvents:
|
||||
agent.run("test input")
|
||||
|
||||
start_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_TURN_START
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_TURN_START
|
||||
]
|
||||
assert len(start_events) == 1
|
||||
assert start_events[0].data["agent"] == "claude_code"
|
||||
@@ -370,13 +397,17 @@ class TestClaudeCodeEvents:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", bus=bus, api_key="k",
|
||||
engine,
|
||||
"test-model",
|
||||
bus=bus,
|
||||
api_key="k",
|
||||
)
|
||||
proc = _mock_proc(returncode=1, stderr="error")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
@@ -449,11 +480,7 @@ class TestParseOutput:
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
}
|
||||
stdout = (
|
||||
"some debug output\n"
|
||||
+ _wrap_output(payload)
|
||||
+ "\nmore output"
|
||||
)
|
||||
stdout = "some debug output\n" + _wrap_output(payload) + "\nmore output"
|
||||
content, tools, meta = ClaudeCodeAgent._parse_output(
|
||||
stdout,
|
||||
)
|
||||
@@ -485,7 +512,9 @@ class TestClaudeCodeDefaults:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", api_key="explicit-key",
|
||||
engine,
|
||||
"test-model",
|
||||
api_key="explicit-key",
|
||||
)
|
||||
assert agent._api_key == "explicit-key"
|
||||
|
||||
@@ -499,7 +528,9 @@ class TestClaudeCodeDefaults:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", timeout=60,
|
||||
engine,
|
||||
"test-model",
|
||||
timeout=60,
|
||||
)
|
||||
assert agent._timeout == 60
|
||||
|
||||
@@ -507,18 +538,23 @@ class TestClaudeCodeDefaults:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
agent = ClaudeCodeAgent(
|
||||
engine, "test-model", api_key="k",
|
||||
engine,
|
||||
"test-model",
|
||||
api_key="k",
|
||||
)
|
||||
output = _wrap_output(
|
||||
{
|
||||
"content": "ok",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
}
|
||||
)
|
||||
output = _wrap_output({
|
||||
"content": "ok",
|
||||
"tool_results": [],
|
||||
"metadata": {},
|
||||
})
|
||||
proc = _mock_proc(stdout=output)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
agent, "_ensure_runner",
|
||||
agent,
|
||||
"_ensure_runner",
|
||||
return_value="/fake/runner",
|
||||
),
|
||||
patch("subprocess.run", return_value=proc),
|
||||
|
||||
@@ -43,39 +43,47 @@ class ContinuationAgent(BaseAgent):
|
||||
|
||||
class TestContinuation:
|
||||
def test_no_continuation_needed(self):
|
||||
engine = MockEngine([
|
||||
{"content": "Hello world", "finish_reason": "stop"},
|
||||
])
|
||||
engine = MockEngine(
|
||||
[
|
||||
{"content": "Hello world", "finish_reason": "stop"},
|
||||
]
|
||||
)
|
||||
agent = ContinuationAgent(engine, "test-model")
|
||||
result = agent.run("Hi")
|
||||
assert result.content == "Hello world"
|
||||
|
||||
def test_single_continuation(self):
|
||||
engine = MockEngine([
|
||||
{"content": "Part 1...", "finish_reason": "length"},
|
||||
{"content": " Part 2.", "finish_reason": "stop"},
|
||||
])
|
||||
engine = MockEngine(
|
||||
[
|
||||
{"content": "Part 1...", "finish_reason": "length"},
|
||||
{"content": " Part 2.", "finish_reason": "stop"},
|
||||
]
|
||||
)
|
||||
agent = ContinuationAgent(engine, "test-model")
|
||||
result = agent.run("Hi")
|
||||
assert result.content == "Part 1... Part 2."
|
||||
|
||||
def test_multiple_continuations(self):
|
||||
engine = MockEngine([
|
||||
{"content": "A", "finish_reason": "length"},
|
||||
{"content": "B", "finish_reason": "length"},
|
||||
{"content": "C", "finish_reason": "stop"},
|
||||
])
|
||||
engine = MockEngine(
|
||||
[
|
||||
{"content": "A", "finish_reason": "length"},
|
||||
{"content": "B", "finish_reason": "length"},
|
||||
{"content": "C", "finish_reason": "stop"},
|
||||
]
|
||||
)
|
||||
agent = ContinuationAgent(engine, "test-model")
|
||||
result = agent.run("Hi")
|
||||
assert result.content == "ABC"
|
||||
|
||||
def test_max_continuations_respected(self):
|
||||
engine = MockEngine([
|
||||
{"content": "A", "finish_reason": "length"},
|
||||
{"content": "B", "finish_reason": "length"},
|
||||
{"content": "C", "finish_reason": "length"}, # 3rd continuation
|
||||
{"content": "D", "finish_reason": "stop"},
|
||||
])
|
||||
engine = MockEngine(
|
||||
[
|
||||
{"content": "A", "finish_reason": "length"},
|
||||
{"content": "B", "finish_reason": "length"},
|
||||
{"content": "C", "finish_reason": "length"}, # 3rd continuation
|
||||
{"content": "D", "finish_reason": "stop"},
|
||||
]
|
||||
)
|
||||
agent = ContinuationAgent(engine, "test-model")
|
||||
# Default max_continuations=2, so should stop after 2 continuations
|
||||
messages = agent._build_messages("Hi")
|
||||
@@ -84,9 +92,11 @@ class TestContinuation:
|
||||
assert content == "ABC" # A + B + C, but not D
|
||||
|
||||
def test_empty_finish_reason(self):
|
||||
engine = MockEngine([
|
||||
{"content": "Done", "finish_reason": ""},
|
||||
])
|
||||
engine = MockEngine(
|
||||
[
|
||||
{"content": "Done", "finish_reason": ""},
|
||||
]
|
||||
)
|
||||
agent = ContinuationAgent(engine, "test-model")
|
||||
result = agent.run("Hi")
|
||||
assert result.content == "Done"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for structured error_detail in executor traces."""
|
||||
|
||||
from openjarvis.agents.errors import EscalateError, FatalError, RetryableError
|
||||
from openjarvis.agents.executor import AgentExecutor
|
||||
from openjarvis.core.events import EventBus
|
||||
@@ -6,6 +7,7 @@ from openjarvis.core.events import EventBus
|
||||
|
||||
def test_build_error_detail_fatal(tmp_path):
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
mgr = AgentManager(db_path=str(tmp_path / "agents.db"))
|
||||
exe = AgentExecutor(manager=mgr, event_bus=EventBus())
|
||||
error = FatalError("401 unauthorized")
|
||||
@@ -17,6 +19,7 @@ def test_build_error_detail_fatal(tmp_path):
|
||||
|
||||
def test_build_error_detail_retryable(tmp_path):
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
mgr = AgentManager(db_path=str(tmp_path / "agents.db"))
|
||||
exe = AgentExecutor(manager=mgr, event_bus=EventBus())
|
||||
error = RetryableError("connection timed out")
|
||||
@@ -27,6 +30,7 @@ def test_build_error_detail_retryable(tmp_path):
|
||||
|
||||
def test_build_error_detail_escalate(tmp_path):
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
mgr = AgentManager(db_path=str(tmp_path / "agents.db"))
|
||||
exe = AgentExecutor(manager=mgr, event_bus=EventBus())
|
||||
error = EscalateError("agent needs help")
|
||||
|
||||
@@ -50,11 +50,14 @@ def test_scheduler_tracks_tick_count_for_learning(tmp_path):
|
||||
executor = AgentExecutor(mgr, bus)
|
||||
scheduler = AgentScheduler(mgr, executor, event_bus=bus)
|
||||
|
||||
agent = mgr.create_agent("tick-counter", config={
|
||||
"learning_enabled": True,
|
||||
"learning_schedule": "every_3_ticks",
|
||||
"schedule_type": "manual",
|
||||
})
|
||||
agent = mgr.create_agent(
|
||||
"tick-counter",
|
||||
config={
|
||||
"learning_enabled": True,
|
||||
"learning_schedule": "every_3_ticks",
|
||||
"schedule_type": "manual",
|
||||
},
|
||||
)
|
||||
|
||||
# Simulate 3 ticks completing
|
||||
for _ in range(3):
|
||||
@@ -62,8 +65,7 @@ def test_scheduler_tracks_tick_count_for_learning(tmp_path):
|
||||
|
||||
# Should have triggered learning
|
||||
learning_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_LEARNING_STARTED
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_LEARNING_STARTED
|
||||
]
|
||||
assert len(learning_events) == 1
|
||||
assert learning_events[0].data["agent_id"] == agent["id"]
|
||||
@@ -85,17 +87,19 @@ def test_scheduler_no_learning_when_disabled(tmp_path):
|
||||
executor = AgentExecutor(mgr, bus)
|
||||
scheduler = AgentScheduler(mgr, executor, event_bus=bus)
|
||||
|
||||
agent = mgr.create_agent("no-learning", config={
|
||||
"learning_enabled": False,
|
||||
"learning_schedule": "every_3_ticks",
|
||||
})
|
||||
agent = mgr.create_agent(
|
||||
"no-learning",
|
||||
config={
|
||||
"learning_enabled": False,
|
||||
"learning_schedule": "every_3_ticks",
|
||||
},
|
||||
)
|
||||
|
||||
for _ in range(5):
|
||||
scheduler._on_tick_completed(agent["id"])
|
||||
|
||||
learning_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_LEARNING_STARTED
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_LEARNING_STARTED
|
||||
]
|
||||
assert len(learning_events) == 0
|
||||
mgr.close()
|
||||
|
||||
@@ -8,6 +8,7 @@ from openjarvis.core.events import EventBus, EventType
|
||||
class TestLoopGuard:
|
||||
def _make_guard(self, **kwargs):
|
||||
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
kwargs.setdefault("warn_before_block", False)
|
||||
config = LoopGuardConfig(**kwargs)
|
||||
bus = EventBus(record_history=True)
|
||||
@@ -55,8 +56,7 @@ class TestLoopGuard:
|
||||
guard.check_call("x", '{"a": 1}')
|
||||
guard.check_call("x", '{"a": 1}')
|
||||
events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.LOOP_GUARD_TRIGGERED
|
||||
e for e in bus.history if e.event_type == EventType.LOOP_GUARD_TRIGGERED
|
||||
]
|
||||
assert len(events) == 1
|
||||
|
||||
@@ -70,6 +70,7 @@ class TestLoopGuard:
|
||||
|
||||
def test_context_compression_no_overflow(self):
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
guard, _ = self._make_guard(max_context_messages=100)
|
||||
messages = [Message(role=Role.USER, content=f"msg {i}") for i in range(10)]
|
||||
result = guard.compress_context(messages)
|
||||
@@ -77,42 +78,43 @@ class TestLoopGuard:
|
||||
|
||||
def test_context_compression_with_overflow(self):
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
guard, _ = self._make_guard(max_context_messages=10)
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="sys"),
|
||||
] + [
|
||||
Message(role=Role.USER, content=f"msg {i}")
|
||||
for i in range(50)
|
||||
] + [
|
||||
Message(role=Role.TOOL, content=f"result {i}", tool_call_id=f"t{i}")
|
||||
for i in range(50)
|
||||
]
|
||||
messages = (
|
||||
[
|
||||
Message(role=Role.SYSTEM, content="sys"),
|
||||
]
|
||||
+ [Message(role=Role.USER, content=f"msg {i}") for i in range(50)]
|
||||
+ [
|
||||
Message(role=Role.TOOL, content=f"result {i}", tool_call_id=f"t{i}")
|
||||
for i in range(50)
|
||||
]
|
||||
)
|
||||
result = guard.compress_context(messages)
|
||||
assert len(result) <= 10
|
||||
|
||||
def test_context_compression_stage4_uses_current_state(self):
|
||||
"""Stage 4 should derive from compressed state."""
|
||||
from openjarvis.core.types import Message, Role
|
||||
|
||||
guard, _ = self._make_guard(max_context_messages=5)
|
||||
messages = [
|
||||
Message(role=Role.SYSTEM, content="sys"),
|
||||
] + [
|
||||
Message(role=Role.USER, content=f"msg {i}")
|
||||
for i in range(100)
|
||||
] + [
|
||||
Message(
|
||||
role=Role.TOOL,
|
||||
content=f"result {i}",
|
||||
tool_call_id=f"t{i}",
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
messages = (
|
||||
[
|
||||
Message(role=Role.SYSTEM, content="sys"),
|
||||
]
|
||||
+ [Message(role=Role.USER, content=f"msg {i}") for i in range(100)]
|
||||
+ [
|
||||
Message(
|
||||
role=Role.TOOL,
|
||||
content=f"result {i}",
|
||||
tool_call_id=f"t{i}",
|
||||
)
|
||||
for i in range(100)
|
||||
]
|
||||
)
|
||||
result = guard.compress_context(messages)
|
||||
assert len(result) == 5
|
||||
system_count = sum(
|
||||
1 for m in result
|
||||
if getattr(m, 'role', None) == 'system'
|
||||
)
|
||||
system_count = sum(1 for m in result if getattr(m, "role", None) == "system")
|
||||
assert system_count == 1
|
||||
|
||||
def test_check_response_returns_unblocked(self):
|
||||
@@ -122,6 +124,7 @@ class TestLoopGuard:
|
||||
|
||||
def test_disabled_loop_guard(self):
|
||||
from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig
|
||||
|
||||
config = LoopGuardConfig(enabled=False)
|
||||
guard = LoopGuard(config)
|
||||
# Even though we'd normally block, disabled guard shouldn't
|
||||
|
||||
@@ -7,7 +7,9 @@ from openjarvis.agents.loop_guard import LoopGuard, LoopGuardConfig, LoopVerdict
|
||||
|
||||
def test_warn_before_block_first_cycle_warns():
|
||||
config = LoopGuardConfig(
|
||||
enabled=True, max_identical_calls=2, warn_before_block=True,
|
||||
enabled=True,
|
||||
max_identical_calls=2,
|
||||
warn_before_block=True,
|
||||
)
|
||||
guard = LoopGuard(config)
|
||||
# Simulate the Rust backend blocking on the second identical call
|
||||
@@ -25,7 +27,9 @@ def test_warn_before_block_first_cycle_warns():
|
||||
|
||||
def test_warn_before_block_second_cycle_blocks():
|
||||
config = LoopGuardConfig(
|
||||
enabled=True, max_identical_calls=2, warn_before_block=True,
|
||||
enabled=True,
|
||||
max_identical_calls=2,
|
||||
warn_before_block=True,
|
||||
)
|
||||
guard = LoopGuard(config)
|
||||
mock_rust = MagicMock()
|
||||
@@ -47,7 +51,9 @@ def test_warn_before_block_second_cycle_blocks():
|
||||
|
||||
def test_default_behavior_unchanged():
|
||||
config = LoopGuardConfig(
|
||||
enabled=True, max_identical_calls=2, warn_before_block=False,
|
||||
enabled=True,
|
||||
max_identical_calls=2,
|
||||
warn_before_block=False,
|
||||
)
|
||||
guard = LoopGuard(config)
|
||||
mock_rust = MagicMock()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for AgentManager.recover_agent() always resetting status."""
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.agents.manager import AgentManager
|
||||
|
||||
@@ -23,6 +23,7 @@ class TestMonitorOperativeAgent:
|
||||
# Import triggers registration; re-register after autouse fixture
|
||||
# clears the registry (same pattern as test_monitor.py)
|
||||
import openjarvis.agents.monitor_operative # noqa: F401
|
||||
|
||||
if not AgentRegistry.contains("monitor_operative"):
|
||||
AgentRegistry.register_value("monitor_operative", MonitorOperativeAgent)
|
||||
assert AgentRegistry.contains("monitor_operative")
|
||||
@@ -46,7 +47,8 @@ class TestMonitorOperativeAgent:
|
||||
def test_custom_strategies(self) -> None:
|
||||
engine = _make_engine()
|
||||
agent = MonitorOperativeAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
memory_extraction="scratchpad",
|
||||
observation_compression="none",
|
||||
retrieval_strategy="keyword",
|
||||
|
||||
@@ -38,6 +38,7 @@ class _CodeInterpreterStub(BaseTool):
|
||||
# Simple simulation: if it contains print(), capture the content
|
||||
if "print(" in code:
|
||||
import re
|
||||
|
||||
match = re.search(r"print\((.+?)\)", code)
|
||||
if match:
|
||||
try:
|
||||
@@ -134,13 +135,12 @@ class TestNativeOpenHandsAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
"Let me calculate:\n```python\nprint(2+2)\n```"
|
||||
),
|
||||
_engine_response("Let me calculate:\n```python\nprint(2+2)\n```"),
|
||||
_engine_response("The result is 4."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
@@ -159,7 +159,8 @@ class TestNativeOpenHandsAgent:
|
||||
_engine_response("First was 2, second was 9."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
)
|
||||
result = agent.run("Two calculations")
|
||||
@@ -175,7 +176,8 @@ class TestNativeOpenHandsAgent:
|
||||
"More code:\n```python\nprint('hello')\n```"
|
||||
)
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
max_turns=3,
|
||||
)
|
||||
@@ -205,7 +207,8 @@ class TestNativeOpenHandsAgent:
|
||||
_engine_response("Done."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
bus=bus,
|
||||
)
|
||||
@@ -238,13 +241,12 @@ class TestNativeOpenHandsAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Action: calculator\nAction Input: {"expression": "7*6"}'
|
||||
),
|
||||
_engine_response('Action: calculator\nAction Input: {"expression": "7*6"}'),
|
||||
_engine_response("The answer is 42."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 7 times 6?")
|
||||
@@ -285,7 +287,8 @@ class TestNativeOpenHandsAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = _engine_response("Ok")
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub(), _CalculatorStub()],
|
||||
)
|
||||
agent.run("Hello")
|
||||
@@ -301,7 +304,8 @@ class TestNativeOpenHandsAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = _engine_response("Ok")
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub(), _CalculatorStub()],
|
||||
)
|
||||
agent.run("Hello")
|
||||
@@ -321,7 +325,8 @@ class TestNativeOpenHandsAgent:
|
||||
"Still working:\n```python\nx = 1\n```"
|
||||
)
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
max_turns=2,
|
||||
)
|
||||
@@ -339,7 +344,8 @@ class TestNativeOpenHandsAgent:
|
||||
_engine_response("Got 42."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CodeInterpreterStub()],
|
||||
)
|
||||
agent.run("Print 42")
|
||||
@@ -358,8 +364,7 @@ class TestNativeOpenHandsAgent:
|
||||
agent = NativeOpenHandsAgent(engine, "test-model", bus=bus)
|
||||
agent.run("test input")
|
||||
start_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_TURN_START
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_TURN_START
|
||||
]
|
||||
assert len(start_events) == 1
|
||||
assert start_events[0].data["agent"] == "native_openhands"
|
||||
@@ -389,13 +394,12 @@ class TestNativeOpenHandsAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'<tool_call>calculator\n$expression=7*6</calculator>'
|
||||
),
|
||||
_engine_response("<tool_call>calculator\n$expression=7*6</calculator>"),
|
||||
_engine_response("The answer is 42."),
|
||||
]
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 7 times 6?")
|
||||
@@ -408,9 +412,7 @@ class TestNativeOpenHandsAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Action: calculator\nAction Input: {"expression": "1+1"}'
|
||||
),
|
||||
_engine_response('Action: calculator\nAction Input: {"expression": "1+1"}'),
|
||||
_engine_response("Done."),
|
||||
]
|
||||
# Make calculator return very long output
|
||||
@@ -512,7 +514,8 @@ class TestUrlExpansion:
|
||||
import httpx
|
||||
|
||||
monkeypatch.setattr(
|
||||
httpx, "get",
|
||||
httpx,
|
||||
"get",
|
||||
MagicMock(side_effect=Exception("Connection error")),
|
||||
)
|
||||
text, expanded = NativeOpenHandsAgent._expand_urls(
|
||||
|
||||
@@ -111,8 +111,8 @@ class TestNativeReActParsing:
|
||||
def test_parse_thought_action(self):
|
||||
parse = self._parser()
|
||||
text = (
|
||||
'Thought: I need to calculate 2+2.\n'
|
||||
'Action: calculator\n'
|
||||
"Thought: I need to calculate 2+2.\n"
|
||||
"Action: calculator\n"
|
||||
'Action Input: {"expression": "2+2"}'
|
||||
)
|
||||
result = parse(text)
|
||||
@@ -157,8 +157,8 @@ class TestNativeReActParsing:
|
||||
def test_parse_case_insensitive_thought_action(self):
|
||||
parse = self._parser()
|
||||
text = (
|
||||
'thought: I need to calculate 2+2.\n'
|
||||
'action: calculator\n'
|
||||
"thought: I need to calculate 2+2.\n"
|
||||
"action: calculator\n"
|
||||
'action input: {"expression": "2+2"}'
|
||||
)
|
||||
result = parse(text)
|
||||
@@ -199,18 +199,18 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: I need to calculate.\n'
|
||||
'Action: calculator\n'
|
||||
"Thought: I need to calculate.\n"
|
||||
"Action: calculator\n"
|
||||
'Action Input: {"expression": "2+2"}'
|
||||
),
|
||||
_engine_response(
|
||||
"Thought: The result is 4.\nFinal Answer: 4"
|
||||
),
|
||||
_engine_response("Thought: The result is 4.\nFinal Answer: 4"),
|
||||
]
|
||||
bus = EventBus(record_history=True)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
tools=[_CalculatorStub()], bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
bus=bus,
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
assert result.content == "4"
|
||||
@@ -225,7 +225,7 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Calculate.\nAction: calculator\n'
|
||||
"Thought: Calculate.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "3*7"}'
|
||||
),
|
||||
_engine_response("Thought: Done.\nFinal Answer: 21"),
|
||||
@@ -241,21 +241,22 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Step 1.\nAction: calculator\n'
|
||||
"Thought: Step 1.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "1+1"}'
|
||||
),
|
||||
_engine_response(
|
||||
'Thought: Step 2.\nAction: calculator\n'
|
||||
"Thought: Step 2.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "2+2"}'
|
||||
),
|
||||
_engine_response(
|
||||
'Thought: Step 3.\nAction: think\n'
|
||||
"Thought: Step 3.\nAction: think\n"
|
||||
'Action Input: {"thought": "combining results"}'
|
||||
),
|
||||
_engine_response("Thought: All done.\nFinal Answer: Complete."),
|
||||
]
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub(), _ThinkStub()],
|
||||
)
|
||||
result = agent.run("Multi step")
|
||||
@@ -268,11 +269,12 @@ class TestNativeReActAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = _engine_response(
|
||||
'Thought: Keep going.\nAction: calculator\n'
|
||||
"Thought: Keep going.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "1+1"}'
|
||||
)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
max_turns=3,
|
||||
)
|
||||
@@ -287,12 +289,10 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Use a tool.\nAction: nonexistent\n'
|
||||
'Action Input: {}'
|
||||
"Thought: Use a tool.\nAction: nonexistent\nAction Input: {}"
|
||||
),
|
||||
_engine_response(
|
||||
"Thought: Error occurred.\n"
|
||||
"Final Answer: Could not run tool."
|
||||
"Thought: Error occurred.\nFinal Answer: Could not run tool."
|
||||
),
|
||||
]
|
||||
agent = NativeReActAgent(engine, "test-model", tools=[_CalculatorStub()])
|
||||
@@ -322,14 +322,16 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Calc.\nAction: calculator\n'
|
||||
"Thought: Calc.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "1+1"}'
|
||||
),
|
||||
_engine_response("Thought: Done.\nFinal Answer: 2"),
|
||||
]
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
tools=[_CalculatorStub()], bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
bus=bus,
|
||||
)
|
||||
agent.run("Calc")
|
||||
event_types = [e.event_type for e in bus.history]
|
||||
@@ -365,7 +367,7 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Let me reason.\nAction: think\n'
|
||||
"Thought: Let me reason.\nAction: think\n"
|
||||
'Action Input: {"thought": "The user wants a greeting"}'
|
||||
),
|
||||
_engine_response("Thought: Now I know.\nFinal Answer: Greetings!"),
|
||||
@@ -403,7 +405,7 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'Thought: Calc.\nAction: calculator\n'
|
||||
"Thought: Calc.\nAction: calculator\n"
|
||||
'Action Input: {"expression": "5+5"}'
|
||||
),
|
||||
_engine_response("Thought: Got it.\nFinal Answer: 10"),
|
||||
@@ -427,7 +429,8 @@ class TestNativeReActAgent:
|
||||
"Thought: Done.\nFinal Answer: ok"
|
||||
)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub(), _ThinkStub()],
|
||||
)
|
||||
agent.run("Hello")
|
||||
@@ -455,11 +458,11 @@ class TestNativeReActAgent:
|
||||
engine = MagicMock()
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.return_value = _engine_response(
|
||||
'Thought: Go.\nAction: calculator\n'
|
||||
'Action Input: {"expression": "1"}'
|
||||
'Thought: Go.\nAction: calculator\nAction Input: {"expression": "1"}'
|
||||
)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
max_turns=1,
|
||||
)
|
||||
@@ -478,14 +481,12 @@ class TestNativeReActAgent:
|
||||
agent = NativeReActAgent(engine, "test-model", bus=bus)
|
||||
agent.run("test input")
|
||||
start_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_TURN_START
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_TURN_START
|
||||
]
|
||||
assert len(start_events) == 1
|
||||
assert start_events[0].data["agent"] == "native_react"
|
||||
assert start_events[0].data["input"] == "test input"
|
||||
|
||||
|
||||
def test_system_prompt_enriched_descriptions(self):
|
||||
"""System prompt should include parameter schemas, not just names."""
|
||||
engine = MagicMock()
|
||||
@@ -494,7 +495,8 @@ class TestNativeReActAgent:
|
||||
"Thought: Done.\nFinal Answer: ok"
|
||||
)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub(), _ThinkStub()],
|
||||
)
|
||||
agent.run("Hello")
|
||||
@@ -514,16 +516,15 @@ class TestNativeReActAgent:
|
||||
engine.engine_id = "mock"
|
||||
engine.generate.side_effect = [
|
||||
_engine_response(
|
||||
'thought: I need to calculate.\n'
|
||||
'action: calculator\n'
|
||||
"thought: I need to calculate.\n"
|
||||
"action: calculator\n"
|
||||
'action input: {"expression": "2+2"}'
|
||||
),
|
||||
_engine_response(
|
||||
"thought: The result is 4.\nfinal answer: 4"
|
||||
),
|
||||
_engine_response("thought: The result is 4.\nfinal answer: 4"),
|
||||
]
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
|
||||
@@ -114,11 +114,13 @@ def _make_engine_multi_tool() -> MagicMock:
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1", "name": "calculator",
|
||||
"id": "call_1",
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"2+2"}',
|
||||
},
|
||||
{
|
||||
"id": "call_2", "name": "think",
|
||||
"id": "call_2",
|
||||
"name": "think",
|
||||
"arguments": '{"thought":"thinking..."}',
|
||||
},
|
||||
],
|
||||
@@ -158,7 +160,9 @@ class TestOrchestratorAgent:
|
||||
def test_single_tool_call(self):
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
assert result.content == "The answer is 4."
|
||||
@@ -170,7 +174,8 @@ class TestOrchestratorAgent:
|
||||
def test_multiple_tool_calls_same_turn(self):
|
||||
engine = _make_engine_multi_tool()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub(), _ThinkStub()],
|
||||
)
|
||||
result = agent.run("Think and calculate.")
|
||||
@@ -193,7 +198,9 @@ class TestOrchestratorAgent:
|
||||
def test_tools_passed_to_engine(self):
|
||||
engine = _make_engine_no_tools()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
agent.run("Hello")
|
||||
call_kwargs = engine.generate.call_args[1]
|
||||
@@ -221,7 +228,8 @@ class TestOrchestratorAgent:
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
max_turns=3,
|
||||
)
|
||||
@@ -236,7 +244,9 @@ class TestOrchestratorAgent:
|
||||
final_content="Handled.",
|
||||
)
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("Use unknown tool")
|
||||
assert result.content == "Handled."
|
||||
@@ -281,7 +291,10 @@ class TestOrchestratorAgent:
|
||||
bus = EventBus(record_history=True)
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()], bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
bus=bus,
|
||||
)
|
||||
agent.run("Calc 2+2")
|
||||
event_types = [e.event_type for e in bus.history]
|
||||
@@ -292,7 +305,9 @@ class TestOrchestratorAgent:
|
||||
"""After tool call, messages include assistant + tool messages."""
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
agent.run("What is 2+2?")
|
||||
# Second call should include accumulated messages
|
||||
@@ -305,7 +320,9 @@ class TestOrchestratorAgent:
|
||||
def test_tool_message_has_tool_call_id(self):
|
||||
engine = _make_engine_with_tool_call(tool_call_id="abc123")
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
agent.run("What is 2+2?")
|
||||
second_call = engine.generate.call_args_list[1]
|
||||
@@ -317,7 +334,9 @@ class TestOrchestratorAgent:
|
||||
def test_no_bus_works(self):
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
assert result.content == "The answer is 4."
|
||||
@@ -335,10 +354,13 @@ class TestOrchestratorAgent:
|
||||
engine.generate.side_effect = [
|
||||
{
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "c1", "name": "calculator",
|
||||
"arguments": '{"expression":"2+2"}',
|
||||
}],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"2+2"}',
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 3,
|
||||
@@ -349,10 +371,13 @@ class TestOrchestratorAgent:
|
||||
},
|
||||
{
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "c2", "name": "calculator",
|
||||
"arguments": '{"expression":"4*3"}',
|
||||
}],
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c2",
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"4*3"}',
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 15,
|
||||
"completion_tokens": 3,
|
||||
@@ -373,7 +398,9 @@ class TestOrchestratorAgent:
|
||||
},
|
||||
]
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("Calculate")
|
||||
assert result.turns == 3
|
||||
@@ -384,7 +411,9 @@ class TestOrchestratorAgent:
|
||||
def test_tool_result_latency_tracked(self):
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
assert result.tool_results[0].latency_seconds >= 0
|
||||
@@ -403,7 +432,8 @@ class TestOrchestratorAgent:
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
max_turns=1,
|
||||
)
|
||||
@@ -433,7 +463,8 @@ class TestOrchestratorAgent:
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
max_turns=2,
|
||||
)
|
||||
@@ -456,7 +487,9 @@ class TestOrchestratorStructuredMode:
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", mode="structured",
|
||||
engine,
|
||||
"test-model",
|
||||
mode="structured",
|
||||
)
|
||||
result = agent.run("What is the capital of France?")
|
||||
assert result.content == "Paris"
|
||||
@@ -471,7 +504,7 @@ class TestOrchestratorStructuredMode:
|
||||
{
|
||||
"content": (
|
||||
"THOUGHT: Need to calculate.\n"
|
||||
'TOOL: calculator\n'
|
||||
"TOOL: calculator\n"
|
||||
'INPUT: {"expression":"2+2"}'
|
||||
),
|
||||
"usage": {
|
||||
@@ -483,10 +516,7 @@ class TestOrchestratorStructuredMode:
|
||||
"finish_reason": "stop",
|
||||
},
|
||||
{
|
||||
"content": (
|
||||
"THOUGHT: Got 4.\n"
|
||||
"FINAL_ANSWER: The answer is 4."
|
||||
),
|
||||
"content": ("THOUGHT: Got 4.\nFINAL_ANSWER: The answer is 4."),
|
||||
"usage": {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 10,
|
||||
@@ -497,7 +527,8 @@ class TestOrchestratorStructuredMode:
|
||||
},
|
||||
]
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
mode="structured",
|
||||
)
|
||||
@@ -519,7 +550,8 @@ class TestOrchestratorStructuredMode:
|
||||
"finish_reason": "stop",
|
||||
}
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
mode="structured",
|
||||
)
|
||||
@@ -591,7 +623,10 @@ class TestOrchestratorParallelTools:
|
||||
]
|
||||
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_SlowTool()], parallel_tools=True,
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_SlowTool()],
|
||||
parallel_tools=True,
|
||||
)
|
||||
t0 = time.time()
|
||||
result = agent.run("Do things")
|
||||
@@ -610,7 +645,8 @@ class TestOrchestratorParallelTools:
|
||||
"""parallel_tools=False runs tools sequentially."""
|
||||
engine = _make_engine_multi_tool()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub(), _ThinkStub()],
|
||||
parallel_tools=False,
|
||||
)
|
||||
@@ -622,7 +658,9 @@ class TestOrchestratorParallelTools:
|
||||
"""Single tool call should not use parallel path even if parallel_tools=True."""
|
||||
engine = _make_engine_with_tool_call()
|
||||
agent = OrchestratorAgent(
|
||||
engine, "test-model", tools=[_CalculatorStub()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[_CalculatorStub()],
|
||||
parallel_tools=True,
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
|
||||
@@ -197,9 +197,7 @@ class TestRLMSubLMCalls:
|
||||
engine.generate.side_effect = [
|
||||
{
|
||||
"content": (
|
||||
"```python\n"
|
||||
"result = llm_query('What is 2+2?')\n"
|
||||
"FINAL(result)\n```"
|
||||
"```python\nresult = llm_query('What is 2+2?')\nFINAL(result)\n```"
|
||||
),
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
@@ -236,10 +234,7 @@ class TestRLMMultiTurn:
|
||||
engine.generate.side_effect = [
|
||||
# Turn 1: code that sets a variable
|
||||
{
|
||||
"content": (
|
||||
"```python\n"
|
||||
"x = 10\nprint(f'x = {x}')\n```"
|
||||
),
|
||||
"content": ("```python\nx = 10\nprint(f'x = {x}')\n```"),
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 10,
|
||||
@@ -250,9 +245,7 @@ class TestRLMMultiTurn:
|
||||
},
|
||||
# Turn 2: code that uses the variable and terminates
|
||||
{
|
||||
"content": (
|
||||
"```python\ny = x * 2\nFINAL(y)\n```"
|
||||
),
|
||||
"content": ("```python\ny = x * 2\nFINAL(y)\n```"),
|
||||
"usage": {
|
||||
"prompt_tokens": 20,
|
||||
"completion_tokens": 10,
|
||||
@@ -335,9 +328,7 @@ class TestRLMSubLMWithTools:
|
||||
# Root LM: code that calls llm_query
|
||||
{
|
||||
"content": (
|
||||
"```python\n"
|
||||
"result = llm_query('Calculate 2+2')\n"
|
||||
"FINAL(result)\n```"
|
||||
"```python\nresult = llm_query('Calculate 2+2')\nFINAL(result)\n```"
|
||||
),
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
|
||||
@@ -91,10 +91,7 @@ class TestSimpleAgent:
|
||||
agent = SimpleAgent(engine, "test-model", bus=bus)
|
||||
agent.run("test input")
|
||||
evts = bus.history
|
||||
start = [
|
||||
e for e in evts
|
||||
if e.event_type == EventType.AGENT_TURN_START
|
||||
][0]
|
||||
start = [e for e in evts if e.event_type == EventType.AGENT_TURN_START][0]
|
||||
assert start.data["agent"] == "simple"
|
||||
assert start.data["input"] == "test input"
|
||||
|
||||
|
||||
+35
-20
@@ -18,10 +18,13 @@ def test_activity_tracking_updates_last_activity_at(tmp_path):
|
||||
agent = mgr.create_agent("stall-test")
|
||||
|
||||
def fake_invoke(agent_dict):
|
||||
bus.publish(EventType.TOOL_CALL_START, {
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
})
|
||||
bus.publish(
|
||||
EventType.TOOL_CALL_START,
|
||||
{
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
},
|
||||
)
|
||||
return AgentResult(content="done", metadata={})
|
||||
|
||||
with patch.object(executor, "_invoke_agent", side_effect=fake_invoke):
|
||||
@@ -44,10 +47,13 @@ def test_activity_tracking_filters_by_agent_id(tmp_path):
|
||||
|
||||
def fake_invoke(agent_dict):
|
||||
# Emit event for agent_b while agent_a is executing
|
||||
bus.publish(EventType.TOOL_CALL_START, {
|
||||
"agent": agent_b["id"],
|
||||
"tool": "web_search",
|
||||
})
|
||||
bus.publish(
|
||||
EventType.TOOL_CALL_START,
|
||||
{
|
||||
"agent": agent_b["id"],
|
||||
"tool": "web_search",
|
||||
},
|
||||
)
|
||||
return AgentResult(content="done", metadata={})
|
||||
|
||||
with patch.object(executor, "_invoke_agent", side_effect=fake_invoke):
|
||||
@@ -67,10 +73,13 @@ def test_reconcile_detects_stalled_agent(tmp_path):
|
||||
|
||||
scheduler = AgentScheduler(mgr, executor, event_bus=bus)
|
||||
|
||||
agent = mgr.create_agent("stall-me", config={
|
||||
"timeout_seconds": 10,
|
||||
"max_stall_retries": 3,
|
||||
})
|
||||
agent = mgr.create_agent(
|
||||
"stall-me",
|
||||
config={
|
||||
"timeout_seconds": 10,
|
||||
"max_stall_retries": 3,
|
||||
},
|
||||
)
|
||||
mgr.update_agent(agent["id"], status="running", last_activity_at=time.time() - 30)
|
||||
|
||||
scheduler._reconcile()
|
||||
@@ -79,8 +88,7 @@ def test_reconcile_detects_stalled_agent(tmp_path):
|
||||
assert updated["stall_retries"] == 1
|
||||
|
||||
stall_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.AGENT_STALL_DETECTED
|
||||
e for e in bus.history if e.event_type == EventType.AGENT_STALL_DETECTED
|
||||
]
|
||||
assert len(stall_events) == 1
|
||||
mgr.close()
|
||||
@@ -114,12 +122,19 @@ def test_reconcile_retries_exhausted_sets_error(tmp_path):
|
||||
|
||||
scheduler = AgentScheduler(mgr, executor, event_bus=bus)
|
||||
|
||||
agent = mgr.create_agent("exhausted", config={
|
||||
"timeout_seconds": 10,
|
||||
"max_stall_retries": 2,
|
||||
})
|
||||
mgr.update_agent(agent["id"], status="running",
|
||||
last_activity_at=time.time() - 30, stall_retries=2)
|
||||
agent = mgr.create_agent(
|
||||
"exhausted",
|
||||
config={
|
||||
"timeout_seconds": 10,
|
||||
"max_stall_retries": 2,
|
||||
},
|
||||
)
|
||||
mgr.update_agent(
|
||||
agent["id"],
|
||||
status="running",
|
||||
last_activity_at=time.time() - 30,
|
||||
stall_retries=2,
|
||||
)
|
||||
|
||||
scheduler._reconcile()
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for suggest_action helper."""
|
||||
|
||||
from openjarvis.agents.errors import FatalError, RetryableError, suggest_action
|
||||
|
||||
|
||||
|
||||
@@ -21,17 +21,23 @@ def test_executor_records_trace(tmp_path):
|
||||
agent = mgr.create_agent("trace-test")
|
||||
|
||||
def fake_invoke(agent_dict):
|
||||
bus.publish(EventType.TOOL_CALL_START, {
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
"args": {"query": "test"},
|
||||
})
|
||||
bus.publish(EventType.TOOL_CALL_END, {
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
"result": "search results...",
|
||||
"duration": 0.5,
|
||||
})
|
||||
bus.publish(
|
||||
EventType.TOOL_CALL_START,
|
||||
{
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
"args": {"query": "test"},
|
||||
},
|
||||
)
|
||||
bus.publish(
|
||||
EventType.TOOL_CALL_END,
|
||||
{
|
||||
"agent": agent_dict["id"],
|
||||
"tool": "web_search",
|
||||
"result": "search results...",
|
||||
"duration": 0.5,
|
||||
},
|
||||
)
|
||||
return AgentResult(content="found it", metadata={"tokens_used": 100})
|
||||
|
||||
with patch.object(executor, "_invoke_agent", side_effect=fake_invoke):
|
||||
@@ -62,7 +68,9 @@ def test_executor_records_error_trace(tmp_path):
|
||||
agent = mgr.create_agent("error-trace")
|
||||
|
||||
with patch.object(
|
||||
executor, "_invoke_agent", side_effect=FatalError("boom"),
|
||||
executor,
|
||||
"_invoke_agent",
|
||||
side_effect=FatalError("boom"),
|
||||
):
|
||||
executor.execute_tick(agent["id"])
|
||||
|
||||
|
||||
@@ -78,7 +78,10 @@ class TestEnergyBenchmark:
|
||||
|
||||
b = EnergyBenchmark()
|
||||
result = b.run(
|
||||
engine, "test-model", num_samples=3, warmup_samples=0,
|
||||
engine,
|
||||
"test-model",
|
||||
num_samples=3,
|
||||
warmup_samples=0,
|
||||
energy_monitor=monitor,
|
||||
)
|
||||
|
||||
|
||||
@@ -56,8 +56,12 @@ class TestLatencyBenchmark:
|
||||
b = LatencyBenchmark()
|
||||
result = b.run(engine, "test-model", num_samples=3)
|
||||
expected_keys = {
|
||||
"mean_latency", "p50_latency", "p95_latency",
|
||||
"min_latency", "max_latency", "std_latency",
|
||||
"mean_latency",
|
||||
"p50_latency",
|
||||
"p95_latency",
|
||||
"min_latency",
|
||||
"max_latency",
|
||||
"std_latency",
|
||||
}
|
||||
assert set(result.metrics.keys()) == expected_keys
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ class TestChannelConfig:
|
||||
class TestTomlLoading:
|
||||
def _write_toml(self, content: str) -> Path:
|
||||
f = tempfile.NamedTemporaryFile(
|
||||
mode="w", suffix=".toml", delete=False,
|
||||
mode="w",
|
||||
suffix=".toml",
|
||||
delete=False,
|
||||
)
|
||||
f.write(content)
|
||||
f.flush()
|
||||
|
||||
@@ -25,8 +25,12 @@ class TestChannelRegistry:
|
||||
pass
|
||||
|
||||
def send(
|
||||
self, channel, content,
|
||||
*, conversation_id="", metadata=None,
|
||||
self,
|
||||
channel,
|
||||
content,
|
||||
*,
|
||||
conversation_id="",
|
||||
metadata=None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
@@ -56,8 +60,12 @@ class TestChannelRegistry:
|
||||
pass
|
||||
|
||||
def send(
|
||||
self, channel, content,
|
||||
*, conversation_id="", metadata=None,
|
||||
self,
|
||||
channel,
|
||||
content,
|
||||
*,
|
||||
conversation_id="",
|
||||
metadata=None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
@@ -99,8 +107,12 @@ class TestChannelRegistry:
|
||||
pass
|
||||
|
||||
def send(
|
||||
self, channel, content,
|
||||
*, conversation_id="", metadata=None,
|
||||
self,
|
||||
channel,
|
||||
content,
|
||||
*,
|
||||
conversation_id="",
|
||||
metadata=None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@@ -153,10 +153,13 @@ class TestChannelImportError:
|
||||
|
||||
class TestLineChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"LINE_CHANNEL_ACCESS_TOKEN": "env-tok",
|
||||
"LINE_CHANNEL_SECRET": "env-sec",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"LINE_CHANNEL_ACCESS_TOKEN": "env-tok",
|
||||
"LINE_CHANNEL_SECRET": "env-sec",
|
||||
},
|
||||
):
|
||||
ch = LineChannel()
|
||||
assert ch._channel_access_token == "env-tok"
|
||||
assert ch._channel_secret == "env-sec"
|
||||
@@ -164,10 +167,13 @@ class TestLineChannel:
|
||||
|
||||
class TestViberChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"VIBER_AUTH_TOKEN": "env-tok",
|
||||
"VIBER_BOT_NAME": "TestBot",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"VIBER_AUTH_TOKEN": "env-tok",
|
||||
"VIBER_BOT_NAME": "TestBot",
|
||||
},
|
||||
):
|
||||
ch = ViberChannel()
|
||||
assert ch._auth_token == "env-tok"
|
||||
assert ch._name == "TestBot"
|
||||
@@ -175,21 +181,27 @@ class TestViberChannel:
|
||||
|
||||
class TestMessengerChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"MESSENGER_ACCESS_TOKEN": "env-tok",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"MESSENGER_ACCESS_TOKEN": "env-tok",
|
||||
},
|
||||
):
|
||||
ch = MessengerChannel()
|
||||
assert ch._access_token == "env-tok"
|
||||
|
||||
|
||||
class TestRedditChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"REDDIT_CLIENT_ID": "cid",
|
||||
"REDDIT_CLIENT_SECRET": "csec",
|
||||
"REDDIT_USERNAME": "user",
|
||||
"REDDIT_PASSWORD": "pass",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"REDDIT_CLIENT_ID": "cid",
|
||||
"REDDIT_CLIENT_SECRET": "csec",
|
||||
"REDDIT_USERNAME": "user",
|
||||
"REDDIT_PASSWORD": "pass",
|
||||
},
|
||||
):
|
||||
ch = RedditChannel()
|
||||
assert ch._client_id == "cid"
|
||||
assert ch._client_secret == "csec"
|
||||
@@ -199,10 +211,13 @@ class TestRedditChannel:
|
||||
|
||||
class TestMastodonChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"MASTODON_API_BASE_URL": "https://m.social",
|
||||
"MASTODON_ACCESS_TOKEN": "env-tok",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"MASTODON_API_BASE_URL": "https://m.social",
|
||||
"MASTODON_ACCESS_TOKEN": "env-tok",
|
||||
},
|
||||
):
|
||||
ch = MastodonChannel()
|
||||
assert ch._api_base_url == "https://m.social"
|
||||
assert ch._access_token == "env-tok"
|
||||
@@ -210,12 +225,15 @@ class TestMastodonChannel:
|
||||
|
||||
class TestXMPPChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"XMPP_JID": "bot@example.com",
|
||||
"XMPP_PASSWORD": "pass",
|
||||
"XMPP_SERVER": "xmpp.example.com",
|
||||
"XMPP_PORT": "5223",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"XMPP_JID": "bot@example.com",
|
||||
"XMPP_PASSWORD": "pass",
|
||||
"XMPP_SERVER": "xmpp.example.com",
|
||||
"XMPP_PORT": "5223",
|
||||
},
|
||||
):
|
||||
ch = XMPPChannel()
|
||||
assert ch._jid == "bot@example.com"
|
||||
assert ch._password == "pass"
|
||||
@@ -225,22 +243,28 @@ class TestXMPPChannel:
|
||||
|
||||
class TestRocketChatChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"ROCKETCHAT_URL": "https://rc.example.com",
|
||||
"ROCKETCHAT_USER": "bot",
|
||||
"ROCKETCHAT_PASSWORD": "pass",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"ROCKETCHAT_URL": "https://rc.example.com",
|
||||
"ROCKETCHAT_USER": "bot",
|
||||
"ROCKETCHAT_PASSWORD": "pass",
|
||||
},
|
||||
):
|
||||
ch = RocketChatChannel()
|
||||
assert ch._url == "https://rc.example.com"
|
||||
assert ch._user == "bot"
|
||||
assert ch._password == "pass"
|
||||
|
||||
def test_token_auth_env(self):
|
||||
with patch.dict("os.environ", {
|
||||
"ROCKETCHAT_URL": "https://rc.example.com",
|
||||
"ROCKETCHAT_AUTH_TOKEN": "tok",
|
||||
"ROCKETCHAT_USER_ID": "uid",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"ROCKETCHAT_URL": "https://rc.example.com",
|
||||
"ROCKETCHAT_AUTH_TOKEN": "tok",
|
||||
"ROCKETCHAT_USER_ID": "uid",
|
||||
},
|
||||
):
|
||||
ch = RocketChatChannel()
|
||||
assert ch._auth_token == "tok"
|
||||
assert ch._user_id == "uid"
|
||||
@@ -248,32 +272,41 @@ class TestRocketChatChannel:
|
||||
|
||||
class TestZulipChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"ZULIP_EMAIL": "bot@zulip.com",
|
||||
"ZULIP_API_KEY": "key",
|
||||
"ZULIP_SITE": "https://z.com",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"ZULIP_EMAIL": "bot@zulip.com",
|
||||
"ZULIP_API_KEY": "key",
|
||||
"ZULIP_SITE": "https://z.com",
|
||||
},
|
||||
):
|
||||
ch = ZulipChannel()
|
||||
assert ch._email == "bot@zulip.com"
|
||||
assert ch._api_key == "key"
|
||||
assert ch._site == "https://z.com"
|
||||
|
||||
def test_zuliprc_env(self):
|
||||
with patch.dict("os.environ", {
|
||||
"ZULIP_RC": "/path/to/zuliprc",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"ZULIP_RC": "/path/to/zuliprc",
|
||||
},
|
||||
):
|
||||
ch = ZulipChannel()
|
||||
assert ch._zuliprc == "/path/to/zuliprc"
|
||||
|
||||
|
||||
class TestTwitchChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"TWITCH_ACCESS_TOKEN": "env-tok",
|
||||
"TWITCH_CLIENT_ID": "env-cid",
|
||||
"TWITCH_NICK": "env-nick",
|
||||
"TWITCH_CHANNELS": "chan1,chan2",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"TWITCH_ACCESS_TOKEN": "env-tok",
|
||||
"TWITCH_CLIENT_ID": "env-cid",
|
||||
"TWITCH_NICK": "env-nick",
|
||||
"TWITCH_CHANNELS": "chan1,chan2",
|
||||
},
|
||||
):
|
||||
ch = TwitchChannel()
|
||||
assert ch._access_token == "env-tok"
|
||||
assert ch._client_id == "env-cid"
|
||||
@@ -283,10 +316,13 @@ class TestTwitchChannel:
|
||||
|
||||
class TestNostrChannel:
|
||||
def test_env_fallback(self):
|
||||
with patch.dict("os.environ", {
|
||||
"NOSTR_PRIVATE_KEY": "aa" * 32,
|
||||
"NOSTR_RELAYS": "wss://r1.example.com,wss://r2.example.com",
|
||||
}):
|
||||
with patch.dict(
|
||||
"os.environ",
|
||||
{
|
||||
"NOSTR_PRIVATE_KEY": "aa" * 32,
|
||||
"NOSTR_RELAYS": "wss://r1.example.com,wss://r2.example.com",
|
||||
},
|
||||
):
|
||||
ch = NostrChannel()
|
||||
assert ch._private_key == "aa" * 32
|
||||
assert len(ch._relays) == 2
|
||||
|
||||
@@ -26,7 +26,8 @@ class TestRegistration:
|
||||
|
||||
def test_channel_id(self):
|
||||
ch = EmailChannel(
|
||||
smtp_host="smtp.example.com", username="user@example.com",
|
||||
smtp_host="smtp.example.com",
|
||||
username="user@example.com",
|
||||
)
|
||||
assert ch.channel_id == "email"
|
||||
|
||||
@@ -62,10 +63,13 @@ class TestInit:
|
||||
assert ch._use_tls is False
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {
|
||||
"EMAIL_USERNAME": "env@example.com",
|
||||
"EMAIL_PASSWORD": "env-pass",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"EMAIL_USERNAME": "env@example.com",
|
||||
"EMAIL_PASSWORD": "env-pass",
|
||||
},
|
||||
):
|
||||
ch = EmailChannel()
|
||||
assert ch._username == "env@example.com"
|
||||
assert ch._password == "env-pass"
|
||||
@@ -206,7 +210,8 @@ class TestOnMessage:
|
||||
class TestDisconnect:
|
||||
def test_disconnect(self):
|
||||
ch = EmailChannel(
|
||||
smtp_host="smtp.example.com", username="user@example.com",
|
||||
smtp_host="smtp.example.com",
|
||||
username="user@example.com",
|
||||
)
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
ch.disconnect()
|
||||
|
||||
@@ -25,7 +25,9 @@ class TestRegistration:
|
||||
assert ChannelRegistry.contains("google_chat")
|
||||
|
||||
def test_channel_id(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
assert ch.channel_id == "google_chat"
|
||||
|
||||
|
||||
@@ -36,27 +38,48 @@ class TestInit:
|
||||
assert ch._status == ChannelStatus.DISCONNECTED
|
||||
|
||||
def test_constructor_url(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
assert ch._webhook_url == "https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
assert (
|
||||
ch._webhook_url
|
||||
== "https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {
|
||||
"GOOGLE_CHAT_WEBHOOK_URL": "https://chat.googleapis.com/v1/spaces/env/messages?key=env",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOOGLE_CHAT_WEBHOOK_URL": "https://chat.googleapis.com/v1/spaces/env/messages?key=env",
|
||||
},
|
||||
):
|
||||
ch = GoogleChatChannel()
|
||||
assert ch._webhook_url == "https://chat.googleapis.com/v1/spaces/env/messages?key=env"
|
||||
assert (
|
||||
ch._webhook_url
|
||||
== "https://chat.googleapis.com/v1/spaces/env/messages?key=env"
|
||||
)
|
||||
|
||||
def test_constructor_overrides_env(self):
|
||||
with patch.dict(os.environ, {
|
||||
"GOOGLE_CHAT_WEBHOOK_URL": "https://chat.googleapis.com/v1/spaces/env/messages?key=env",
|
||||
}):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/explicit/messages?key=explicit")
|
||||
assert ch._webhook_url == "https://chat.googleapis.com/v1/spaces/explicit/messages?key=explicit"
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"GOOGLE_CHAT_WEBHOOK_URL": "https://chat.googleapis.com/v1/spaces/env/messages?key=env",
|
||||
},
|
||||
):
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/explicit/messages?key=explicit"
|
||||
)
|
||||
assert (
|
||||
ch._webhook_url
|
||||
== "https://chat.googleapis.com/v1/spaces/explicit/messages?key=explicit"
|
||||
)
|
||||
|
||||
|
||||
class TestSend:
|
||||
def test_send_success(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
@@ -67,7 +90,9 @@ class TestSend:
|
||||
mock_post.assert_called_once()
|
||||
|
||||
def test_send_failure(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 400
|
||||
@@ -78,7 +103,9 @@ class TestSend:
|
||||
assert result is False
|
||||
|
||||
def test_send_exception(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
|
||||
with patch("httpx.post", side_effect=ConnectionError("refused")):
|
||||
result = ch.send("space", "Hello!")
|
||||
@@ -108,13 +135,17 @@ class TestSend:
|
||||
|
||||
class TestListChannels:
|
||||
def test_list_channels(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
assert ch.list_channels() == ["google_chat"]
|
||||
|
||||
|
||||
class TestStatus:
|
||||
def test_disconnected_initially(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
|
||||
def test_no_url_connect_error(self):
|
||||
@@ -125,7 +156,9 @@ class TestStatus:
|
||||
|
||||
class TestOnMessage:
|
||||
def test_on_message(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
assert handler in ch._handlers
|
||||
@@ -133,7 +166,9 @@ class TestOnMessage:
|
||||
|
||||
class TestDisconnect:
|
||||
def test_disconnect(self):
|
||||
ch = GoogleChatChannel(webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy")
|
||||
ch = GoogleChatChannel(
|
||||
webhook_url="https://chat.googleapis.com/v1/spaces/xxx/messages?key=yyy"
|
||||
)
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
ch.disconnect()
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
|
||||
@@ -45,12 +45,15 @@ class TestInit:
|
||||
assert ch._password == "pass123"
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {
|
||||
"IRC_SERVER": "irc.env.com",
|
||||
"IRC_NICK": "envbot",
|
||||
"IRC_PASSWORD": "envpass",
|
||||
"IRC_PORT": "6697",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"IRC_SERVER": "irc.env.com",
|
||||
"IRC_NICK": "envbot",
|
||||
"IRC_PASSWORD": "envpass",
|
||||
"IRC_PORT": "6697",
|
||||
},
|
||||
):
|
||||
ch = IRCChannel()
|
||||
assert ch._server == "irc.env.com"
|
||||
assert ch._nick == "envbot"
|
||||
@@ -58,11 +61,14 @@ class TestInit:
|
||||
assert ch._port == 6697
|
||||
|
||||
def test_constructor_overrides_env(self):
|
||||
with patch.dict(os.environ, {
|
||||
"IRC_SERVER": "irc.env.com",
|
||||
"IRC_NICK": "envbot",
|
||||
"IRC_PASSWORD": "envpass",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"IRC_SERVER": "irc.env.com",
|
||||
"IRC_NICK": "envbot",
|
||||
"IRC_PASSWORD": "envpass",
|
||||
},
|
||||
):
|
||||
ch = IRCChannel(
|
||||
server="irc.explicit.com",
|
||||
nick="explicit",
|
||||
|
||||
@@ -42,19 +42,25 @@ class TestInit:
|
||||
assert ch._phone_number == "+1234567890"
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {
|
||||
"SIGNAL_API_URL": "http://env-signal:8080",
|
||||
"SIGNAL_PHONE_NUMBER": "+9876543210",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"SIGNAL_API_URL": "http://env-signal:8080",
|
||||
"SIGNAL_PHONE_NUMBER": "+9876543210",
|
||||
},
|
||||
):
|
||||
ch = SignalChannel()
|
||||
assert ch._api_url == "http://env-signal:8080"
|
||||
assert ch._phone_number == "+9876543210"
|
||||
|
||||
def test_constructor_overrides_env(self):
|
||||
with patch.dict(os.environ, {
|
||||
"SIGNAL_API_URL": "http://env-signal:8080",
|
||||
"SIGNAL_PHONE_NUMBER": "+9876543210",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"SIGNAL_API_URL": "http://env-signal:8080",
|
||||
"SIGNAL_PHONE_NUMBER": "+9876543210",
|
||||
},
|
||||
):
|
||||
ch = SignalChannel(
|
||||
api_url="http://explicit:8080",
|
||||
phone_number="+1111111111",
|
||||
|
||||
@@ -95,8 +95,12 @@ class TestBaseChannel:
|
||||
pass
|
||||
|
||||
def send(
|
||||
self, channel, content,
|
||||
*, conversation_id="", metadata=None,
|
||||
self,
|
||||
channel,
|
||||
content,
|
||||
*,
|
||||
conversation_id="",
|
||||
metadata=None,
|
||||
) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
@@ -140,3 +140,125 @@ class TestDisconnect:
|
||||
ch._status = ChannelStatus.CONNECTED
|
||||
ch.disconnect()
|
||||
assert ch.status() == ChannelStatus.DISCONNECTED
|
||||
|
||||
|
||||
class TestAllowedChatIds:
|
||||
"""Tests for the allowed_chat_ids enforcement in _poll_loop."""
|
||||
|
||||
def _make_update(self, chat_id: str, text: str = "hello"):
|
||||
"""Build a minimal fake python-telegram-bot Update object."""
|
||||
msg = MagicMock()
|
||||
msg.text = text
|
||||
msg.message_id = 1
|
||||
msg.from_user.id = chat_id
|
||||
msg.chat.id = chat_id
|
||||
update = MagicMock()
|
||||
update.message = msg
|
||||
return update
|
||||
|
||||
def _invoke_handle_msg(self, ch: TelegramChannel, chat_id: str, text: str = "hi"):
|
||||
"""Simulate _poll_loop dispatching a message without starting a thread."""
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
|
||||
cm = ChannelMessage(
|
||||
channel="telegram",
|
||||
sender=chat_id,
|
||||
content=text,
|
||||
message_id="1",
|
||||
conversation_id=chat_id,
|
||||
)
|
||||
# Directly exercise the allow-list logic (mirrors _handle_msg body)
|
||||
if ch._allowed_chat_ids:
|
||||
_allowed = {
|
||||
cid.strip() for cid in ch._allowed_chat_ids.split(",") if cid.strip()
|
||||
}
|
||||
if cm.conversation_id not in _allowed:
|
||||
return False # would return inside _handle_msg
|
||||
for handler in ch._handlers:
|
||||
handler(cm)
|
||||
return True
|
||||
|
||||
def test_no_allowlist_accepts_any(self):
|
||||
"""When allowed_chat_ids is empty every chat is dispatched."""
|
||||
ch = TelegramChannel(bot_token="tok", allowed_chat_ids="")
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
dispatched = self._invoke_handle_msg(ch, "99999")
|
||||
assert dispatched is True
|
||||
handler.assert_called_once()
|
||||
|
||||
def test_allowlist_passes_listed_chat(self):
|
||||
"""A chat ID present in the allow-list is dispatched to handlers."""
|
||||
ch = TelegramChannel(bot_token="tok", allowed_chat_ids="111,222")
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
dispatched = self._invoke_handle_msg(ch, "111")
|
||||
assert dispatched is True
|
||||
handler.assert_called_once()
|
||||
|
||||
def test_allowlist_blocks_unlisted_chat(self):
|
||||
"""A chat ID not in the allow-list is silently dropped (not dispatched)."""
|
||||
ch = TelegramChannel(bot_token="tok", allowed_chat_ids="111,222")
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
dispatched = self._invoke_handle_msg(ch, "999")
|
||||
assert dispatched is False
|
||||
handler.assert_not_called()
|
||||
|
||||
def test_allowlist_trims_whitespace(self):
|
||||
"""Spaces around IDs in the allow-list are handled gracefully."""
|
||||
ch = TelegramChannel(bot_token="tok", allowed_chat_ids=" 111 , 222 ")
|
||||
handler = MagicMock()
|
||||
ch.on_message(handler)
|
||||
dispatched = self._invoke_handle_msg(ch, "111")
|
||||
assert dispatched is True
|
||||
handler.assert_called_once()
|
||||
|
||||
|
||||
class TestChannelAgentWiring:
|
||||
"""Tests for the channel → agent handler wired in serve.py."""
|
||||
|
||||
def test_on_message_handler_invoked_on_message(self):
|
||||
"""on_message callback registered on a channel is called when a message
|
||||
arrives."""
|
||||
ch = TelegramChannel(bot_token="tok")
|
||||
received = []
|
||||
ch.on_message(lambda cm: received.append(cm))
|
||||
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
|
||||
cm = ChannelMessage(
|
||||
channel="telegram",
|
||||
sender="42",
|
||||
content="ping",
|
||||
message_id="1",
|
||||
conversation_id="42",
|
||||
)
|
||||
for h in ch._handlers:
|
||||
h(cm)
|
||||
|
||||
assert len(received) == 1
|
||||
assert received[0].content == "ping"
|
||||
|
||||
def test_multiple_handlers_all_invoked(self):
|
||||
"""Both handlers registered via on_message are called for the same message."""
|
||||
ch = TelegramChannel(bot_token="tok")
|
||||
calls_a: list = []
|
||||
calls_b: list = []
|
||||
ch.on_message(lambda cm: calls_a.append(cm))
|
||||
ch.on_message(lambda cm: calls_b.append(cm))
|
||||
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
|
||||
cm = ChannelMessage(
|
||||
channel="telegram",
|
||||
sender="1",
|
||||
content="x",
|
||||
message_id="1",
|
||||
conversation_id="1",
|
||||
)
|
||||
for h in ch._handlers:
|
||||
h(cm)
|
||||
|
||||
assert len(calls_a) == 1
|
||||
assert len(calls_b) == 1
|
||||
|
||||
@@ -42,19 +42,25 @@ class TestInit:
|
||||
assert ch._phone_number_id == "12345"
|
||||
|
||||
def test_env_var_fallback(self):
|
||||
with patch.dict(os.environ, {
|
||||
"WHATSAPP_ACCESS_TOKEN": "env-token",
|
||||
"WHATSAPP_PHONE_NUMBER_ID": "env-id",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"WHATSAPP_ACCESS_TOKEN": "env-token",
|
||||
"WHATSAPP_PHONE_NUMBER_ID": "env-id",
|
||||
},
|
||||
):
|
||||
ch = WhatsAppChannel()
|
||||
assert ch._token == "env-token"
|
||||
assert ch._phone_number_id == "env-id"
|
||||
|
||||
def test_constructor_overrides_env(self):
|
||||
with patch.dict(os.environ, {
|
||||
"WHATSAPP_ACCESS_TOKEN": "env-token",
|
||||
"WHATSAPP_PHONE_NUMBER_ID": "env-id",
|
||||
}):
|
||||
with patch.dict(
|
||||
os.environ,
|
||||
{
|
||||
"WHATSAPP_ACCESS_TOKEN": "env-token",
|
||||
"WHATSAPP_PHONE_NUMBER_ID": "env-id",
|
||||
},
|
||||
):
|
||||
ch = WhatsAppChannel(
|
||||
access_token="explicit-token",
|
||||
phone_number_id="explicit-id",
|
||||
|
||||
@@ -259,13 +259,15 @@ class TestReaderLoop:
|
||||
bus = EventBus(record_history=True)
|
||||
ch = WhatsAppBaileysChannel(bus=bus)
|
||||
|
||||
ch._handle_bridge_event({
|
||||
"type": "message",
|
||||
"jid": "123@s.whatsapp.net",
|
||||
"sender": "456@s.whatsapp.net",
|
||||
"text": "Bus test",
|
||||
"message_id": "msg-002",
|
||||
})
|
||||
ch._handle_bridge_event(
|
||||
{
|
||||
"type": "message",
|
||||
"jid": "123@s.whatsapp.net",
|
||||
"sender": "456@s.whatsapp.net",
|
||||
"text": "Bus test",
|
||||
"message_id": "msg-002",
|
||||
}
|
||||
)
|
||||
|
||||
event_types = [e.event_type for e in bus.history]
|
||||
assert EventType.CHANNEL_MESSAGE_RECEIVED in event_types
|
||||
@@ -276,13 +278,16 @@ class TestReaderLoop:
|
||||
|
||||
lines = [
|
||||
json.dumps({"type": "status", "status": "connected"}) + "\n",
|
||||
json.dumps({
|
||||
"type": "message",
|
||||
"jid": "j",
|
||||
"sender": "s",
|
||||
"text": "t",
|
||||
"message_id": "m",
|
||||
}) + "\n",
|
||||
json.dumps(
|
||||
{
|
||||
"type": "message",
|
||||
"jid": "j",
|
||||
"sender": "s",
|
||||
"text": "t",
|
||||
"message_id": "m",
|
||||
}
|
||||
)
|
||||
+ "\n",
|
||||
]
|
||||
|
||||
mock_proc = MagicMock()
|
||||
@@ -316,11 +321,13 @@ class TestReaderLoop:
|
||||
ch.on_message(bad_handler)
|
||||
|
||||
# Should not raise.
|
||||
ch._handle_bridge_event({
|
||||
"type": "message",
|
||||
"jid": "j",
|
||||
"sender": "s",
|
||||
"text": "t",
|
||||
"message_id": "m",
|
||||
})
|
||||
ch._handle_bridge_event(
|
||||
{
|
||||
"type": "message",
|
||||
"jid": "j",
|
||||
"sender": "s",
|
||||
"text": "t",
|
||||
"message_id": "m",
|
||||
}
|
||||
)
|
||||
bad_handler.assert_called_once()
|
||||
|
||||
@@ -42,7 +42,8 @@ class TestAddCmd:
|
||||
mcp_dir = tmp_path / "mcp"
|
||||
with mock.patch("openjarvis.cli.add_cmd._MCP_CONFIG_DIR", mcp_dir):
|
||||
result = CliRunner().invoke(
|
||||
add, ["github", "--key", "test_token"],
|
||||
add,
|
||||
["github", "--key", "test_token"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -55,6 +56,6 @@ class TestAddCmd:
|
||||
def test_mcp_templates_complete(self) -> None:
|
||||
required_fields = {"command", "args", "env_key", "description"}
|
||||
for name, tmpl in _MCP_TEMPLATES.items():
|
||||
assert required_fields.issubset(
|
||||
tmpl.keys()
|
||||
), f"Template '{name}' missing fields: {required_fields - tmpl.keys()}"
|
||||
assert required_fields.issubset(tmpl.keys()), (
|
||||
f"Template '{name}' missing fields: {required_fields - tmpl.keys()}"
|
||||
)
|
||||
|
||||
@@ -80,8 +80,19 @@ class TestNewAgentCommands:
|
||||
result = CliRunner().invoke(cli, ["agents", "--help"])
|
||||
assert result.exit_code == 0
|
||||
cmds = (
|
||||
"launch", "start", "stop", "run", "status", "logs",
|
||||
"daemon", "watch", "recover", "errors", "ask", "instruct", "messages",
|
||||
"launch",
|
||||
"start",
|
||||
"stop",
|
||||
"run",
|
||||
"status",
|
||||
"logs",
|
||||
"daemon",
|
||||
"watch",
|
||||
"recover",
|
||||
"errors",
|
||||
"ask",
|
||||
"instruct",
|
||||
"messages",
|
||||
)
|
||||
for cmd in cmds:
|
||||
assert cmd in result.output, f"Missing command: {cmd}"
|
||||
|
||||
@@ -123,7 +123,8 @@ def agent_setup():
|
||||
patch.object(_ask_mod, "get_engine", return_value=("mock", engine)),
|
||||
patch.object(_ask_mod, "discover_engines", return_value=[("mock", engine)]),
|
||||
patch.object(
|
||||
_ask_mod, "discover_models",
|
||||
_ask_mod,
|
||||
"discover_models",
|
||||
return_value={"mock": ["test-model"]},
|
||||
),
|
||||
patch.object(_ask_mod, "register_builtin_models"),
|
||||
@@ -152,6 +153,7 @@ def mock_setup():
|
||||
patch.object(_ask_mod, "merge_discovered_models"),
|
||||
):
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
|
||||
mock_cfg.return_value = JarvisConfig()
|
||||
mock_ge.return_value = ("mock", engine)
|
||||
mock_de.return_value = [("mock", engine)]
|
||||
@@ -175,7 +177,8 @@ class TestAskAgentOption:
|
||||
|
||||
def test_agent_orchestrator_no_tools(self, runner, mock_setup):
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "orchestrator", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "orchestrator", "Hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -183,8 +186,11 @@ class TestAskAgentOption:
|
||||
result = runner.invoke(
|
||||
cli,
|
||||
[
|
||||
"ask", "--agent", "orchestrator",
|
||||
"--tools", "calculator,think",
|
||||
"ask",
|
||||
"--agent",
|
||||
"orchestrator",
|
||||
"--tools",
|
||||
"calculator,think",
|
||||
"What is 2+2?",
|
||||
],
|
||||
)
|
||||
@@ -192,7 +198,8 @@ class TestAskAgentOption:
|
||||
|
||||
def test_agent_json_output(self, runner, mock_setup):
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "simple", "--json", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "simple", "--json", "Hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert '"content"' in result.output
|
||||
@@ -200,7 +207,8 @@ class TestAskAgentOption:
|
||||
|
||||
def test_unknown_agent(self, runner, mock_setup):
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "nonexistent", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "nonexistent", "Hello"],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -211,13 +219,15 @@ class TestAskAgentOption:
|
||||
|
||||
def test_agent_simple_with_model(self, runner, mock_setup):
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "simple", "-m", "test-model", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "simple", "-m", "test-model", "Hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_agent_simple_with_temperature(self, runner, mock_setup):
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "simple", "-t", "0.1", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "simple", "-t", "0.1", "Hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -240,7 +250,8 @@ class TestAskAgentOption:
|
||||
agent_setup.config.agent.tools = agent_tools
|
||||
|
||||
result = runner.invoke(
|
||||
cli, ["ask", "--agent", "confirming_agent", "Hello"],
|
||||
cli,
|
||||
["ask", "--agent", "confirming_agent", "Hello"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -11,9 +11,7 @@ from openjarvis.cli import cli
|
||||
|
||||
def test_ask_no_context_flag():
|
||||
"""The --no-context flag is accepted."""
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "--no-context", "--help"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["ask", "--no-context", "--help"])
|
||||
# --help should succeed regardless
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -26,7 +24,8 @@ def test_ask_has_no_context_option():
|
||||
|
||||
|
||||
def test_get_memory_backend_returns_none_when_empty(
|
||||
tmp_path, monkeypatch,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""_get_memory_backend returns None when no docs indexed."""
|
||||
from openjarvis.core.config import JarvisConfig, MemoryConfig
|
||||
@@ -47,7 +46,8 @@ def test_get_memory_backend_returns_none_when_empty(
|
||||
|
||||
|
||||
def test_get_memory_backend_returns_backend_with_docs(
|
||||
tmp_path, monkeypatch,
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
):
|
||||
"""_get_memory_backend returns a backend when docs exist."""
|
||||
from openjarvis.core.config import JarvisConfig, MemoryConfig
|
||||
|
||||
+18
-39
@@ -30,9 +30,7 @@ def _mock_engine_response():
|
||||
}
|
||||
|
||||
|
||||
def _patch_ask(
|
||||
monkeypatch, tmp_path, *, engine_result=None, no_engine=False
|
||||
):
|
||||
def _patch_ask(monkeypatch, tmp_path, *, engine_result=None, no_engine=False):
|
||||
"""Set up common mocks for ask tests."""
|
||||
cfg = JarvisConfig()
|
||||
cfg.telemetry.db_path = str(tmp_path / "telemetry.db")
|
||||
@@ -40,74 +38,55 @@ def _patch_ask(
|
||||
monkeypatch.setattr(_ask_mod, "load_config", lambda: cfg)
|
||||
|
||||
if no_engine:
|
||||
monkeypatch.setattr(
|
||||
_ask_mod, "get_engine", lambda *a, **kw: None
|
||||
)
|
||||
monkeypatch.setattr(_ask_mod, "get_engine", lambda *a, **kw: None)
|
||||
else:
|
||||
fake_engine = mock.MagicMock()
|
||||
fake_engine.engine_id = "mock"
|
||||
fake_engine.health.return_value = True
|
||||
fake_engine.generate.return_value = (
|
||||
engine_result or _mock_engine_response()
|
||||
)
|
||||
fake_engine.generate.return_value = engine_result or _mock_engine_response()
|
||||
fake_engine.list_models.return_value = ["test-model"]
|
||||
monkeypatch.setattr(
|
||||
_ask_mod, "get_engine",
|
||||
_ask_mod,
|
||||
"get_engine",
|
||||
lambda *a, **kw: ("mock", fake_engine),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_ask_mod, "discover_engines",
|
||||
_ask_mod,
|
||||
"discover_engines",
|
||||
lambda c: [("mock", fake_engine)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_ask_mod, "discover_models",
|
||||
_ask_mod,
|
||||
"discover_models",
|
||||
lambda e: {"mock": ["test-model"]},
|
||||
)
|
||||
|
||||
|
||||
class TestAskCommand:
|
||||
def test_basic_response(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
def test_basic_response(self, monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_ask(monkeypatch, tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "What is 2+2?"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["ask", "What is 2+2?"])
|
||||
assert result.exit_code == 0
|
||||
assert "The answer is 4" in result.output
|
||||
|
||||
def test_no_engine_error(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
def test_no_engine_error(self, monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_ask(monkeypatch, tmp_path, no_engine=True)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "Hello"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["ask", "Hello"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_model_override(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
def test_model_override(self, monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_ask(monkeypatch, tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "-m", "custom-model", "Hello"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["ask", "-m", "custom-model", "Hello"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_json_output(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
def test_json_output(self, monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_ask(monkeypatch, tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "--json", "Hello"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["ask", "--json", "Hello"])
|
||||
assert result.exit_code == 0
|
||||
data = json.loads(result.output)
|
||||
assert "content" in data
|
||||
|
||||
def test_telemetry_recorded(
|
||||
self, monkeypatch, tmp_path: Path
|
||||
) -> None:
|
||||
def test_telemetry_recorded(self, monkeypatch, tmp_path: Path) -> None:
|
||||
_patch_ask(monkeypatch, tmp_path)
|
||||
CliRunner().invoke(cli, ["ask", "Hello"])
|
||||
db_path = tmp_path / "telemetry.db"
|
||||
|
||||
@@ -31,15 +31,18 @@ def _patch_engine(engine):
|
||||
"""Return context managers that patch engine discovery to use our mock."""
|
||||
return (
|
||||
mock.patch.object(
|
||||
_ask_mod, "get_engine",
|
||||
_ask_mod,
|
||||
"get_engine",
|
||||
return_value=("mock", engine),
|
||||
),
|
||||
mock.patch.object(
|
||||
_ask_mod, "discover_engines",
|
||||
_ask_mod,
|
||||
"discover_engines",
|
||||
return_value={"mock": engine},
|
||||
),
|
||||
mock.patch.object(
|
||||
_ask_mod, "discover_models",
|
||||
_ask_mod,
|
||||
"discover_models",
|
||||
return_value={"mock": ["test-model"]},
|
||||
),
|
||||
mock.patch.object(_ask_mod, "register_builtin_models"),
|
||||
@@ -64,7 +67,8 @@ class TestAskModelResolution:
|
||||
patches = _patch_engine(engine)
|
||||
with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]:
|
||||
result = CliRunner().invoke(
|
||||
cli, ["ask", "-m", "test-model", "Hello"],
|
||||
cli,
|
||||
["ask", "-m", "test-model", "Hello"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Hello!" in result.output
|
||||
@@ -74,9 +78,15 @@ class TestAskModelResolution:
|
||||
engine = _mock_engine()
|
||||
patches = _patch_engine(engine)
|
||||
with (
|
||||
patches[0], patches[1], patches[2], patches[3], patches[4], patches[5],
|
||||
patches[0],
|
||||
patches[1],
|
||||
patches[2],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
mock.patch.object(
|
||||
_ask_mod, "load_config",
|
||||
_ask_mod,
|
||||
"load_config",
|
||||
) as mock_config,
|
||||
):
|
||||
cfg = mock_config.return_value
|
||||
@@ -93,14 +103,19 @@ class TestAskModelResolution:
|
||||
patches = _patch_engine(engine)
|
||||
# Override discover_models to return empty list
|
||||
with (
|
||||
patches[0], patches[1],
|
||||
patches[0],
|
||||
patches[1],
|
||||
mock.patch.object(
|
||||
_ask_mod, "discover_models",
|
||||
_ask_mod,
|
||||
"discover_models",
|
||||
return_value={"mock": []},
|
||||
),
|
||||
patches[3], patches[4], patches[5],
|
||||
patches[3],
|
||||
patches[4],
|
||||
patches[5],
|
||||
mock.patch.object(
|
||||
_ask_mod, "load_config",
|
||||
_ask_mod,
|
||||
"load_config",
|
||||
) as mock_config,
|
||||
):
|
||||
cfg = mock_config.return_value
|
||||
|
||||
@@ -56,7 +56,8 @@ class TestBenchCLI:
|
||||
return_value=("mock", engine),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["bench", "run", "-n", "2", "--json"],
|
||||
cli,
|
||||
["bench", "run", "-n", "2", "--json"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "benchmark_count" in result.output
|
||||
@@ -76,7 +77,8 @@ class TestBenchCLI:
|
||||
return_value=("mock", engine),
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
cli, ["bench", "run", "-n", "2", "-o", str(out_file)],
|
||||
cli,
|
||||
["bench", "run", "-n", "2", "-o", str(out_file)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert out_file.exists()
|
||||
|
||||
@@ -25,7 +25,8 @@ def _patch_channel(
|
||||
bridge_instance.status.return_value = status_return
|
||||
|
||||
config_patch = mock.patch(
|
||||
"openjarvis.core.config.load_config", return_value=cfg,
|
||||
"openjarvis.core.config.load_config",
|
||||
return_value=cfg,
|
||||
)
|
||||
get_channel_patch = mock.patch(
|
||||
"openjarvis.cli.channel_cmd._get_channel",
|
||||
@@ -75,7 +76,8 @@ class TestChannelSend:
|
||||
config_p, getch_p, _ = _patch_channel(send_return=True)
|
||||
with config_p, getch_p:
|
||||
result = CliRunner().invoke(
|
||||
cli, ["channel", "send", "slack", "Hello!"],
|
||||
cli,
|
||||
["channel", "send", "slack", "Hello!"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Message sent" in result.output
|
||||
@@ -84,7 +86,8 @@ class TestChannelSend:
|
||||
config_p, getch_p, _ = _patch_channel(send_return=False)
|
||||
with config_p, getch_p:
|
||||
result = CliRunner().invoke(
|
||||
cli, ["channel", "send", "slack", "Hello!"],
|
||||
cli,
|
||||
["channel", "send", "slack", "Hello!"],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "Failed to send" in result.output
|
||||
|
||||
+3
-11
@@ -31,11 +31,7 @@ class TestCLI:
|
||||
# Either exits with error (no engine) or succeeds (deps missing)
|
||||
# Both are valid states for testing
|
||||
out = result.output.lower()
|
||||
assert (
|
||||
result.exit_code != 0
|
||||
or "not installed" in out
|
||||
or "no inference" in out
|
||||
)
|
||||
assert result.exit_code != 0 or "not installed" in out or "no inference" in out
|
||||
|
||||
def test_model_subcommands_exist(self) -> None:
|
||||
result = CliRunner().invoke(cli, ["model", "--help"])
|
||||
@@ -82,12 +78,8 @@ class TestCLI:
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
with (
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir
|
||||
),
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path
|
||||
),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--engine", "ollama"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -23,18 +23,14 @@ class TestDaemonCommands:
|
||||
|
||||
def test_stop_no_server(self) -> None:
|
||||
"""``jarvis stop`` when no PID file shows 'not running'."""
|
||||
with patch(
|
||||
"openjarvis.cli.daemon_cmd._read_pid", return_value=None
|
||||
):
|
||||
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None):
|
||||
result = CliRunner().invoke(cli, ["stop"])
|
||||
assert result.exit_code != 0
|
||||
assert "No running server" in result.output
|
||||
|
||||
def test_status_no_server(self) -> None:
|
||||
"""``jarvis status`` when no PID file shows 'not running'."""
|
||||
with patch(
|
||||
"openjarvis.cli.daemon_cmd._read_pid", return_value=None
|
||||
):
|
||||
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=None):
|
||||
result = CliRunner().invoke(cli, ["status"])
|
||||
assert result.exit_code == 0
|
||||
assert "not running" in result.output
|
||||
@@ -52,9 +48,7 @@ class TestDaemonCommands:
|
||||
pid_file = tmp_path / "server.pid"
|
||||
with (
|
||||
patch("openjarvis.cli.daemon_cmd._PID_FILE", pid_file),
|
||||
patch(
|
||||
"openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path
|
||||
),
|
||||
patch("openjarvis.cli.daemon_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch("os.kill", return_value=None),
|
||||
):
|
||||
_write_pid(12345)
|
||||
@@ -68,9 +62,7 @@ class TestDaemonCommands:
|
||||
mock_config.server.port = 8000
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.daemon_cmd._read_pid", return_value=9999
|
||||
),
|
||||
patch("openjarvis.cli.daemon_cmd._read_pid", return_value=9999),
|
||||
patch(
|
||||
"openjarvis.cli.daemon_cmd.load_config",
|
||||
return_value=mock_config,
|
||||
@@ -83,9 +75,7 @@ class TestDaemonCommands:
|
||||
|
||||
def test_start_already_running(self) -> None:
|
||||
"""``jarvis start`` exits with error when a server is already running."""
|
||||
with patch(
|
||||
"openjarvis.cli.daemon_cmd._read_pid", return_value=42
|
||||
):
|
||||
with patch("openjarvis.cli.daemon_cmd._read_pid", return_value=42):
|
||||
result = CliRunner().invoke(cli, ["start"])
|
||||
assert result.exit_code != 0
|
||||
assert "already running" in result.output
|
||||
|
||||
@@ -32,19 +32,13 @@ class TestDoctorRuns:
|
||||
mock_config.intelligence.default_model = ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd.load_config", return_value=mock_config
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd.load_config", return_value=mock_config),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd.DEFAULT_CONFIG_PATH",
|
||||
Path("/tmp/nonexistent/config.toml"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_engines", return_value=[]
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_models", return_value=[]
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
|
||||
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["doctor"])
|
||||
assert result.exit_code == 0
|
||||
@@ -58,19 +52,13 @@ class TestDoctorJsonOutput:
|
||||
mock_config.intelligence.default_model = ""
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd.load_config", return_value=mock_config
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd.load_config", return_value=mock_config),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd.DEFAULT_CONFIG_PATH",
|
||||
Path("/tmp/nonexistent/config.toml"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_engines", return_value=[]
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.doctor_cmd._check_models", return_value=[]
|
||||
),
|
||||
patch("openjarvis.cli.doctor_cmd._check_engines", return_value=[]),
|
||||
patch("openjarvis.cli.doctor_cmd._check_models", return_value=[]),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["doctor", "--json"])
|
||||
assert result.exit_code == 0
|
||||
@@ -129,13 +117,9 @@ class TestCheckEngineProbing:
|
||||
for key in sorted(keys):
|
||||
engine = mock_make_engine(key, mock_config)
|
||||
if engine.health():
|
||||
results.append(
|
||||
CheckResult(f"Engine: {key}", "ok", "Reachable")
|
||||
)
|
||||
results.append(CheckResult(f"Engine: {key}", "ok", "Reachable"))
|
||||
else:
|
||||
results.append(
|
||||
CheckResult(f"Engine: {key}", "warn", "Unreachable")
|
||||
)
|
||||
results.append(CheckResult(f"Engine: {key}", "warn", "Unreachable"))
|
||||
|
||||
names = [r.name for r in results]
|
||||
assert "Engine: ollama" in names
|
||||
|
||||
@@ -15,10 +15,12 @@ _real_import = builtins.__import__
|
||||
|
||||
def _selective_import_blocker(*blocked: str):
|
||||
"""Return an __import__ replacement that blocks specific packages."""
|
||||
|
||||
def _import(name, *args, **kwargs):
|
||||
if name in blocked:
|
||||
raise ImportError(f"mocked: {name} not installed")
|
||||
return _real_import(name, *args, **kwargs)
|
||||
|
||||
return _import
|
||||
|
||||
|
||||
@@ -45,8 +47,7 @@ class TestDoctorOptionalLabels:
|
||||
result = runner.invoke(cli, ["doctor", "--json"])
|
||||
data = json.loads(result.output)
|
||||
apple_checks = [
|
||||
c for c in data
|
||||
if c["name"] == "Optional: Apple Silicon energy monitoring"
|
||||
c for c in data if c["name"] == "Optional: Apple Silicon energy monitoring"
|
||||
]
|
||||
assert len(apple_checks) == 1
|
||||
assert "Not installed (openjarvis[energy-apple])" == apple_checks[0]["message"]
|
||||
@@ -59,8 +60,7 @@ class TestDoctorOptionalLabels:
|
||||
result = runner.invoke(cli, ["doctor", "--json"])
|
||||
data = json.loads(result.output)
|
||||
nvidia_checks = [
|
||||
c for c in data
|
||||
if c["name"] == "Optional: NVIDIA energy monitoring"
|
||||
c for c in data if c["name"] == "Optional: NVIDIA energy monitoring"
|
||||
]
|
||||
assert len(nvidia_checks) == 1
|
||||
assert "Not installed (openjarvis[gpu-metrics])" == nvidia_checks[0]["message"]
|
||||
|
||||
@@ -17,12 +17,8 @@ class TestInitShowsNextSteps:
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
with (
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir
|
||||
),
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path
|
||||
),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"])
|
||||
assert result.exit_code == 0
|
||||
@@ -35,12 +31,8 @@ class TestInitShowsNextSteps:
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
with (
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir
|
||||
),
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path
|
||||
),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--engine", "llamacpp"])
|
||||
assert result.exit_code == 0
|
||||
@@ -98,12 +90,8 @@ class TestMinimalConfig:
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
with (
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir
|
||||
),
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path
|
||||
),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--engine", "ollama"])
|
||||
assert result.exit_code == 0
|
||||
@@ -119,12 +107,8 @@ class TestMinimalConfig:
|
||||
config_dir = tmp_path / ".openjarvis"
|
||||
config_path = config_dir / "config.toml"
|
||||
with (
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir
|
||||
),
|
||||
mock.patch(
|
||||
"openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path
|
||||
),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_DIR", config_dir),
|
||||
mock.patch("openjarvis.cli.init_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
):
|
||||
result = CliRunner().invoke(cli, ["init", "--full", "--engine", "ollama"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
@@ -29,10 +29,7 @@ class TestSetupLogging:
|
||||
log_file = tmp_path / "cli.log"
|
||||
logger = setup_logging(verbose=True, quiet=False, log_file=log_file)
|
||||
# Should have at least one file handler
|
||||
file_handlers = [
|
||||
h for h in logger.handlers
|
||||
if hasattr(h, "baseFilename")
|
||||
]
|
||||
file_handlers = [h for h in logger.handlers if hasattr(h, "baseFilename")]
|
||||
assert len(file_handlers) >= 1
|
||||
# Clean up
|
||||
for h in logger.handlers[:]:
|
||||
|
||||
@@ -29,13 +29,12 @@ def test_memory_index_file(tmp_path: Path, monkeypatch):
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod, "_get_backend",
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, ["memory", "index", str(doc)]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["memory", "index", str(doc)])
|
||||
assert result.exit_code == 0
|
||||
assert "Indexed" in result.output or "chunk" in result.output
|
||||
|
||||
@@ -43,9 +42,7 @@ def test_memory_index_file(tmp_path: Path, monkeypatch):
|
||||
def test_memory_index_nonexistent(tmp_path: Path):
|
||||
"""Indexing a nonexistent path should fail."""
|
||||
_register_sqlite()
|
||||
result = CliRunner().invoke(
|
||||
cli, ["memory", "index", str(tmp_path / "nope")]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["memory", "index", str(tmp_path / "nope")])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
@@ -61,13 +58,12 @@ def test_memory_search_returns_results(tmp_path: Path, monkeypatch):
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod, "_get_backend",
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, ["memory", "search", "Python"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["memory", "search", "Python"])
|
||||
assert result.exit_code == 0
|
||||
assert "Python" in result.output
|
||||
backend.close()
|
||||
@@ -82,13 +78,12 @@ def test_memory_search_no_results(tmp_path: Path, monkeypatch):
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod, "_get_backend",
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
cli, ["memory", "search", "quantum supercollider"]
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["memory", "search", "quantum supercollider"])
|
||||
assert result.exit_code == 0
|
||||
assert "No results" in result.output
|
||||
backend.close()
|
||||
@@ -104,7 +99,8 @@ def test_memory_stats_shows_count(tmp_path: Path, monkeypatch):
|
||||
|
||||
mod = importlib.import_module("openjarvis.cli.memory_cmd")
|
||||
monkeypatch.setattr(
|
||||
mod, "_get_backend",
|
||||
mod,
|
||||
"_get_backend",
|
||||
lambda b=None: SQLiteMemory(db_path=db_path),
|
||||
)
|
||||
|
||||
|
||||
+11
-23
@@ -29,11 +29,13 @@ class TestModelList:
|
||||
monkeypatch.setattr(_model_mod, "load_config", lambda: cfg)
|
||||
fake = _mock_engine()
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_engines",
|
||||
_model_mod,
|
||||
"discover_engines",
|
||||
lambda c: [("mock", fake)],
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_models",
|
||||
_model_mod,
|
||||
"discover_models",
|
||||
lambda e: {"mock": ["model-a", "model-b"]},
|
||||
)
|
||||
result = CliRunner().invoke(cli, ["model", "list"])
|
||||
@@ -43,9 +45,7 @@ class TestModelList:
|
||||
def test_no_engines_message(self, monkeypatch) -> None:
|
||||
cfg = JarvisConfig()
|
||||
monkeypatch.setattr(_model_mod, "load_config", lambda: cfg)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_engines", lambda c: []
|
||||
)
|
||||
monkeypatch.setattr(_model_mod, "discover_engines", lambda c: [])
|
||||
result = CliRunner().invoke(cli, ["model", "list"])
|
||||
assert result.exit_code == 0
|
||||
assert "No inference engines" in result.output
|
||||
@@ -55,28 +55,16 @@ class TestModelInfo:
|
||||
def test_info_known_model(self, monkeypatch) -> None:
|
||||
cfg = JarvisConfig()
|
||||
monkeypatch.setattr(_model_mod, "load_config", lambda: cfg)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_engines", lambda c: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_models", lambda e: {}
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["model", "info", "qwen3:8b"]
|
||||
)
|
||||
monkeypatch.setattr(_model_mod, "discover_engines", lambda c: [])
|
||||
monkeypatch.setattr(_model_mod, "discover_models", lambda e: {})
|
||||
result = CliRunner().invoke(cli, ["model", "info", "qwen3:8b"])
|
||||
assert result.exit_code == 0
|
||||
assert "Qwen3 8B" in result.output
|
||||
|
||||
def test_unknown_model_not_found(self, monkeypatch) -> None:
|
||||
cfg = JarvisConfig()
|
||||
monkeypatch.setattr(_model_mod, "load_config", lambda: cfg)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_engines", lambda c: []
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_model_mod, "discover_models", lambda e: {}
|
||||
)
|
||||
result = CliRunner().invoke(
|
||||
cli, ["model", "info", "nonexistent-model"]
|
||||
)
|
||||
monkeypatch.setattr(_model_mod, "discover_engines", lambda c: [])
|
||||
monkeypatch.setattr(_model_mod, "discover_models", lambda e: {})
|
||||
result = CliRunner().invoke(cli, ["model", "info", "nonexistent-model"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
@@ -32,28 +32,23 @@ class TestQuickstartCommand:
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".generate_default_toml",
|
||||
"openjarvis.cli.quickstart_cmd.generate_default_toml",
|
||||
return_value="[engine]\n",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".recommend_engine",
|
||||
"openjarvis.cli.quickstart_cmd.recommend_engine",
|
||||
return_value="ollama",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_engine_health",
|
||||
"openjarvis.cli.quickstart_cmd._check_engine_health",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_model_available",
|
||||
"openjarvis.cli.quickstart_cmd._check_model_available",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._test_query",
|
||||
"openjarvis.cli.quickstart_cmd._test_query",
|
||||
return_value="Hello!",
|
||||
),
|
||||
):
|
||||
@@ -79,28 +74,23 @@ class TestQuickstartCommand:
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".generate_default_toml",
|
||||
"openjarvis.cli.quickstart_cmd.generate_default_toml",
|
||||
return_value="[engine]\n",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".recommend_engine",
|
||||
"openjarvis.cli.quickstart_cmd.recommend_engine",
|
||||
return_value="ollama",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_engine_health",
|
||||
"openjarvis.cli.quickstart_cmd._check_engine_health",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_model_available",
|
||||
"openjarvis.cli.quickstart_cmd._check_model_available",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._test_query",
|
||||
"openjarvis.cli.quickstart_cmd._test_query",
|
||||
return_value="Hello!",
|
||||
),
|
||||
):
|
||||
@@ -128,23 +118,19 @@ class TestQuickstartCommand:
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".generate_default_toml",
|
||||
"openjarvis.cli.quickstart_cmd.generate_default_toml",
|
||||
return_value="[engine]\nnew = true\n",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".recommend_engine",
|
||||
"openjarvis.cli.quickstart_cmd.recommend_engine",
|
||||
return_value="ollama",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_engine_health",
|
||||
"openjarvis.cli.quickstart_cmd._check_engine_health",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_model_available",
|
||||
"openjarvis.cli.quickstart_cmd._check_model_available",
|
||||
return_value=True,
|
||||
),
|
||||
patch("openjarvis.cli.quickstart_cmd._test_query", return_value="Hello!"),
|
||||
@@ -169,18 +155,15 @@ class TestQuickstartCommand:
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".generate_default_toml",
|
||||
"openjarvis.cli.quickstart_cmd.generate_default_toml",
|
||||
return_value="[engine]\n",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".recommend_engine",
|
||||
"openjarvis.cli.quickstart_cmd.recommend_engine",
|
||||
return_value="ollama",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_engine_health",
|
||||
"openjarvis.cli.quickstart_cmd._check_engine_health",
|
||||
return_value=False,
|
||||
),
|
||||
):
|
||||
@@ -207,33 +190,27 @@ class TestQuickstartCommand:
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_PATH", config_path),
|
||||
patch("openjarvis.cli.quickstart_cmd.DEFAULT_CONFIG_DIR", tmp_path),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".generate_default_toml",
|
||||
return_value="[engine]\ndefault = \"mlx\"\n",
|
||||
"openjarvis.cli.quickstart_cmd.generate_default_toml",
|
||||
return_value='[engine]\ndefault = "mlx"\n',
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
".recommend_engine",
|
||||
"openjarvis.cli.quickstart_cmd.recommend_engine",
|
||||
return_value="mlx",
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_engine_health",
|
||||
"openjarvis.cli.quickstart_cmd._check_engine_health",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._discover_healthy_engines",
|
||||
"openjarvis.cli.quickstart_cmd._discover_healthy_engines",
|
||||
return_value=["ollama"],
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._check_model_available",
|
||||
"openjarvis.cli.quickstart_cmd._check_model_available",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.quickstart_cmd"
|
||||
"._test_query",
|
||||
"openjarvis.cli.quickstart_cmd._test_query",
|
||||
return_value="Hello!",
|
||||
),
|
||||
):
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for JarvisSystem.wire_channel() — channel → agent routing.
|
||||
|
||||
These tests exercise wire_channel() on JarvisSystem directly. The serve.py
|
||||
entrypoint now delegates all channel-wiring logic there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from openjarvis.channels._stubs import ChannelMessage
|
||||
from openjarvis.core.config import JarvisConfig
|
||||
from openjarvis.core.events import EventBus
|
||||
from openjarvis.sessions.session import SessionStore
|
||||
from openjarvis.system import JarvisSystem
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_channel_message(
|
||||
channel: str = "telegram",
|
||||
sender: str = "42",
|
||||
content: str = "hello",
|
||||
conversation_id: str = "42",
|
||||
) -> ChannelMessage:
|
||||
return ChannelMessage(
|
||||
channel=channel,
|
||||
sender=sender,
|
||||
content=content,
|
||||
message_id="1",
|
||||
conversation_id=conversation_id,
|
||||
)
|
||||
|
||||
|
||||
def _make_system(engine=None, agent_name="", tmp_path=None) -> JarvisSystem:
|
||||
"""Build a minimal JarvisSystem with mock engine for testing wire_channel."""
|
||||
config = JarvisConfig()
|
||||
if tmp_path is not None:
|
||||
config.sessions.db_path = str(tmp_path / "sessions.db")
|
||||
mock_engine = engine or MagicMock()
|
||||
return JarvisSystem(
|
||||
config=config,
|
||||
bus=EventBus(record_history=False),
|
||||
engine=mock_engine,
|
||||
engine_key="mock",
|
||||
model="test-model",
|
||||
agent_name=agent_name,
|
||||
)
|
||||
|
||||
|
||||
def _fire(channel_mock, cm: ChannelMessage) -> None:
|
||||
"""Invoke all registered on_message handlers as if the channel received cm."""
|
||||
for handler in channel_mock.on_message.call_args_list:
|
||||
handler[0][0](cm)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests via JarvisSystem.wire_channel()
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWireChannelWithAgent:
|
||||
"""wire_channel routes incoming messages through the agent and replies."""
|
||||
|
||||
def test_ask_called_and_reply_sent(self, tmp_path):
|
||||
system = _make_system(agent_name="simple", tmp_path=tmp_path)
|
||||
# Patch ask() so we don't need a real engine/agent
|
||||
system.ask = MagicMock(return_value={"content": "pong"})
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
|
||||
# Simulate an incoming message
|
||||
cm = _make_channel_message(content="ping")
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
handler(cm)
|
||||
|
||||
system.ask.assert_called_once()
|
||||
assert system.ask.call_args[0][0] == "ping"
|
||||
mock_channel.send.assert_called_once_with(
|
||||
"telegram",
|
||||
"pong",
|
||||
conversation_id="42",
|
||||
)
|
||||
|
||||
def test_session_store_created_lazily(self, tmp_path):
|
||||
system = _make_system(tmp_path=tmp_path)
|
||||
assert system.session_store is None
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.ask = MagicMock(return_value={"content": "ok"})
|
||||
system.wire_channel(mock_channel)
|
||||
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
handler(_make_channel_message())
|
||||
|
||||
assert system.session_store is not None
|
||||
|
||||
def test_existing_session_store_reused(self, tmp_path):
|
||||
system = _make_system(tmp_path=tmp_path)
|
||||
existing_store = SessionStore(db_path=tmp_path / "sessions.db")
|
||||
system.session_store = existing_store
|
||||
system.ask = MagicMock(return_value={"content": "ok"})
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
handler(_make_channel_message())
|
||||
|
||||
assert system.session_store is existing_store
|
||||
|
||||
|
||||
class TestWireChannelWithEngine:
|
||||
"""wire_channel falls back to engine when no agent is set."""
|
||||
|
||||
def test_engine_path_used_when_no_agent(self, tmp_path):
|
||||
system = _make_system(agent_name="", tmp_path=tmp_path)
|
||||
system.ask = MagicMock(return_value={"content": "raw reply"})
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
handler(_make_channel_message(content="hi"))
|
||||
|
||||
mock_channel.send.assert_called_once_with(
|
||||
"telegram",
|
||||
"raw reply",
|
||||
conversation_id="42",
|
||||
)
|
||||
|
||||
|
||||
class TestWireChannelSessionIsolation:
|
||||
"""Separate conversation_ids get independent sessions."""
|
||||
|
||||
def test_two_chats_isolated(self, tmp_path):
|
||||
system = _make_system(tmp_path=tmp_path)
|
||||
replies = {"111": "reply-A", "222": "reply-B"}
|
||||
system.ask = MagicMock(
|
||||
side_effect=lambda q, **kw: {"content": replies.get(q, "")}
|
||||
)
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
|
||||
handler(_make_channel_message(content="111", conversation_id="111"))
|
||||
handler(_make_channel_message(content="222", conversation_id="222"))
|
||||
|
||||
# Reload sessions — each chat must have only its own messages
|
||||
s1 = system.session_store.get_or_create("telegram:111")
|
||||
s2 = system.session_store.get_or_create("telegram:222")
|
||||
c1 = {m.content for m in s1.messages}
|
||||
c2 = {m.content for m in s2.messages}
|
||||
|
||||
assert "111" in c1 and "222" not in c1
|
||||
assert "222" in c2 and "111" not in c2
|
||||
|
||||
def test_same_chat_accumulates_history(self, tmp_path):
|
||||
system = _make_system(tmp_path=tmp_path)
|
||||
system.ask = MagicMock(return_value={"content": "reply"})
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
|
||||
handler(_make_channel_message(content="first"))
|
||||
handler(_make_channel_message(content="second"))
|
||||
|
||||
session = system.session_store.get_or_create("telegram:42")
|
||||
contents = [m.content for m in session.messages]
|
||||
# user + assistant alternating for two turns
|
||||
assert contents.count("first") == 1
|
||||
assert contents.count("second") == 1
|
||||
|
||||
|
||||
class TestWireChannelErrorHandling:
|
||||
"""Handler sends a user-visible error message when ask() raises."""
|
||||
|
||||
def test_error_reply_sent(self, tmp_path):
|
||||
system = _make_system(tmp_path=tmp_path)
|
||||
system.ask = MagicMock(side_effect=RuntimeError("boom"))
|
||||
|
||||
mock_channel = MagicMock()
|
||||
system.wire_channel(mock_channel)
|
||||
handler = mock_channel.on_message.call_args[0][0]
|
||||
handler(_make_channel_message())
|
||||
|
||||
mock_channel.send.assert_called_once()
|
||||
sent_content = mock_channel.send.call_args[0][1]
|
||||
assert "error" in sent_content.lower()
|
||||
|
||||
|
||||
class TestPerChatSessionIsolation:
|
||||
"""Direct SessionStore isolation tests (not via wire_channel)."""
|
||||
|
||||
def test_two_chats_have_separate_sessions(self, tmp_path):
|
||||
store = SessionStore(db_path=tmp_path / "sessions.db")
|
||||
|
||||
session_a = store.get_or_create(
|
||||
"telegram:111",
|
||||
channel="telegram",
|
||||
channel_user_id="111",
|
||||
)
|
||||
store.save_message(session_a.session_id, "user", "msg from A")
|
||||
|
||||
session_b = store.get_or_create(
|
||||
"telegram:222",
|
||||
channel="telegram",
|
||||
channel_user_id="222",
|
||||
)
|
||||
store.save_message(session_b.session_id, "user", "msg from B")
|
||||
|
||||
reloaded_a = store.get_or_create(
|
||||
"telegram:111",
|
||||
channel="telegram",
|
||||
channel_user_id="111",
|
||||
)
|
||||
reloaded_b = store.get_or_create(
|
||||
"telegram:222",
|
||||
channel="telegram",
|
||||
channel_user_id="222",
|
||||
)
|
||||
|
||||
contents_a = {m.content for m in reloaded_a.messages}
|
||||
contents_b = {m.content for m in reloaded_b.messages}
|
||||
|
||||
assert "msg from A" in contents_a and "msg from B" not in contents_a
|
||||
assert "msg from B" in contents_b and "msg from A" not in contents_b
|
||||
|
||||
def test_same_chat_accumulates_history(self, tmp_path):
|
||||
store = SessionStore(db_path=tmp_path / "sessions.db")
|
||||
session = store.get_or_create(
|
||||
"telegram:42",
|
||||
channel="telegram",
|
||||
channel_user_id="42",
|
||||
)
|
||||
store.save_message(session.session_id, "user", "first")
|
||||
store.save_message(session.session_id, "assistant", "reply")
|
||||
|
||||
reloaded = store.get_or_create(
|
||||
"telegram:42",
|
||||
channel="telegram",
|
||||
channel_user_id="42",
|
||||
)
|
||||
assert [m.content for m in reloaded.messages] == ["first", "reply"]
|
||||
@@ -18,16 +18,18 @@ def _populate_db(db_path: Path, n: int = 3) -> None:
|
||||
"""Create a telemetry DB with *n* records."""
|
||||
store = TelemetryStore(db_path)
|
||||
for i in range(n):
|
||||
store.record(TelemetryRecord(
|
||||
timestamp=time.time() - (n - i),
|
||||
model_id=f"model-{i % 2}",
|
||||
engine="ollama",
|
||||
prompt_tokens=10 * (i + 1),
|
||||
completion_tokens=5 * (i + 1),
|
||||
total_tokens=15 * (i + 1),
|
||||
latency_seconds=0.5 * (i + 1),
|
||||
cost_usd=0.001 * (i + 1),
|
||||
))
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time() - (n - i),
|
||||
model_id=f"model-{i % 2}",
|
||||
engine="ollama",
|
||||
prompt_tokens=10 * (i + 1),
|
||||
completion_tokens=5 * (i + 1),
|
||||
total_tokens=15 * (i + 1),
|
||||
latency_seconds=0.5 * (i + 1),
|
||||
cost_usd=0.001 * (i + 1),
|
||||
)
|
||||
)
|
||||
store.close()
|
||||
|
||||
|
||||
@@ -37,7 +39,8 @@ def _patch_config(tmp_path: Path):
|
||||
cfg = mock.MagicMock()
|
||||
cfg.telemetry.db_path = str(db_path)
|
||||
return mock.patch(
|
||||
"openjarvis.cli.telemetry_cmd.load_config", return_value=cfg,
|
||||
"openjarvis.cli.telemetry_cmd.load_config",
|
||||
return_value=cfg,
|
||||
), db_path
|
||||
|
||||
|
||||
@@ -115,7 +118,8 @@ class TestTelemetryExport:
|
||||
out_file = tmp_path / "export.json"
|
||||
with patch:
|
||||
result = CliRunner().invoke(
|
||||
cli, ["telemetry", "export", "-o", str(out_file)],
|
||||
cli,
|
||||
["telemetry", "export", "-o", str(out_file)],
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert out_file.exists()
|
||||
|
||||
@@ -47,7 +47,8 @@ class TestVaultCmd:
|
||||
mock.patch("openjarvis.cli.vault_cmd._VAULT_FILE", vault_file),
|
||||
mock.patch("openjarvis.cli.vault_cmd._VAULT_KEY_FILE", key_file),
|
||||
mock.patch(
|
||||
"openjarvis.cli.vault_cmd.DEFAULT_CONFIG_DIR", tmp_path,
|
||||
"openjarvis.cli.vault_cmd.DEFAULT_CONFIG_DIR",
|
||||
tmp_path,
|
||||
),
|
||||
):
|
||||
runner = CliRunner()
|
||||
|
||||
+7
-2
@@ -54,8 +54,10 @@ def nvidia_gpu() -> GpuInfo:
|
||||
def nvidia_consumer_gpu() -> GpuInfo:
|
||||
"""NVIDIA consumer GPU fixture."""
|
||||
return GpuInfo(
|
||||
vendor="nvidia", name="NVIDIA GeForce RTX 4090",
|
||||
vram_gb=24.0, count=1,
|
||||
vendor="nvidia",
|
||||
name="NVIDIA GeForce RTX 4090",
|
||||
vram_gb=24.0,
|
||||
count=1,
|
||||
)
|
||||
|
||||
|
||||
@@ -147,6 +149,7 @@ def has_ollama() -> bool:
|
||||
"""Check if Ollama is running locally."""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get("http://localhost:11434/api/tags", timeout=2.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
@@ -158,6 +161,7 @@ def has_vllm() -> bool:
|
||||
"""Check if vLLM is running locally."""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get("http://localhost:8000/v1/models", timeout=2.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
@@ -169,6 +173,7 @@ def has_llamacpp() -> bool:
|
||||
"""Check if llama.cpp server is running locally."""
|
||||
try:
|
||||
import httpx
|
||||
|
||||
resp = httpx.get("http://localhost:8080/v1/models", timeout=2.0)
|
||||
return resp.status_code == 200
|
||||
except Exception:
|
||||
|
||||
+10
-12
@@ -200,9 +200,10 @@ class TestAgentConfigNew:
|
||||
|
||||
def test_no_temperature_or_max_tokens(self) -> None:
|
||||
ac = AgentConfig()
|
||||
assert not hasattr(ac.__class__, "temperature") or isinstance(
|
||||
getattr(ac.__class__, "temperature", None), property
|
||||
) is False
|
||||
assert (
|
||||
not hasattr(ac.__class__, "temperature")
|
||||
or isinstance(getattr(ac.__class__, "temperature", None), property) is False
|
||||
)
|
||||
|
||||
|
||||
class TestNestedEngineConfig:
|
||||
@@ -288,9 +289,9 @@ class TestNestedLearningConfig:
|
||||
def test_loads_nested_toml(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[learning]\nenabled = true\nupdate_interval = 50\n\n'
|
||||
"[learning]\nenabled = true\nupdate_interval = 50\n\n"
|
||||
'[learning.routing]\npolicy = "learned"\n\n'
|
||||
'[learning.metrics]\nlatency_weight = 0.5\n'
|
||||
"[learning.metrics]\nlatency_weight = 0.5\n"
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.learning.enabled is True
|
||||
@@ -315,16 +316,14 @@ class TestNestedLearningConfig:
|
||||
class TestBackwardCompatMigration:
|
||||
def test_agent_temperature_migrates_to_intelligence(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[agent]\ntemperature = 0.3\nmax_tokens = 512\n'
|
||||
)
|
||||
toml_file.write_text("[agent]\ntemperature = 0.3\nmax_tokens = 512\n")
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.intelligence.temperature == 0.3
|
||||
assert cfg.intelligence.max_tokens == 512
|
||||
|
||||
def test_memory_context_injection_migrates_to_agent(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text('[memory]\ncontext_injection = false\n')
|
||||
toml_file.write_text("[memory]\ncontext_injection = false\n")
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.agent.context_from_memory is False
|
||||
|
||||
@@ -400,8 +399,7 @@ class TestSandboxConfig:
|
||||
def test_loads_from_toml(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[sandbox]\nenabled = true\ntimeout = 600\n'
|
||||
'runtime = "podman"\n'
|
||||
'[sandbox]\nenabled = true\ntimeout = 600\nruntime = "podman"\n'
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.sandbox.enabled is True
|
||||
@@ -435,7 +433,7 @@ class TestSchedulerConfig:
|
||||
def test_loads_from_toml(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[scheduler]\nenabled = true\npoll_interval = 30\n'
|
||||
"[scheduler]\nenabled = true\npoll_interval = 30\n"
|
||||
'db_path = "/tmp/sched.db"\n'
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
|
||||
@@ -45,8 +45,7 @@ class TestLearningConfig:
|
||||
def test_toml_loading_with_learning(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[learning]\ndefault_policy = "grpo"\n'
|
||||
'reward_weights = "latency=0.5"\n'
|
||||
'[learning]\ndefault_policy = "grpo"\nreward_weights = "latency=0.5"\n'
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.learning.routing.policy == "grpo"
|
||||
@@ -55,9 +54,9 @@ class TestLearningConfig:
|
||||
def test_toml_loading_nested(self, tmp_path: Path) -> None:
|
||||
toml_file = tmp_path / "config.toml"
|
||||
toml_file.write_text(
|
||||
'[learning]\nenabled = true\n\n'
|
||||
"[learning]\nenabled = true\n\n"
|
||||
'[learning.routing]\npolicy = "learned"\n\n'
|
||||
'[learning.metrics]\nlatency_weight = 0.5\n'
|
||||
"[learning.metrics]\nlatency_weight = 0.5\n"
|
||||
)
|
||||
cfg = load_config(toml_file)
|
||||
assert cfg.learning.enabled is True
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""Tests for credential persistence module."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -95,12 +95,14 @@ class TestEventBus:
|
||||
class TestAgentEventTypes:
|
||||
def test_agent_tick_events_exist(self):
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
assert EventType.AGENT_TICK_START
|
||||
assert EventType.AGENT_TICK_END
|
||||
assert EventType.AGENT_TICK_ERROR
|
||||
|
||||
def test_agent_operational_events_exist(self):
|
||||
from openjarvis.core.events import EventType
|
||||
|
||||
assert EventType.AGENT_BUDGET_EXCEEDED
|
||||
assert EventType.AGENT_STALL_DETECTED
|
||||
assert EventType.AGENT_MESSAGE_RECEIVED
|
||||
|
||||
@@ -24,12 +24,14 @@ class TestScanResultFromJson:
|
||||
|
||||
def test_empty_findings(self):
|
||||
from openjarvis._rust_bridge import scan_result_from_json
|
||||
|
||||
result = scan_result_from_json('{"findings": []}')
|
||||
assert result.clean
|
||||
assert result.findings == []
|
||||
|
||||
def test_with_findings(self):
|
||||
from openjarvis._rust_bridge import scan_result_from_json
|
||||
|
||||
data = {
|
||||
"findings": [
|
||||
{
|
||||
@@ -54,6 +56,7 @@ class TestInjectionResultFromJson:
|
||||
|
||||
def test_clean(self):
|
||||
from openjarvis._rust_bridge import injection_result_from_json
|
||||
|
||||
data = {"is_clean": True, "findings": [], "threat_level": "low"}
|
||||
result = injection_result_from_json(json.dumps(data))
|
||||
assert result.is_clean
|
||||
@@ -61,6 +64,7 @@ class TestInjectionResultFromJson:
|
||||
|
||||
def test_with_findings(self):
|
||||
from openjarvis._rust_bridge import injection_result_from_json
|
||||
|
||||
data = {
|
||||
"is_clean": False,
|
||||
"findings": [
|
||||
@@ -86,11 +90,13 @@ class TestRetrievalResultsFromJson:
|
||||
|
||||
def test_empty(self):
|
||||
from openjarvis._rust_bridge import retrieval_results_from_json
|
||||
|
||||
results = retrieval_results_from_json("[]")
|
||||
assert results == []
|
||||
|
||||
def test_with_items(self):
|
||||
from openjarvis._rust_bridge import retrieval_results_from_json
|
||||
|
||||
data = [
|
||||
{
|
||||
"content": "hello world",
|
||||
@@ -108,6 +114,7 @@ class TestRetrievalResultsFromJson:
|
||||
|
||||
def test_metadata_as_string(self):
|
||||
from openjarvis._rust_bridge import retrieval_results_from_json
|
||||
|
||||
data = [
|
||||
{
|
||||
"content": "test",
|
||||
|
||||
@@ -324,8 +324,10 @@ class TestCloudAnthropic:
|
||||
model="claude-opus-4-6",
|
||||
)
|
||||
call_kwargs = fake_client.messages.create.call_args
|
||||
assert call_kwargs.kwargs.get("system") == "You are helpful" or \
|
||||
call_kwargs[1].get("system") == "You are helpful"
|
||||
assert (
|
||||
call_kwargs.kwargs.get("system") == "You are helpful"
|
||||
or call_kwargs[1].get("system") == "You are helpful"
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -339,10 +341,13 @@ class TestCloudGemini:
|
||||
monkeypatch.delenv("OPENAI_API_KEY", raising=False)
|
||||
monkeypatch.delenv("ANTHROPIC_API_KEY", raising=False)
|
||||
fake_genai = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": fake_genai,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": fake_genai,
|
||||
},
|
||||
):
|
||||
if not EngineRegistry.contains("cloud"):
|
||||
EngineRegistry.register_value("cloud", CloudEngine)
|
||||
engine = CloudEngine()
|
||||
@@ -360,11 +365,14 @@ class TestCloudGemini:
|
||||
fake_config = mock.MagicMock()
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = fake_config
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="gemini-2.5-pro"
|
||||
)
|
||||
@@ -383,11 +391,14 @@ class TestCloudGemini:
|
||||
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="gemini-2.5-flash"
|
||||
)
|
||||
@@ -403,11 +414,14 @@ class TestCloudGemini:
|
||||
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="gemini-3-pro"
|
||||
)
|
||||
@@ -423,11 +437,14 @@ class TestCloudGemini:
|
||||
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="gemini-3-flash"
|
||||
)
|
||||
@@ -441,16 +458,16 @@ class TestCloudGemini:
|
||||
fake_client = mock.MagicMock()
|
||||
|
||||
# Build a response with a function_call part
|
||||
text_part = SimpleNamespace(
|
||||
text="Let me calculate.", function_call=None
|
||||
)
|
||||
text_part = SimpleNamespace(text="Let me calculate.", function_call=None)
|
||||
fc = SimpleNamespace(name="calculator", args={"expression": "2+2"})
|
||||
fc_part = SimpleNamespace(text=None, function_call=fc)
|
||||
content_obj = SimpleNamespace(parts=[text_part, fc_part])
|
||||
candidate = SimpleNamespace(content=content_obj)
|
||||
usage = SimpleNamespace(prompt_token_count=10, candidates_token_count=8)
|
||||
fake_resp = SimpleNamespace(
|
||||
candidates=[candidate], usage_metadata=usage, text=None,
|
||||
candidates=[candidate],
|
||||
usage_metadata=usage,
|
||||
text=None,
|
||||
)
|
||||
fake_client.models.generate_content.return_value = fake_resp
|
||||
engine._google_client = fake_client
|
||||
@@ -458,11 +475,14 @@ class TestCloudGemini:
|
||||
fake_types = mock.MagicMock()
|
||||
fake_config = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = fake_config
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="What is 2+2?")],
|
||||
model="gemini-3-pro",
|
||||
@@ -497,11 +517,14 @@ class TestCloudGemini:
|
||||
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="gemini-3-pro"
|
||||
)
|
||||
@@ -538,11 +561,14 @@ class TestCloudGemini:
|
||||
|
||||
fake_types = mock.MagicMock()
|
||||
fake_types.GenerateContentConfig.return_value = mock.MagicMock()
|
||||
with mock.patch.dict("sys.modules", {
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
}):
|
||||
with mock.patch.dict(
|
||||
"sys.modules",
|
||||
{
|
||||
"google": mock.MagicMock(),
|
||||
"google.genai": mock.MagicMock(),
|
||||
"google.genai.types": fake_types,
|
||||
},
|
||||
):
|
||||
with pytest.raises(
|
||||
EngineConnectionError,
|
||||
match="Google client not available",
|
||||
@@ -606,8 +632,13 @@ class TestPricingTable:
|
||||
def test_all_new_models_in_pricing(self) -> None:
|
||||
expected = [
|
||||
"gpt-5-mini",
|
||||
"claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5",
|
||||
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro", "gemini-3-flash",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
]
|
||||
for model_id in expected:
|
||||
assert model_id in PRICING, f"{model_id} missing from PRICING dict"
|
||||
|
||||
@@ -54,9 +54,7 @@ class TestDiscoverEngines:
|
||||
cfg = JarvisConfig()
|
||||
with mock.patch(
|
||||
"openjarvis.engine._discovery._make_engine",
|
||||
side_effect=lambda k, c: _FakeEngine(
|
||||
healthy=(k == "healthy")
|
||||
),
|
||||
side_effect=lambda k, c: _FakeEngine(healthy=(k == "healthy")),
|
||||
):
|
||||
result = discover_engines(cfg)
|
||||
assert len(result) == 1
|
||||
|
||||
@@ -40,15 +40,18 @@ def _api_prefix(engine_key: str) -> str:
|
||||
return ""
|
||||
return "/v1"
|
||||
|
||||
ENGINES_AND_HOSTS = [
|
||||
(key, host) for key, host, _ in _OPENAI_COMPAT_ENGINES
|
||||
] + [
|
||||
|
||||
ENGINES_AND_HOSTS = [(key, host) for key, host, _ in _OPENAI_COMPAT_ENGINES] + [
|
||||
("ollama", "http://testhost:11434"),
|
||||
]
|
||||
|
||||
MODELS = [
|
||||
"gpt-oss:120b", "qwen3:8b", "glm-4.7-flash", "trinity-mini",
|
||||
"qwen3.5:35b-a3b", "LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
||||
"gpt-oss:120b",
|
||||
"qwen3:8b",
|
||||
"glm-4.7-flash",
|
||||
"trinity-mini",
|
||||
"qwen3.5:35b-a3b",
|
||||
"LiquidAI/LFM2.5-1.2B-Instruct-GGUF",
|
||||
]
|
||||
|
||||
_ENGINE_CLASSES = {key: cls for key, _, cls in _OPENAI_COMPAT_ENGINES}
|
||||
@@ -69,26 +72,34 @@ def _mock_simple_chat(respx_mock, engine_key: str, host: str, model: str):
|
||||
"""Set up mock for a simple chat response."""
|
||||
if engine_key == "ollama":
|
||||
respx_mock.post(f"{host}/api/chat").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"model": model,
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 5,
|
||||
"done": True,
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {"role": "assistant", "content": "Hello!"},
|
||||
"model": model,
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 5,
|
||||
"done": True,
|
||||
},
|
||||
)
|
||||
)
|
||||
else: # All OpenAI-compatible engines
|
||||
prefix = _api_prefix(engine_key)
|
||||
respx_mock.post(f"{host}{prefix}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [
|
||||
{"message": {"content": "Hello!"}, "finish_reason": "stop"},
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15,
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{"message": {"content": "Hello!"}, "finish_reason": "stop"},
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 5,
|
||||
"total_tokens": 15,
|
||||
},
|
||||
"model": model,
|
||||
},
|
||||
"model": model,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -96,39 +107,59 @@ def _mock_tool_call(respx_mock, engine_key: str, host: str, model: str):
|
||||
"""Set up mock for a tool-call response."""
|
||||
if engine_key == "ollama":
|
||||
respx_mock.post(f"{host}/api/chat").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"function": {"name": "calculator", "arguments": '{"x":1}'},
|
||||
}],
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"x":1}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"model": model,
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 8,
|
||||
"done": True,
|
||||
},
|
||||
"model": model,
|
||||
"prompt_eval_count": 10,
|
||||
"eval_count": 8,
|
||||
"done": True,
|
||||
})
|
||||
)
|
||||
)
|
||||
else: # All OpenAI-compatible engines
|
||||
prefix = _api_prefix(engine_key)
|
||||
respx_mock.post(f"{host}{prefix}/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "calculator", "arguments": '{"x":1}'},
|
||||
}],
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"x":1}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 10,
|
||||
"completion_tokens": 8,
|
||||
"total_tokens": 18,
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 10, "completion_tokens": 8, "total_tokens": 18,
|
||||
"model": model,
|
||||
},
|
||||
"model": model,
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -176,9 +207,7 @@ class TestEngineScenarios:
|
||||
engine = _create_engine(engine_key, host)
|
||||
_mock_error(respx_mock, engine_key, host)
|
||||
with pytest.raises(EngineConnectionError):
|
||||
engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="qwen3:8b"
|
||||
)
|
||||
engine.generate([Message(role=Role.USER, content="Hi")], model="qwen3:8b")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -190,7 +219,11 @@ class TestEngineScenarios:
|
||||
@pytest.mark.parametrize("model_id", MODELS)
|
||||
class TestEngineModelMatrix:
|
||||
def test_generate_with_model(
|
||||
self, respx_mock, engine_key: str, host: str, model_id: str,
|
||||
self,
|
||||
respx_mock,
|
||||
engine_key: str,
|
||||
host: str,
|
||||
model_id: str,
|
||||
) -> None:
|
||||
engine = _create_engine(engine_key, host)
|
||||
_mock_simple_chat(respx_mock, engine_key, host, model_id)
|
||||
|
||||
@@ -85,7 +85,9 @@ class TestLlamaCppGenerate:
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=_openai_response(
|
||||
content="", model=model_id, tool_calls=tool_calls,
|
||||
content="",
|
||||
model=model_id,
|
||||
tool_calls=tool_calls,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -143,6 +145,7 @@ class TestLlamaCppGenerate:
|
||||
return tokens
|
||||
|
||||
import asyncio
|
||||
|
||||
tokens = asyncio.run(collect())
|
||||
assert tokens == ["Hi", " there"]
|
||||
|
||||
|
||||
@@ -51,9 +51,7 @@ class TestMLXGenerate:
|
||||
side_effect=httpx.ConnectError("refused")
|
||||
)
|
||||
with pytest.raises(EngineConnectionError):
|
||||
engine.generate(
|
||||
[Message(role=Role.USER, content="Hi")], model="m"
|
||||
)
|
||||
engine.generate([Message(role=Role.USER, content="Hi")], model="m")
|
||||
|
||||
|
||||
class TestMLXHealth:
|
||||
|
||||
@@ -81,7 +81,9 @@ class TestOllamaGenerate:
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=_ollama_response(
|
||||
content="", model=model_id, tool_calls=tool_calls,
|
||||
content="",
|
||||
model=model_id,
|
||||
tool_calls=tool_calls,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -104,7 +106,8 @@ class TestOllamaGenerate:
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=_ollama_response(
|
||||
content="", model=model_id,
|
||||
content="",
|
||||
model=model_id,
|
||||
tool_calls=tool_calls,
|
||||
),
|
||||
)
|
||||
@@ -136,6 +139,7 @@ class TestOllamaGenerate:
|
||||
return tokens
|
||||
|
||||
import asyncio
|
||||
|
||||
tokens = asyncio.run(collect())
|
||||
assert "Hello" in tokens
|
||||
|
||||
@@ -222,9 +226,7 @@ class TestOllamaErrors:
|
||||
|
||||
def capture(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200, json=_ollama_response(content="ok")
|
||||
)
|
||||
return httpx.Response(200, json=_ollama_response(content="ok"))
|
||||
|
||||
respx_mock.post(f"{OLLAMA_HOST}/api/chat").mock(side_effect=capture)
|
||||
engine.generate(
|
||||
@@ -241,12 +243,8 @@ class TestOllamaErrors:
|
||||
|
||||
def capture(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(
|
||||
200, json=_ollama_response(content="ok")
|
||||
)
|
||||
return httpx.Response(200, json=_ollama_response(content="ok"))
|
||||
|
||||
respx_mock.post(f"{OLLAMA_HOST}/api/chat").mock(side_effect=capture)
|
||||
engine.generate(
|
||||
[Message(role=Role.USER, content="Hello")], model="qwen3:8b"
|
||||
)
|
||||
engine.generate([Message(role=Role.USER, content="Hello")], model="qwen3:8b")
|
||||
assert "tools" not in captured["body"]
|
||||
|
||||
@@ -47,12 +47,11 @@ class TestOpenAICompatGenerate:
|
||||
assert result["usage"]["total_tokens"] == 9
|
||||
|
||||
def test_empty_choices_returns_graceful_fallback(
|
||||
self, engine: VLLMEngine,
|
||||
self,
|
||||
engine: VLLMEngine,
|
||||
) -> None:
|
||||
with respx.mock:
|
||||
respx.post(
|
||||
"http://testhost:8000/v1/chat/completions"
|
||||
).mock(
|
||||
respx.post("http://testhost:8000/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
|
||||
@@ -24,17 +24,20 @@ class TestOpenAICompatToolCalls:
|
||||
def test_no_tool_calls(self, respx_mock):
|
||||
"""When no tool_calls in response, result has no tool_calls key."""
|
||||
respx_mock.post("http://localhost:9999/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [
|
||||
{"message": {"content": "Hi"}, "finish_reason": "stop"},
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 7,
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{"message": {"content": "Hi"}, "finish_reason": "stop"},
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 2,
|
||||
"total_tokens": 7,
|
||||
},
|
||||
"model": "test",
|
||||
},
|
||||
"model": "test",
|
||||
})
|
||||
)
|
||||
)
|
||||
engine = _TestEngine()
|
||||
result = engine.generate(
|
||||
@@ -47,28 +50,35 @@ class TestOpenAICompatToolCalls:
|
||||
def test_with_tool_calls(self, respx_mock):
|
||||
"""Extract tool_calls from OpenAI-format response."""
|
||||
respx_mock.post("http://localhost:9999/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [{
|
||||
"id": "call_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"2+2"}',
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": None,
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "call_abc",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"2+2"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
}],
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 15,
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": {
|
||||
"prompt_tokens": 5,
|
||||
"completion_tokens": 10,
|
||||
"total_tokens": 15,
|
||||
"model": "test",
|
||||
},
|
||||
"model": "test",
|
||||
})
|
||||
)
|
||||
)
|
||||
engine = _TestEngine()
|
||||
result = engine.generate(
|
||||
@@ -89,11 +99,16 @@ class TestOpenAICompatToolCalls:
|
||||
|
||||
def capture(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={
|
||||
"choices": [{"message": {"content": "ok"}, "finish_reason": "stop"}],
|
||||
"usage": {},
|
||||
"model": "test",
|
||||
})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{"message": {"content": "ok"}, "finish_reason": "stop"}
|
||||
],
|
||||
"usage": {},
|
||||
"model": "test",
|
||||
},
|
||||
)
|
||||
|
||||
respx_mock.post("http://localhost:9999/v1/chat/completions").mock(
|
||||
side_effect=capture,
|
||||
@@ -108,26 +123,33 @@ class TestOpenAICompatToolCalls:
|
||||
|
||||
def test_multiple_tool_calls(self, respx_mock):
|
||||
respx_mock.post("http://localhost:9999/v1/chat/completions").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"choices": [{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1", "type": "function",
|
||||
"function": {"name": "a", "arguments": "{}"},
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"choices": [
|
||||
{
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"id": "c1",
|
||||
"type": "function",
|
||||
"function": {"name": "a", "arguments": "{}"},
|
||||
},
|
||||
{
|
||||
"id": "c2",
|
||||
"type": "function",
|
||||
"function": {"name": "b", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"id": "c2", "type": "function",
|
||||
"function": {"name": "b", "arguments": "{}"},
|
||||
},
|
||||
],
|
||||
},
|
||||
"finish_reason": "tool_calls",
|
||||
}],
|
||||
"usage": {},
|
||||
"model": "test",
|
||||
})
|
||||
"finish_reason": "tool_calls",
|
||||
}
|
||||
],
|
||||
"usage": {},
|
||||
"model": "test",
|
||||
},
|
||||
)
|
||||
)
|
||||
engine = _TestEngine()
|
||||
result = engine.generate(
|
||||
@@ -145,12 +167,15 @@ class TestOpenAICompatToolCalls:
|
||||
class TestOllamaToolCalls:
|
||||
def test_no_tool_calls(self, respx_mock):
|
||||
respx_mock.post("http://localhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"message": {"content": "Hi"},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 2,
|
||||
})
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {"content": "Hi"},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 2,
|
||||
},
|
||||
)
|
||||
)
|
||||
engine = OllamaEngine()
|
||||
result = engine.generate(
|
||||
@@ -161,20 +186,25 @@ class TestOllamaToolCalls:
|
||||
|
||||
def test_with_tool_calls(self, respx_mock):
|
||||
respx_mock.post("http://localhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"3*3"}',
|
||||
},
|
||||
}],
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": '{"expression":"3*3"}',
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 3,
|
||||
},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 3,
|
||||
})
|
||||
)
|
||||
)
|
||||
engine = OllamaEngine()
|
||||
result = engine.generate(
|
||||
@@ -189,10 +219,13 @@ class TestOllamaToolCalls:
|
||||
|
||||
def capture(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={
|
||||
"message": {"content": "ok"},
|
||||
"model": "test",
|
||||
})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {"content": "ok"},
|
||||
"model": "test",
|
||||
},
|
||||
)
|
||||
|
||||
respx_mock.post("http://localhost:11434/api/chat").mock(side_effect=capture)
|
||||
engine = OllamaEngine()
|
||||
@@ -205,23 +238,26 @@ class TestOllamaToolCalls:
|
||||
|
||||
def test_dict_arguments_serialized_to_json(self, respx_mock):
|
||||
"""Ollama returns arguments as dict — engine must serialize."""
|
||||
respx_mock.post(
|
||||
"http://localhost:11434/api/chat"
|
||||
).mock(
|
||||
return_value=httpx.Response(200, json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [{
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": {"expression": "3*3"},
|
||||
},
|
||||
}],
|
||||
respx_mock.post("http://localhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {
|
||||
"content": "",
|
||||
"tool_calls": [
|
||||
{
|
||||
"function": {
|
||||
"name": "calculator",
|
||||
"arguments": {"expression": "3*3"},
|
||||
},
|
||||
}
|
||||
],
|
||||
},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 3,
|
||||
},
|
||||
"model": "test",
|
||||
"prompt_eval_count": 5,
|
||||
"eval_count": 3,
|
||||
})
|
||||
)
|
||||
)
|
||||
engine = OllamaEngine()
|
||||
result = engine.generate(
|
||||
@@ -240,10 +276,13 @@ class TestOllamaToolCalls:
|
||||
|
||||
def capture(request):
|
||||
captured["body"] = json.loads(request.content)
|
||||
return httpx.Response(200, json={
|
||||
"message": {"content": "ok"},
|
||||
"model": "test",
|
||||
})
|
||||
return httpx.Response(
|
||||
200,
|
||||
json={
|
||||
"message": {"content": "ok"},
|
||||
"model": "test",
|
||||
},
|
||||
)
|
||||
|
||||
respx_mock.post("http://localhost:11434/api/chat").mock(side_effect=capture)
|
||||
engine = OllamaEngine()
|
||||
|
||||
@@ -180,9 +180,7 @@ class TestAnthropicStructuredOutput:
|
||||
json_tool = [t for t in call_kwargs["tools"] if t["name"] == "json_output"][0]
|
||||
assert json_tool["input_schema"] == schema
|
||||
|
||||
def test_appends_to_existing_tools(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_appends_to_existing_tools(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
engine, fake_client = self._make_engine(monkeypatch)
|
||||
rf = ResponseFormat()
|
||||
existing_tools = [
|
||||
@@ -228,12 +226,8 @@ class TestGoogleStructuredOutput:
|
||||
text='{"answer": 42}',
|
||||
function_call=None,
|
||||
)
|
||||
fake_candidate = SimpleNamespace(
|
||||
content=SimpleNamespace(parts=[fake_part])
|
||||
)
|
||||
fake_um = SimpleNamespace(
|
||||
prompt_token_count=10, candidates_token_count=5
|
||||
)
|
||||
fake_candidate = SimpleNamespace(content=SimpleNamespace(parts=[fake_part]))
|
||||
fake_um = SimpleNamespace(prompt_token_count=10, candidates_token_count=5)
|
||||
fake_resp = SimpleNamespace(
|
||||
candidates=[fake_candidate],
|
||||
usage_metadata=fake_um,
|
||||
@@ -242,9 +236,7 @@ class TestGoogleStructuredOutput:
|
||||
fake_client.models.generate_content.return_value = fake_resp
|
||||
return engine, fake_client
|
||||
|
||||
def test_json_mode_sets_mime_type(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
def test_json_mode_sets_mime_type(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
engine, fake_client = self._make_engine(monkeypatch)
|
||||
rf = ResponseFormat()
|
||||
|
||||
@@ -361,9 +353,7 @@ class TestOllamaStructuredOutput:
|
||||
sent_payload = json.loads(route.calls[0].request.content)
|
||||
assert sent_payload["format"] == "json"
|
||||
|
||||
def test_no_format_without_response_format(
|
||||
self, engine: OllamaEngine
|
||||
) -> None:
|
||||
def test_no_format_without_response_format(self, engine: OllamaEngine) -> None:
|
||||
with respx.mock:
|
||||
route = respx.post("http://testhost:11434/api/chat").mock(
|
||||
return_value=httpx.Response(
|
||||
|
||||
@@ -84,7 +84,9 @@ class TestVLLMGenerate:
|
||||
return_value=httpx.Response(
|
||||
200,
|
||||
json=_openai_response(
|
||||
content="", model=model_id, tool_calls=tool_calls,
|
||||
content="",
|
||||
model=model_id,
|
||||
tool_calls=tool_calls,
|
||||
),
|
||||
)
|
||||
)
|
||||
@@ -112,9 +114,7 @@ class TestVLLMGenerate:
|
||||
200, json=_openai_response(content="Fallback reply", model=model_id)
|
||||
)
|
||||
|
||||
respx_mock.post(f"{VLLM_HOST}/v1/chat/completions").mock(
|
||||
side_effect=handler
|
||||
)
|
||||
respx_mock.post(f"{VLLM_HOST}/v1/chat/completions").mock(side_effect=handler)
|
||||
result = engine.generate(
|
||||
[Message(role=Role.USER, content="Hello")],
|
||||
model=model_id,
|
||||
@@ -144,6 +144,7 @@ class TestVLLMGenerate:
|
||||
return tokens
|
||||
|
||||
import asyncio
|
||||
|
||||
tokens = asyncio.run(collect())
|
||||
assert tokens == ["Hello", " world"]
|
||||
|
||||
|
||||
@@ -11,9 +11,7 @@ def _make_record(exact_facts=None, semantic_facts=None):
|
||||
if exact_facts:
|
||||
all_facts += [{"fact": f, "type": "exact"} for f in exact_facts]
|
||||
if semantic_facts:
|
||||
all_facts += [
|
||||
{"fact": f, "type": "semantic"} for f in semantic_facts
|
||||
]
|
||||
all_facts += [{"fact": f, "type": "semantic"} for f in semantic_facts]
|
||||
return EvalRecord(
|
||||
record_id="test-ba-1",
|
||||
problem="Research this topic.",
|
||||
@@ -76,9 +74,7 @@ def test_sources_with_url():
|
||||
record = _make_record(exact_facts=["5432"])
|
||||
scorer = BrowserAssistantScorer()
|
||||
|
||||
answer = (
|
||||
"Port 5432. See https://www.postgresql.org/docs/"
|
||||
)
|
||||
answer = "Port 5432. See https://www.postgresql.org/docs/"
|
||||
is_correct, meta = scorer.score(record, answer)
|
||||
assert meta["sources_cited"] is True
|
||||
|
||||
|
||||
@@ -64,10 +64,7 @@ def test_checklist_scorer_all_pass():
|
||||
|
||||
|
||||
def test_checklist_scorer_partial():
|
||||
response = (
|
||||
"1. yes — Redis is mentioned\n"
|
||||
"2. no — Port number not found\n"
|
||||
)
|
||||
response = "1. yes — Redis is mentioned\n2. no — Port number not found\n"
|
||||
backend = FakeJudgeBackend(response)
|
||||
scorer = ChecklistScorer(backend, "test-model")
|
||||
score, details = scorer.score_checklist(
|
||||
|
||||
@@ -28,7 +28,8 @@ def test_all_tests_fixed():
|
||||
"def test_neg(): assert add(-1, 1) == 0\n"
|
||||
)
|
||||
record = _make_record(
|
||||
buggy, tests,
|
||||
buggy,
|
||||
tests,
|
||||
["test_basic", "test_neg"],
|
||||
["test_zero"],
|
||||
)
|
||||
@@ -49,7 +50,8 @@ def test_partial_fix():
|
||||
"def test_zero(): assert div(0, 1) == 0.0\n"
|
||||
)
|
||||
record = _make_record(
|
||||
buggy, tests,
|
||||
buggy,
|
||||
tests,
|
||||
["test_zero_div"], # not actually testable here
|
||||
["test_normal", "test_zero"],
|
||||
)
|
||||
|
||||
@@ -23,10 +23,8 @@ def _make_record(required_facts):
|
||||
|
||||
def test_all_facts_with_citations():
|
||||
facts = [
|
||||
{"fact": "reclaims storage from dead tuples",
|
||||
"source_doc_index": 0},
|
||||
{"fact": "must run periodically",
|
||||
"source_doc_index": 0},
|
||||
{"fact": "reclaims storage from dead tuples", "source_doc_index": 0},
|
||||
{"fact": "must run periodically", "source_doc_index": 0},
|
||||
]
|
||||
record = _make_record(facts)
|
||||
scorer = DocQAScorer()
|
||||
@@ -45,8 +43,7 @@ def test_all_facts_with_citations():
|
||||
|
||||
def test_facts_without_citations():
|
||||
facts = [
|
||||
{"fact": "reclaims storage from dead tuples",
|
||||
"source_doc_index": 0},
|
||||
{"fact": "reclaims storage from dead tuples", "source_doc_index": 0},
|
||||
]
|
||||
record = _make_record(facts)
|
||||
scorer = DocQAScorer()
|
||||
@@ -70,10 +67,7 @@ def test_partial_facts():
|
||||
record = _make_record(facts)
|
||||
scorer = DocQAScorer()
|
||||
|
||||
answer = (
|
||||
"VACUUM reclaims storage [Doc 1]. "
|
||||
"It also prevents table bloat [Doc 1]."
|
||||
)
|
||||
answer = "VACUUM reclaims storage [Doc 1]. It also prevents table bloat [Doc 1]."
|
||||
is_correct, meta = scorer.score(record, answer)
|
||||
assert meta["facts_found"] == 2
|
||||
assert 0.6 <= meta["fact_score"] <= 0.7
|
||||
|
||||
@@ -62,8 +62,7 @@ def test_partial_detection():
|
||||
scorer = SecurityScannerScorer()
|
||||
|
||||
answer = (
|
||||
"Found SQL injection in app.py. "
|
||||
"Severity: critical. Use parameterized queries."
|
||||
"Found SQL injection in app.py. Severity: critical. Use parameterized queries."
|
||||
)
|
||||
is_correct, meta = scorer.score(record, answer)
|
||||
assert meta["vulns_found"] == 1
|
||||
@@ -107,10 +106,7 @@ def test_vuln_type_aliases():
|
||||
record = _make_record(vulns)
|
||||
scorer = SecurityScannerScorer()
|
||||
|
||||
answer = (
|
||||
"app.py has a cross-site scripting "
|
||||
"vulnerability. Severity: high."
|
||||
)
|
||||
answer = "app.py has a cross-site scripting vulnerability. Severity: high."
|
||||
is_correct, meta = scorer.score(record, answer)
|
||||
assert meta["vulns_found"] == 1
|
||||
assert meta["detection_rate"] == 1.0
|
||||
@@ -120,7 +116,8 @@ def test_no_vulnerabilities():
|
||||
record = _make_record([])
|
||||
scorer = SecurityScannerScorer()
|
||||
is_correct, meta = scorer.score(
|
||||
record, "Everything looks clean.",
|
||||
record,
|
||||
"Everything looks clean.",
|
||||
)
|
||||
assert is_correct is None
|
||||
assert meta["reason"] == "no_vulnerabilities_in_manifest"
|
||||
|
||||
@@ -121,9 +121,7 @@ class TestAgenticRunner:
|
||||
def test_artifacts_saved(self, tmp_path):
|
||||
records = [MockRecord(record_id="r1", problem="test")]
|
||||
dataset = MockDataset(records)
|
||||
runner = AgenticRunner(
|
||||
agent=MockAgent(), dataset=dataset, run_dir=tmp_path
|
||||
)
|
||||
runner = AgenticRunner(agent=MockAgent(), dataset=dataset, run_dir=tmp_path)
|
||||
|
||||
self._run_async(runner.run())
|
||||
arts = tmp_path / "artifacts"
|
||||
@@ -137,9 +135,7 @@ class TestAgenticRunner:
|
||||
"""Verify timeout is stored and runner accepts the parameter."""
|
||||
records = [MockRecord(record_id="r1", problem="test")]
|
||||
dataset = MockDataset(records)
|
||||
runner = AgenticRunner(
|
||||
agent=MockAgent(), dataset=dataset, query_timeout=30.0
|
||||
)
|
||||
runner = AgenticRunner(agent=MockAgent(), dataset=dataset, query_timeout=30.0)
|
||||
assert runner._query_timeout == 30.0
|
||||
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ class TestAMABenchDataset:
|
||||
|
||||
def test_question_types_mapped(self) -> None:
|
||||
from openjarvis.evals.datasets.ama_bench import _QUESTION_TYPE_TO_SUBJECT
|
||||
|
||||
assert _QUESTION_TYPE_TO_SUBJECT["A"] == "recall"
|
||||
assert _QUESTION_TYPE_TO_SUBJECT["B"] == "causal_inference"
|
||||
assert _QUESTION_TYPE_TO_SUBJECT["C"] == "state_updating"
|
||||
@@ -111,8 +112,10 @@ class TestAMABenchScorer:
|
||||
def test_empty_response(self) -> None:
|
||||
s = AMABenchScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="test-1", problem="question",
|
||||
reference="answer", category="agentic",
|
||||
record_id="test-1",
|
||||
problem="question",
|
||||
reference="answer",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "")
|
||||
assert is_correct is False
|
||||
@@ -175,14 +178,10 @@ class TestJudgeParsing:
|
||||
assert _parse_judge_label("yes.") == "yes"
|
||||
|
||||
def test_with_thinking_tags(self) -> None:
|
||||
assert _parse_judge_label(
|
||||
"<think>Let me check...</think>yes"
|
||||
) == "yes"
|
||||
assert _parse_judge_label("<think>Let me check...</think>yes") == "yes"
|
||||
|
||||
def test_with_thinking_tags_no(self) -> None:
|
||||
assert _parse_judge_label(
|
||||
"<think>The answer doesn't match</think>\nno"
|
||||
) == "no"
|
||||
assert _parse_judge_label("<think>The answer doesn't match</think>\nno") == "no"
|
||||
|
||||
def test_multiline(self) -> None:
|
||||
assert _parse_judge_label(" \n yes\n") == "yes"
|
||||
@@ -216,14 +215,17 @@ class TestTokenF1:
|
||||
class TestAMABenchCLI:
|
||||
def test_in_benchmarks_dict(self) -> None:
|
||||
from openjarvis.evals.cli import BENCHMARKS
|
||||
|
||||
assert "ama-bench" in BENCHMARKS
|
||||
|
||||
def test_build_dataset(self) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset("ama-bench")
|
||||
assert ds.dataset_id == "ama-bench"
|
||||
|
||||
def test_build_scorer(self) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
s = _build_scorer("ama-bench", _mock_backend(), "test-model")
|
||||
assert s.scorer_id == "ama-bench"
|
||||
|
||||
@@ -24,84 +24,98 @@ class TestDatasetInstantiation:
|
||||
|
||||
def test_supergpqa(self) -> None:
|
||||
from openjarvis.evals.datasets.supergpqa import SuperGPQADataset
|
||||
|
||||
ds = SuperGPQADataset()
|
||||
assert ds.dataset_id == "supergpqa"
|
||||
assert ds.dataset_name == "SuperGPQA"
|
||||
|
||||
def test_gpqa(self) -> None:
|
||||
from openjarvis.evals.datasets.gpqa import GPQADataset
|
||||
|
||||
ds = GPQADataset()
|
||||
assert ds.dataset_id == "gpqa"
|
||||
assert ds.dataset_name == "GPQA"
|
||||
|
||||
def test_mmlu_pro(self) -> None:
|
||||
from openjarvis.evals.datasets.mmlu_pro import MMLUProDataset
|
||||
|
||||
ds = MMLUProDataset()
|
||||
assert ds.dataset_id == "mmlu-pro"
|
||||
assert ds.dataset_name == "MMLU-Pro"
|
||||
|
||||
def test_math500(self) -> None:
|
||||
from openjarvis.evals.datasets.math500 import MATH500Dataset
|
||||
|
||||
ds = MATH500Dataset()
|
||||
assert ds.dataset_id == "math500"
|
||||
assert ds.dataset_name == "MATH-500"
|
||||
|
||||
def test_natural_reasoning(self) -> None:
|
||||
from openjarvis.evals.datasets.natural_reasoning import NaturalReasoningDataset
|
||||
|
||||
ds = NaturalReasoningDataset()
|
||||
assert ds.dataset_id == "natural-reasoning"
|
||||
assert ds.dataset_name == "Natural Reasoning"
|
||||
|
||||
def test_hle(self) -> None:
|
||||
from openjarvis.evals.datasets.hle import HLEDataset
|
||||
|
||||
ds = HLEDataset()
|
||||
assert ds.dataset_id == "hle"
|
||||
assert ds.dataset_name == "HLE"
|
||||
|
||||
def test_simpleqa(self) -> None:
|
||||
from openjarvis.evals.datasets.simpleqa import SimpleQADataset
|
||||
|
||||
ds = SimpleQADataset()
|
||||
assert ds.dataset_id == "simpleqa"
|
||||
assert ds.dataset_name == "SimpleQA"
|
||||
|
||||
def test_wildchat(self) -> None:
|
||||
from openjarvis.evals.datasets.wildchat import WildChatDataset
|
||||
|
||||
ds = WildChatDataset()
|
||||
assert ds.dataset_id == "wildchat"
|
||||
assert ds.dataset_name == "WildChat"
|
||||
|
||||
def test_ipw(self) -> None:
|
||||
from openjarvis.evals.datasets.ipw_mixed import IPWDataset
|
||||
|
||||
ds = IPWDataset()
|
||||
assert ds.dataset_id == "ipw"
|
||||
assert ds.dataset_name == "IPW"
|
||||
|
||||
def test_gaia(self) -> None:
|
||||
from openjarvis.evals.datasets.gaia import GAIADataset
|
||||
|
||||
ds = GAIADataset()
|
||||
assert ds.dataset_id == "gaia"
|
||||
assert ds.dataset_name == "GAIA"
|
||||
|
||||
def test_frames(self) -> None:
|
||||
from openjarvis.evals.datasets.frames import FRAMESDataset
|
||||
|
||||
ds = FRAMESDataset()
|
||||
assert ds.dataset_id == "frames"
|
||||
assert ds.dataset_name == "FRAMES"
|
||||
|
||||
def test_swebench(self) -> None:
|
||||
from openjarvis.evals.datasets.swebench import SWEBenchDataset
|
||||
|
||||
ds = SWEBenchDataset()
|
||||
assert ds.dataset_id == "swebench"
|
||||
assert ds.dataset_name == "SWE-bench"
|
||||
|
||||
def test_swefficiency(self) -> None:
|
||||
from openjarvis.evals.datasets.swefficiency import SWEfficiencyDataset
|
||||
|
||||
ds = SWEfficiencyDataset()
|
||||
assert ds.dataset_id == "swefficiency"
|
||||
assert ds.dataset_name == "SWEfficiency"
|
||||
|
||||
def test_terminalbench(self) -> None:
|
||||
from openjarvis.evals.datasets.terminalbench import TerminalBenchDataset
|
||||
|
||||
ds = TerminalBenchDataset()
|
||||
assert ds.dataset_id == "terminalbench"
|
||||
assert ds.dataset_name == "TerminalBench"
|
||||
@@ -110,6 +124,7 @@ class TestDatasetInstantiation:
|
||||
from openjarvis.evals.datasets.terminalbench_native import (
|
||||
TerminalBenchNativeDataset,
|
||||
)
|
||||
|
||||
ds = TerminalBenchNativeDataset()
|
||||
assert ds.dataset_id == "terminalbench-native"
|
||||
assert ds.dataset_name == "TerminalBench Native"
|
||||
@@ -132,56 +147,67 @@ class TestScorerInstantiation:
|
||||
|
||||
def test_supergpqa_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.supergpqa_mcq import SuperGPQAScorer
|
||||
|
||||
s = SuperGPQAScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "supergpqa"
|
||||
|
||||
def test_gpqa_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.gpqa_mcq import GPQAScorer
|
||||
|
||||
s = GPQAScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "gpqa"
|
||||
|
||||
def test_mmlu_pro_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.mmlu_pro_mcq import MMLUProScorer
|
||||
|
||||
s = MMLUProScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "mmlu-pro"
|
||||
|
||||
def test_reasoning_judge_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.reasoning_judge import ReasoningJudgeScorer
|
||||
|
||||
s = ReasoningJudgeScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "reasoning_judge"
|
||||
|
||||
def test_hle_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.hle_judge import HLEScorer
|
||||
|
||||
s = HLEScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "hle"
|
||||
|
||||
def test_simpleqa_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.simpleqa_judge import SimpleQAScorer
|
||||
|
||||
s = SimpleQAScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "simpleqa"
|
||||
|
||||
def test_wildchat_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.wildchat_judge import WildChatScorer
|
||||
|
||||
s = WildChatScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "wildchat"
|
||||
|
||||
def test_ipw_mixed_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.ipw_mixed import IPWMixedScorer
|
||||
|
||||
s = IPWMixedScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "ipw"
|
||||
|
||||
def test_gaia_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.gaia_exact import GAIAScorer
|
||||
|
||||
s = GAIAScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "gaia"
|
||||
|
||||
def test_frames_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.frames_judge import FRAMESScorer
|
||||
|
||||
s = FRAMESScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "frames"
|
||||
|
||||
def test_swebench_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.swebench_structural import SWEBenchScorer
|
||||
|
||||
s = SWEBenchScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "swebench"
|
||||
|
||||
@@ -189,11 +215,13 @@ class TestScorerInstantiation:
|
||||
from openjarvis.evals.scorers.swefficiency_structural import (
|
||||
SWEfficiencyScorer,
|
||||
)
|
||||
|
||||
s = SWEfficiencyScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "swefficiency"
|
||||
|
||||
def test_terminalbench_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.terminalbench_judge import TerminalBenchScorer
|
||||
|
||||
s = TerminalBenchScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "terminalbench"
|
||||
|
||||
@@ -201,6 +229,7 @@ class TestScorerInstantiation:
|
||||
from openjarvis.evals.scorers.terminalbench_native_structural import (
|
||||
TerminalBenchNativeScorer,
|
||||
)
|
||||
|
||||
s = TerminalBenchNativeScorer(_mock_backend(), "test-model")
|
||||
assert s.scorer_id == "terminalbench-native"
|
||||
|
||||
@@ -211,9 +240,21 @@ class TestScorerInstantiation:
|
||||
|
||||
|
||||
ALL_BENCHMARKS = [
|
||||
"supergpqa", "gpqa", "mmlu-pro", "math500", "natural-reasoning",
|
||||
"hle", "simpleqa", "wildchat", "ipw", "gaia", "frames",
|
||||
"swebench", "swefficiency", "terminalbench", "terminalbench-native",
|
||||
"supergpqa",
|
||||
"gpqa",
|
||||
"mmlu-pro",
|
||||
"math500",
|
||||
"natural-reasoning",
|
||||
"hle",
|
||||
"simpleqa",
|
||||
"wildchat",
|
||||
"ipw",
|
||||
"gaia",
|
||||
"frames",
|
||||
"swebench",
|
||||
"swefficiency",
|
||||
"terminalbench",
|
||||
"terminalbench-native",
|
||||
]
|
||||
|
||||
|
||||
@@ -223,6 +264,7 @@ class TestCLIFactories:
|
||||
@pytest.mark.parametrize("benchmark", ALL_BENCHMARKS)
|
||||
def test_build_dataset(self, benchmark: str) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset(benchmark)
|
||||
assert ds is not None
|
||||
assert hasattr(ds, "load")
|
||||
@@ -232,6 +274,7 @@ class TestCLIFactories:
|
||||
@pytest.mark.parametrize("benchmark", ALL_BENCHMARKS)
|
||||
def test_build_scorer(self, benchmark: str) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
scorer = _build_scorer(benchmark, _mock_backend(), "test-model")
|
||||
assert scorer is not None
|
||||
assert hasattr(scorer, "score")
|
||||
@@ -240,6 +283,7 @@ class TestCLIFactories:
|
||||
import click
|
||||
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
with pytest.raises(click.ClickException, match="Unknown benchmark"):
|
||||
_build_dataset("nonexistent")
|
||||
|
||||
@@ -247,6 +291,7 @@ class TestCLIFactories:
|
||||
import click
|
||||
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
with pytest.raises(click.ClickException, match="Unknown benchmark"):
|
||||
_build_scorer("nonexistent", _mock_backend(), "test-model")
|
||||
|
||||
@@ -261,11 +306,13 @@ class TestConfigBenchmarks:
|
||||
|
||||
def test_all_benchmarks_known(self) -> None:
|
||||
from openjarvis.evals.core.config import KNOWN_BENCHMARKS
|
||||
|
||||
for b in ALL_BENCHMARKS:
|
||||
assert b in KNOWN_BENCHMARKS, f"{b} missing from KNOWN_BENCHMARKS"
|
||||
|
||||
def test_benchmarks_count(self) -> None:
|
||||
from openjarvis.evals.core.config import KNOWN_BENCHMARKS
|
||||
|
||||
assert len(KNOWN_BENCHMARKS) == 25
|
||||
|
||||
|
||||
@@ -280,9 +327,12 @@ class TestStructuralScorers:
|
||||
def test_swebench_empty_response(self) -> None:
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
from openjarvis.evals.scorers.swebench_structural import SWEBenchScorer
|
||||
|
||||
scorer = SWEBenchScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="swe-1", problem="Fix bug", reference="patch",
|
||||
record_id="swe-1",
|
||||
problem="Fix bug",
|
||||
reference="patch",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = scorer.score(record, "")
|
||||
@@ -292,9 +342,12 @@ class TestStructuralScorers:
|
||||
def test_swebench_with_diff(self) -> None:
|
||||
from openjarvis.evals.core.types import EvalRecord
|
||||
from openjarvis.evals.scorers.swebench_structural import SWEBenchScorer
|
||||
|
||||
scorer = SWEBenchScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="swe-2", problem="Fix bug", reference="patch",
|
||||
record_id="swe-2",
|
||||
problem="Fix bug",
|
||||
reference="patch",
|
||||
category="agentic",
|
||||
)
|
||||
answer = "--- a/file.py\n+++ b/file.py\n@@ -1 +1 @@\n-old\n+new"
|
||||
@@ -308,10 +361,13 @@ class TestStructuralScorers:
|
||||
from openjarvis.evals.scorers.terminalbench_native_structural import (
|
||||
TerminalBenchNativeScorer,
|
||||
)
|
||||
|
||||
scorer = TerminalBenchNativeScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="tb-1", problem="Run command",
|
||||
reference="", category="agentic",
|
||||
record_id="tb-1",
|
||||
problem="Run command",
|
||||
reference="",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = scorer.score(record, "some output")
|
||||
assert is_correct is None
|
||||
@@ -322,10 +378,13 @@ class TestStructuralScorers:
|
||||
from openjarvis.evals.scorers.terminalbench_native_structural import (
|
||||
TerminalBenchNativeScorer,
|
||||
)
|
||||
|
||||
scorer = TerminalBenchNativeScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="tb-2", problem="Run command",
|
||||
reference="", category="agentic",
|
||||
record_id="tb-2",
|
||||
problem="Run command",
|
||||
reference="",
|
||||
category="agentic",
|
||||
metadata={"is_resolved": True},
|
||||
)
|
||||
is_correct, meta = scorer.score(record, "output")
|
||||
|
||||
@@ -63,8 +63,10 @@ class TestDeepPlanningScorer:
|
||||
def test_empty_response(self) -> None:
|
||||
s = DeepPlanningScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="dp-3", problem="task",
|
||||
reference="answer", category="agentic",
|
||||
record_id="dp-3",
|
||||
problem="task",
|
||||
reference="answer",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "")
|
||||
assert is_correct is False
|
||||
@@ -74,14 +76,17 @@ class TestDeepPlanningScorer:
|
||||
class TestDeepPlanningCLI:
|
||||
def test_in_benchmarks(self) -> None:
|
||||
from openjarvis.evals.cli import BENCHMARKS
|
||||
|
||||
assert "deepplanning" in BENCHMARKS
|
||||
|
||||
def test_build_dataset(self) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset("deepplanning")
|
||||
assert ds.dataset_id == "deepplanning"
|
||||
|
||||
def test_build_scorer(self) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
s = _build_scorer("deepplanning", _mock_backend(), "test-model")
|
||||
assert s.scorer_id == "deepplanning"
|
||||
|
||||
@@ -45,8 +45,14 @@ def _make_summary(**overrides) -> RunSummary:
|
||||
|
||||
def _make_metric_stats(**kw) -> MetricStats:
|
||||
defaults = dict(
|
||||
mean=1.0, median=0.9, min=0.1, max=2.5,
|
||||
std=0.3, p90=2.0, p95=2.2, p99=2.4,
|
||||
mean=1.0,
|
||||
median=0.9,
|
||||
min=0.1,
|
||||
max=2.5,
|
||||
std=0.3,
|
||||
p90=2.0,
|
||||
p95=2.2,
|
||||
p99=2.4,
|
||||
)
|
||||
defaults.update(kw)
|
||||
return MetricStats(**defaults)
|
||||
@@ -189,7 +195,8 @@ class TestPrintCompletion:
|
||||
summary = _make_summary()
|
||||
console, buf = _make_console()
|
||||
print_completion(
|
||||
console, summary,
|
||||
console,
|
||||
summary,
|
||||
output_path=Path("results/test.jsonl"),
|
||||
traces_dir=Path("results/traces/supergpqa_qwen3-8b"),
|
||||
)
|
||||
|
||||
@@ -37,13 +37,17 @@ class TestDatasetProviderEpisodes:
|
||||
class TestRunConfigEpisodeMode:
|
||||
def test_episode_mode_field(self) -> None:
|
||||
from openjarvis.evals.core.types import RunConfig
|
||||
|
||||
cfg = RunConfig(
|
||||
benchmark="test", backend="test", model="test",
|
||||
benchmark="test",
|
||||
backend="test",
|
||||
model="test",
|
||||
episode_mode=True,
|
||||
)
|
||||
assert cfg.episode_mode is True
|
||||
|
||||
def test_episode_mode_default_false(self) -> None:
|
||||
from openjarvis.evals.core.types import RunConfig
|
||||
|
||||
cfg = RunConfig(benchmark="test", backend="test", model="test")
|
||||
assert cfg.episode_mode is False
|
||||
|
||||
+74
-57
@@ -17,25 +17,27 @@ from openjarvis.evals.core.trace import QueryTrace, TurnTrace
|
||||
def _make_traces(n=3):
|
||||
traces = []
|
||||
for i in range(n):
|
||||
traces.append(QueryTrace(
|
||||
query_id=f"q{i:04d}",
|
||||
workload_type="test",
|
||||
query_text=f"Question {i}",
|
||||
response_text=f"Answer {i}",
|
||||
turns=[
|
||||
TurnTrace(
|
||||
turn_index=0,
|
||||
input_tokens=100 + i * 10,
|
||||
output_tokens=50 + i * 5,
|
||||
wall_clock_s=1.0 + i * 0.5,
|
||||
gpu_energy_joules=5.0 + i,
|
||||
cost_usd=0.01,
|
||||
),
|
||||
],
|
||||
total_wall_clock_s=1.0 + i * 0.5,
|
||||
completed=True,
|
||||
is_resolved=i % 2 == 0,
|
||||
))
|
||||
traces.append(
|
||||
QueryTrace(
|
||||
query_id=f"q{i:04d}",
|
||||
workload_type="test",
|
||||
query_text=f"Question {i}",
|
||||
response_text=f"Answer {i}",
|
||||
turns=[
|
||||
TurnTrace(
|
||||
turn_index=0,
|
||||
input_tokens=100 + i * 10,
|
||||
output_tokens=50 + i * 5,
|
||||
wall_clock_s=1.0 + i * 0.5,
|
||||
gpu_energy_joules=5.0 + i,
|
||||
cost_usd=0.01,
|
||||
),
|
||||
],
|
||||
total_wall_clock_s=1.0 + i * 0.5,
|
||||
completed=True,
|
||||
is_resolved=i % 2 == 0,
|
||||
)
|
||||
)
|
||||
return traces
|
||||
|
||||
|
||||
@@ -89,11 +91,20 @@ class TestExportSummaryJson:
|
||||
summary = json.loads(path.read_text())
|
||||
stats = summary["statistics"]
|
||||
expected_stat_keys = {
|
||||
"wall_clock_s", "gpu_energy_joules", "cpu_energy_joules",
|
||||
"gpu_power_watts", "cpu_power_watts",
|
||||
"input_tokens", "output_tokens", "total_tokens",
|
||||
"throughput_tokens_per_sec", "energy_per_token_joules",
|
||||
"cost_usd", "turns", "tool_calls", "mbu_avg_pct",
|
||||
"wall_clock_s",
|
||||
"gpu_energy_joules",
|
||||
"cpu_energy_joules",
|
||||
"gpu_power_watts",
|
||||
"cpu_power_watts",
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"total_tokens",
|
||||
"throughput_tokens_per_sec",
|
||||
"energy_per_token_joules",
|
||||
"cost_usd",
|
||||
"turns",
|
||||
"tool_calls",
|
||||
"mbu_avg_pct",
|
||||
}
|
||||
assert set(stats.keys()) == expected_stat_keys
|
||||
|
||||
@@ -152,8 +163,10 @@ class TestComputeEfficiency:
|
||||
def test_no_scored_traces(self):
|
||||
traces = [
|
||||
QueryTrace(
|
||||
query_id="q0", workload_type="test",
|
||||
completed=True, is_resolved=None,
|
||||
query_id="q0",
|
||||
workload_type="test",
|
||||
completed=True,
|
||||
is_resolved=None,
|
||||
),
|
||||
]
|
||||
result = _compute_efficiency(traces, 5.0, 1.0)
|
||||
@@ -169,8 +182,10 @@ class TestComputeEfficiency:
|
||||
def test_with_gpu_power(self):
|
||||
traces = [
|
||||
QueryTrace(
|
||||
query_id="q0", workload_type="test",
|
||||
completed=True, is_resolved=True,
|
||||
query_id="q0",
|
||||
workload_type="test",
|
||||
completed=True,
|
||||
is_resolved=True,
|
||||
query_gpu_power_avg_watts=100.0,
|
||||
),
|
||||
]
|
||||
@@ -351,35 +366,37 @@ class TestActionEnergyBreakdown:
|
||||
def test_action_energy_summary_in_export(self, tmp_path):
|
||||
traces = []
|
||||
for i in range(2):
|
||||
traces.append(QueryTrace(
|
||||
query_id=f"q{i:04d}",
|
||||
workload_type="test",
|
||||
turns=[
|
||||
TurnTrace(
|
||||
turn_index=0,
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
wall_clock_s=2.0,
|
||||
gpu_energy_joules=5.0,
|
||||
action_energy_breakdown=[
|
||||
{
|
||||
"action_type": "lm_inference",
|
||||
"duration_s": 1.5,
|
||||
"gpu_energy_joules": 4.0,
|
||||
"cpu_energy_joules": 0.3,
|
||||
},
|
||||
{
|
||||
"action_type": "tool_call:search",
|
||||
"duration_s": 0.5,
|
||||
"gpu_energy_joules": 1.0,
|
||||
"cpu_energy_joules": 0.1,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
total_wall_clock_s=2.0,
|
||||
completed=True,
|
||||
))
|
||||
traces.append(
|
||||
QueryTrace(
|
||||
query_id=f"q{i:04d}",
|
||||
workload_type="test",
|
||||
turns=[
|
||||
TurnTrace(
|
||||
turn_index=0,
|
||||
input_tokens=100,
|
||||
output_tokens=50,
|
||||
wall_clock_s=2.0,
|
||||
gpu_energy_joules=5.0,
|
||||
action_energy_breakdown=[
|
||||
{
|
||||
"action_type": "lm_inference",
|
||||
"duration_s": 1.5,
|
||||
"gpu_energy_joules": 4.0,
|
||||
"cpu_energy_joules": 0.3,
|
||||
},
|
||||
{
|
||||
"action_type": "tool_call:search",
|
||||
"duration_s": 0.5,
|
||||
"gpu_energy_joules": 1.0,
|
||||
"cpu_energy_joules": 0.1,
|
||||
},
|
||||
],
|
||||
),
|
||||
],
|
||||
total_wall_clock_s=2.0,
|
||||
completed=True,
|
||||
)
|
||||
)
|
||||
path = tmp_path / "summary.json"
|
||||
export_summary_json(traces, {}, path)
|
||||
summary = json.loads(path.read_text())
|
||||
|
||||
@@ -55,13 +55,18 @@ def _db_record(direct=None, md5=None, sql="SELECT * FROM users", skills=None):
|
||||
answer_info = {"direct": direct, "md5": md5, "sql": sql}
|
||||
answer_type = "md5" if md5 else "direct"
|
||||
return EvalRecord(
|
||||
record_id="test-1", problem="task",
|
||||
record_id="test-1",
|
||||
problem="task",
|
||||
reference=json.dumps(answer_info),
|
||||
category="agentic", subject=f"db_{answer_type}",
|
||||
category="agentic",
|
||||
subject=f"db_{answer_type}",
|
||||
metadata={
|
||||
"answer_info": answer_info, "answer_type": answer_type,
|
||||
"skills": skills or [], "table_info": _TABLE_INFO,
|
||||
"table_name": "users", "subset": "db_bench",
|
||||
"answer_info": answer_info,
|
||||
"answer_type": answer_type,
|
||||
"skills": skills or [],
|
||||
"table_info": _TABLE_INFO,
|
||||
"table_name": "users",
|
||||
"subset": "db_bench",
|
||||
"sample_index": 0,
|
||||
},
|
||||
)
|
||||
@@ -69,9 +74,11 @@ def _db_record(direct=None, md5=None, sql="SELECT * FROM users", skills=None):
|
||||
|
||||
def _kg_record(answer_list=None, skills=None, action_list=None):
|
||||
return EvalRecord(
|
||||
record_id="test-kg-1", problem="question",
|
||||
record_id="test-kg-1",
|
||||
problem="question",
|
||||
reference=json.dumps(answer_list or []),
|
||||
category="agentic", subject="knowledge_graph",
|
||||
category="agentic",
|
||||
subject="knowledge_graph",
|
||||
metadata={
|
||||
"subset": "knowledge_graph",
|
||||
"question": "What is the answer?",
|
||||
@@ -86,9 +93,11 @@ def _kg_record(answer_list=None, skills=None, action_list=None):
|
||||
|
||||
def _os_record():
|
||||
return EvalRecord(
|
||||
record_id="test-os-1", problem="task",
|
||||
record_id="test-os-1",
|
||||
problem="task",
|
||||
reference="{}",
|
||||
category="agentic", subject="os_interaction",
|
||||
category="agentic",
|
||||
subject="os_interaction",
|
||||
metadata={
|
||||
"subset": "os_interaction",
|
||||
"instruction": "Create a file",
|
||||
@@ -113,6 +122,7 @@ def _os_record():
|
||||
# DB building
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBuildDB:
|
||||
def test_creates_table_with_rows(self) -> None:
|
||||
conn = build_db(_TABLE_INFO)
|
||||
@@ -174,6 +184,7 @@ class TestTableStateComparison:
|
||||
# SQL extraction (original's Action: Operation format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractSQL:
|
||||
def test_action_operation_format(self) -> None:
|
||||
text = "Action: Operation\n```sql\nSELECT * FROM users;\n```"
|
||||
@@ -211,6 +222,7 @@ class TestExtractSQL:
|
||||
# Text answer parsing (DirectTypeAnswerValidator format)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestTextAnswerParsing:
|
||||
def test_tuple_list(self) -> None:
|
||||
text = "Final Answer: [(1, 'Alice', 95.5), (2, 'Bob', 87.0)]"
|
||||
@@ -249,6 +261,7 @@ class TestTextAnswerParsing:
|
||||
# DB scorer: direct (SELECT)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScorerDBDirect:
|
||||
def test_correct_sql(self) -> None:
|
||||
s = LifelongAgentScorer()
|
||||
@@ -265,7 +278,8 @@ class TestScorerDBDirect:
|
||||
direct=[[1, "Alice", 95.5], [2, "Bob", 87.0], [3, "Carol", 92.3]],
|
||||
)
|
||||
ok, meta = s.score(
|
||||
r, "Action: Operation\n```sql\nSELECT * FROM users\n```",
|
||||
r,
|
||||
"Action: Operation\n```sql\nSELECT * FROM users\n```",
|
||||
)
|
||||
assert ok is True
|
||||
|
||||
@@ -296,7 +310,8 @@ class TestScorerDBDirect:
|
||||
|
||||
def test_no_sql_in_response(self) -> None:
|
||||
ok, meta = LifelongAgentScorer().score(
|
||||
_db_record(direct=[[1]]), "I don't know",
|
||||
_db_record(direct=[[1]]),
|
||||
"I don't know",
|
||||
)
|
||||
assert ok is False
|
||||
|
||||
@@ -304,7 +319,8 @@ class TestScorerDBDirect:
|
||||
s = LifelongAgentScorer()
|
||||
r = _db_record(direct=[[1, "Alice", 95.5]])
|
||||
ok, meta = s.score(
|
||||
r, "Action: Answer\nFinal Answer: [(1, 'Alice', 95.5)]",
|
||||
r,
|
||||
"Action: Answer\nFinal Answer: [(1, 'Alice', 95.5)]",
|
||||
)
|
||||
assert ok is True
|
||||
assert meta["strategy"] == "text_answer_parsing"
|
||||
@@ -322,6 +338,7 @@ class TestScorerDBDirect:
|
||||
# DB scorer: md5 (INSERT/UPDATE/DELETE)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScorerDBMD5:
|
||||
def test_correct_insert(self) -> None:
|
||||
s = LifelongAgentScorer()
|
||||
@@ -363,6 +380,7 @@ class TestScorerDBMD5:
|
||||
# KG scorer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScorerKG:
|
||||
def test_single_shot_unscorable(self) -> None:
|
||||
"""KG tasks should be unscorable in single-shot mode."""
|
||||
@@ -399,6 +417,7 @@ class TestScorerKG:
|
||||
# OS scorer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestScorerOS:
|
||||
def test_returns_scorable_status(self) -> None:
|
||||
s = LifelongAgentScorer()
|
||||
@@ -413,6 +432,7 @@ class TestScorerOS:
|
||||
# KG answer extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractKGAnswers:
|
||||
def test_entity_id(self) -> None:
|
||||
assert extract_kg_answers("Final Answer: m.02h8b9t") == ["m.02h8b9t"]
|
||||
@@ -441,6 +461,7 @@ class TestExtractKGAnswers:
|
||||
# Bash command extraction
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestExtractBashCommands:
|
||||
def test_act_format(self) -> None:
|
||||
text = "Act: ```bash\nls -la /tmp\n```"
|
||||
@@ -469,6 +490,7 @@ class TestExtractBashCommands:
|
||||
# Value comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValueComparison:
|
||||
def test_int(self) -> None:
|
||||
assert values_match(42, 42)
|
||||
@@ -512,6 +534,7 @@ class TestTupleComparison:
|
||||
# Episode grouping
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEpisodeGrouping:
|
||||
def test_single_subset_episode(self) -> None:
|
||||
ds = LifelongAgentDataset(subset="db_bench")
|
||||
@@ -536,14 +559,18 @@ class TestEpisodeGrouping:
|
||||
ds._records = [
|
||||
EvalRecord(
|
||||
record_id="lifelong-db-0",
|
||||
problem="task", reference="{}",
|
||||
category="agentic", subject="db_direct",
|
||||
problem="task",
|
||||
reference="{}",
|
||||
category="agentic",
|
||||
subject="db_direct",
|
||||
metadata={"subset": "db_bench", "sample_index": 0},
|
||||
),
|
||||
EvalRecord(
|
||||
record_id="lifelong-kg-0",
|
||||
problem="task", reference="{}",
|
||||
category="agentic", subject="knowledge_graph",
|
||||
problem="task",
|
||||
reference="{}",
|
||||
category="agentic",
|
||||
subject="knowledge_graph",
|
||||
metadata={"subset": "knowledge_graph", "sample_index": 0},
|
||||
),
|
||||
]
|
||||
@@ -555,8 +582,10 @@ class TestEpisodeGrouping:
|
||||
ds._records = [
|
||||
EvalRecord(
|
||||
record_id=f"lifelong-db-{i}",
|
||||
problem="task", reference="{}",
|
||||
category="agentic", subject="db_direct",
|
||||
problem="task",
|
||||
reference="{}",
|
||||
category="agentic",
|
||||
subject="db_direct",
|
||||
metadata={"subset": "db_bench", "sample_index": i},
|
||||
)
|
||||
for i in range(5)
|
||||
@@ -571,6 +600,7 @@ class TestEpisodeGrouping:
|
||||
# Dataset
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDataset:
|
||||
def test_instantiation_default(self) -> None:
|
||||
ds = LifelongAgentDataset()
|
||||
@@ -603,6 +633,7 @@ class TestDataset:
|
||||
# Multi-turn environments
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDBEnvironment:
|
||||
def test_multi_turn_select(self) -> None:
|
||||
"""DB environment should handle multi-turn SQL interaction."""
|
||||
@@ -643,15 +674,15 @@ class TestDBEnvironment:
|
||||
from openjarvis.evals.environments.lifelong_agent_env import DBEnvironment
|
||||
|
||||
record = _db_record(
|
||||
md5="x", sql="INSERT INTO users VALUES (4, 'Dave', 88.0)",
|
||||
md5="x",
|
||||
sql="INSERT INTO users VALUES (4, 'Dave', 88.0)",
|
||||
)
|
||||
env = DBEnvironment(use_mysql=False)
|
||||
env.reset(record)
|
||||
|
||||
# Agent executes the correct INSERT
|
||||
obs, done = env.step(
|
||||
"Action: Operation\n```sql\n"
|
||||
"INSERT INTO users VALUES (4, 'Dave', 88.0)\n```"
|
||||
"Action: Operation\n```sql\nINSERT INTO users VALUES (4, 'Dave', 88.0)\n```"
|
||||
)
|
||||
assert not done
|
||||
assert "successfully" in obs.lower() or "Result" in obs
|
||||
@@ -814,20 +845,24 @@ class TestOSEnvironment:
|
||||
# CLI wiring
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestCLI:
|
||||
def test_in_benchmarks(self) -> None:
|
||||
from openjarvis.evals.cli import BENCHMARKS
|
||||
|
||||
assert "lifelong-agent" in BENCHMARKS
|
||||
assert BENCHMARKS["lifelong-agent"]["category"] == "agentic"
|
||||
|
||||
def test_build_dataset(self) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset("lifelong-agent")
|
||||
assert ds.dataset_id == "lifelong-agent"
|
||||
assert hasattr(ds, "create_task_env")
|
||||
|
||||
def test_build_scorer(self) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
s = _build_scorer("lifelong-agent", None, "test-model")
|
||||
assert s.scorer_id == "lifelong-agent"
|
||||
|
||||
@@ -836,9 +871,11 @@ class TestCLI:
|
||||
# Runner episode_mode integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRunnerEpisodeMode:
|
||||
def test_episode_mode_field_exists(self) -> None:
|
||||
from openjarvis.evals.core.types import RunConfig
|
||||
|
||||
config = RunConfig(
|
||||
benchmark="lifelong-agent",
|
||||
backend="jarvis-direct",
|
||||
@@ -849,6 +886,7 @@ class TestRunnerEpisodeMode:
|
||||
|
||||
def test_runner_has_episode_mode_method(self) -> None:
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
assert hasattr(EvalRunner, "_run_episode_mode")
|
||||
assert hasattr(EvalRunner, "_process_interactive")
|
||||
assert hasattr(EvalRunner, "_inject_examples")
|
||||
@@ -858,8 +896,11 @@ class TestRunnerEpisodeMode:
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
record = EvalRecord(
|
||||
record_id="test", problem="What is 2+2?",
|
||||
reference="4", category="reasoning", subject="math",
|
||||
record_id="test",
|
||||
problem="What is 2+2?",
|
||||
reference="4",
|
||||
category="reasoning",
|
||||
subject="math",
|
||||
metadata={},
|
||||
)
|
||||
# Call the static-ish method
|
||||
@@ -872,8 +913,11 @@ class TestRunnerEpisodeMode:
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
record = EvalRecord(
|
||||
record_id="test", problem="What is 2+2?",
|
||||
reference="4", category="reasoning", subject="math",
|
||||
record_id="test",
|
||||
problem="What is 2+2?",
|
||||
reference="4",
|
||||
category="reasoning",
|
||||
subject="math",
|
||||
metadata={},
|
||||
)
|
||||
examples = [{"problem": "What is 1+1?", "answer": "2"}]
|
||||
@@ -888,20 +932,25 @@ class TestRunnerEpisodeMode:
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
record = EvalRecord(
|
||||
record_id="test", problem="What is 3+3?",
|
||||
reference="6", category="reasoning", subject="math",
|
||||
record_id="test",
|
||||
problem="What is 3+3?",
|
||||
reference="6",
|
||||
category="reasoning",
|
||||
subject="math",
|
||||
metadata={},
|
||||
)
|
||||
examples = [{
|
||||
"problem": "What is 1+1?",
|
||||
"answer": "2",
|
||||
"interaction_history": [
|
||||
{"role": "user", "content": "What is 1+1?"},
|
||||
{"role": "assistant", "content": "Action: compute(1+1)"},
|
||||
{"role": "user", "content": "Result: 2"},
|
||||
{"role": "assistant", "content": "Final Answer: 2"},
|
||||
],
|
||||
}]
|
||||
examples = [
|
||||
{
|
||||
"problem": "What is 1+1?",
|
||||
"answer": "2",
|
||||
"interaction_history": [
|
||||
{"role": "user", "content": "What is 1+1?"},
|
||||
{"role": "assistant", "content": "Action: compute(1+1)"},
|
||||
{"role": "user", "content": "Result: 2"},
|
||||
{"role": "assistant", "content": "Final Answer: 2"},
|
||||
],
|
||||
}
|
||||
]
|
||||
runner = EvalRunner.__new__(EvalRunner)
|
||||
result = runner._inject_examples(record, examples)
|
||||
assert "Previously Completed Tasks" in result.problem
|
||||
@@ -912,6 +961,7 @@ class TestRunnerEpisodeMode:
|
||||
def test_max_prior_examples_constant(self) -> None:
|
||||
"""Runner should have a FIFO buffer size matching original default."""
|
||||
from openjarvis.evals.core.runner import EvalRunner
|
||||
|
||||
assert EvalRunner._MAX_PRIOR_EXAMPLES == 3
|
||||
|
||||
|
||||
@@ -919,14 +969,13 @@ class TestRunnerEpisodeMode:
|
||||
# KG variable reference resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKGVariableReference:
|
||||
def test_variable_ref_in_scorer(self) -> None:
|
||||
"""Scorer should handle Final Answer: #N format."""
|
||||
# When a variable ref is given and entity IDs are elsewhere in text
|
||||
result = extract_kg_answers(
|
||||
"I found the answer.\n"
|
||||
"The entity m.02h8b9t matches.\n"
|
||||
"Final Answer: #2"
|
||||
"I found the answer.\nThe entity m.02h8b9t matches.\nFinal Answer: #2"
|
||||
)
|
||||
assert "m.02h8b9t" in result
|
||||
|
||||
@@ -962,8 +1011,7 @@ class TestKGVariableReference:
|
||||
def test_variable_ref_with_var_keyword(self) -> None:
|
||||
"""Should handle 'Final Answer: Variable #2' format."""
|
||||
result = extract_kg_answers(
|
||||
"Based on my analysis, m.001 is the answer.\n"
|
||||
"Final Answer: Variable #2"
|
||||
"Based on my analysis, m.001 is the answer.\nFinal Answer: Variable #2"
|
||||
)
|
||||
assert "m.001" in result
|
||||
|
||||
@@ -972,12 +1020,11 @@ class TestKGVariableReference:
|
||||
# OS action format
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestOSActionFormat:
|
||||
def test_original_format_act_bash(self) -> None:
|
||||
"""Should parse original format: Act: bash\\n```bash\\n...\\n```"""
|
||||
cmds = _extract_bash_commands(
|
||||
"Act: bash\n```bash\nls -la /tmp\n```"
|
||||
)
|
||||
cmds = _extract_bash_commands("Act: bash\n```bash\nls -la /tmp\n```")
|
||||
assert len(cmds) == 1
|
||||
assert "ls -la" in cmds[0]
|
||||
|
||||
@@ -995,12 +1042,14 @@ class TestOSActionFormat:
|
||||
# Per-subset max turns
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMaxTurns:
|
||||
def test_db_max_turns(self) -> None:
|
||||
from openjarvis.evals.environments.lifelong_agent_env import (
|
||||
MAX_TURNS_DB,
|
||||
DBEnvironment,
|
||||
)
|
||||
|
||||
env = DBEnvironment(use_mysql=False)
|
||||
assert env.max_turns == MAX_TURNS_DB
|
||||
assert env.max_turns == 3
|
||||
@@ -1010,6 +1059,7 @@ class TestMaxTurns:
|
||||
MAX_TURNS_KG,
|
||||
KGEnvironment,
|
||||
)
|
||||
|
||||
env = KGEnvironment()
|
||||
assert env.max_turns == MAX_TURNS_KG
|
||||
assert env.max_turns == 15
|
||||
@@ -1019,12 +1069,14 @@ class TestMaxTurns:
|
||||
MAX_TURNS_OS,
|
||||
OSEnvironment,
|
||||
)
|
||||
|
||||
env = OSEnvironment()
|
||||
assert env.max_turns == MAX_TURNS_OS
|
||||
assert env.max_turns == 5
|
||||
|
||||
def test_base_default(self) -> None:
|
||||
from openjarvis.evals.environments.base import TaskEnvironment
|
||||
|
||||
# Can't instantiate ABC, but verify the property exists
|
||||
assert hasattr(TaskEnvironment, "max_turns")
|
||||
|
||||
@@ -1033,6 +1085,7 @@ class TestMaxTurns:
|
||||
# Numeric tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNumericTolerance:
|
||||
def test_abs_tol_near_zero(self) -> None:
|
||||
"""abs_tol=1e-6 should make very small values match zero."""
|
||||
|
||||
@@ -101,8 +101,10 @@ class TestLogHubScorer:
|
||||
def test_exact_match_anomaly(self) -> None:
|
||||
s = LogHubScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="test-1", problem="analyze logs",
|
||||
reference="anomaly", category="agentic",
|
||||
record_id="test-1",
|
||||
problem="analyze logs",
|
||||
reference="anomaly",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "ANOMALY\nThe logs show errors.")
|
||||
assert is_correct is True
|
||||
@@ -111,8 +113,10 @@ class TestLogHubScorer:
|
||||
def test_exact_match_normal(self) -> None:
|
||||
s = LogHubScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="test-2", problem="analyze logs",
|
||||
reference="normal", category="agentic",
|
||||
record_id="test-2",
|
||||
problem="analyze logs",
|
||||
reference="normal",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "NORMAL - no issues detected")
|
||||
assert is_correct is True
|
||||
@@ -120,8 +124,10 @@ class TestLogHubScorer:
|
||||
def test_empty_response(self) -> None:
|
||||
s = LogHubScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="test-3", problem="analyze logs",
|
||||
reference="anomaly", category="agentic",
|
||||
record_id="test-3",
|
||||
problem="analyze logs",
|
||||
reference="anomaly",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "")
|
||||
assert is_correct is False
|
||||
@@ -130,8 +136,10 @@ class TestLogHubScorer:
|
||||
def test_wrong_classification(self) -> None:
|
||||
s = LogHubScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="test-4", problem="analyze logs",
|
||||
reference="anomaly", category="agentic",
|
||||
record_id="test-4",
|
||||
problem="analyze logs",
|
||||
reference="anomaly",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "NORMAL - everything looks fine")
|
||||
assert is_correct is False
|
||||
@@ -140,17 +148,20 @@ class TestLogHubScorer:
|
||||
class TestLogHubCLI:
|
||||
def test_in_benchmarks_dict(self) -> None:
|
||||
from openjarvis.evals.cli import BENCHMARKS
|
||||
|
||||
assert "loghub" in BENCHMARKS
|
||||
assert BENCHMARKS["loghub"]["category"] == "agentic"
|
||||
|
||||
def test_build_dataset(self) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset("loghub")
|
||||
assert ds is not None
|
||||
assert ds.dataset_id == "loghub"
|
||||
|
||||
def test_build_scorer(self) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
s = _build_scorer("loghub", _mock_backend(), "test-model")
|
||||
assert s is not None
|
||||
assert s.scorer_id == "loghub"
|
||||
|
||||
@@ -91,8 +91,10 @@ class TestPaperArenaScorer:
|
||||
def test_empty_response(self) -> None:
|
||||
s = PaperArenaScorer(_mock_backend(), "test-model")
|
||||
record = EvalRecord(
|
||||
record_id="pa-5", problem="q",
|
||||
reference="a", category="agentic",
|
||||
record_id="pa-5",
|
||||
problem="q",
|
||||
reference="a",
|
||||
category="agentic",
|
||||
)
|
||||
is_correct, meta = s.score(record, "")
|
||||
assert is_correct is False
|
||||
@@ -102,14 +104,17 @@ class TestPaperArenaScorer:
|
||||
class TestPaperArenaCLI:
|
||||
def test_in_benchmarks(self) -> None:
|
||||
from openjarvis.evals.cli import BENCHMARKS
|
||||
|
||||
assert "paperarena" in BENCHMARKS
|
||||
|
||||
def test_build_dataset(self) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset("paperarena")
|
||||
assert ds.dataset_id == "paperarena"
|
||||
|
||||
def test_build_scorer(self) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
s = _build_scorer("paperarena", _mock_backend(), "test-model")
|
||||
assert s.scorer_id == "paperarena"
|
||||
|
||||
@@ -15,6 +15,7 @@ from openjarvis.evals.core.types import EvalResult, RunConfig, RunSummary
|
||||
# Test double
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class RecordingTracker(ResultTracker):
|
||||
"""Records all lifecycle calls for testing."""
|
||||
|
||||
@@ -58,6 +59,7 @@ class CrashingTracker(ResultTracker):
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_config(**overrides) -> RunConfig:
|
||||
defaults = dict(benchmark="test", backend="jarvis-direct", model="test-model")
|
||||
defaults.update(overrides)
|
||||
@@ -92,6 +94,7 @@ def _make_result(**overrides) -> EvalResult:
|
||||
# RecordingTracker through EvalRunner lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecordingTrackerIntegration:
|
||||
"""Test that trackers receive all lifecycle calls through EvalRunner."""
|
||||
|
||||
@@ -111,11 +114,13 @@ class TestRecordingTrackerIntegration:
|
||||
dataset.iter_records = MagicMock(return_value=[record])
|
||||
|
||||
backend = MagicMock()
|
||||
backend.generate_full = MagicMock(return_value={
|
||||
"content": "2",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
"latency_seconds": 0.5,
|
||||
})
|
||||
backend.generate_full = MagicMock(
|
||||
return_value={
|
||||
"content": "2",
|
||||
"usage": {"prompt_tokens": 10, "completion_tokens": 5},
|
||||
"latency_seconds": 0.5,
|
||||
}
|
||||
)
|
||||
|
||||
scorer = MagicMock()
|
||||
scorer.score = MagicMock(return_value=(True, {}))
|
||||
@@ -152,11 +157,13 @@ class TestRecordingTrackerIntegration:
|
||||
dataset.iter_records = MagicMock(return_value=[record])
|
||||
|
||||
backend = MagicMock()
|
||||
backend.generate_full = MagicMock(return_value={
|
||||
"content": "yes",
|
||||
"usage": {},
|
||||
"latency_seconds": 0.1,
|
||||
})
|
||||
backend.generate_full = MagicMock(
|
||||
return_value={
|
||||
"content": "yes",
|
||||
"usage": {},
|
||||
"latency_seconds": 0.1,
|
||||
}
|
||||
)
|
||||
|
||||
scorer = MagicMock()
|
||||
scorer.score = MagicMock(return_value=(True, {}))
|
||||
@@ -178,6 +185,7 @@ class TestRecordingTrackerIntegration:
|
||||
# WandbTracker unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWandbTracker:
|
||||
"""Unit tests for WandbTracker (mocked wandb module)."""
|
||||
|
||||
@@ -185,6 +193,7 @@ class TestWandbTracker:
|
||||
"""WandbTracker raises ImportError when wandb is not installed."""
|
||||
with patch.dict(sys.modules, {"wandb": None}):
|
||||
import openjarvis.evals.trackers.wandb_tracker as wt_mod
|
||||
|
||||
original = wt_mod.wandb
|
||||
wt_mod.wandb = None
|
||||
try:
|
||||
@@ -277,12 +286,14 @@ class TestWandbTracker:
|
||||
# SheetsTracker unit tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSheetsTracker:
|
||||
"""Unit tests for SheetsTracker."""
|
||||
|
||||
def test_import_error_when_gspread_missing(self):
|
||||
"""SheetsTracker raises ImportError when gspread not installed."""
|
||||
import openjarvis.evals.trackers.sheets_tracker as st_mod
|
||||
|
||||
original = st_mod.gspread
|
||||
st_mod.gspread = None
|
||||
try:
|
||||
|
||||
@@ -25,12 +25,14 @@ import pytest
|
||||
class TestEmailTriageDataset:
|
||||
def test_instantiation(self) -> None:
|
||||
from openjarvis.evals.datasets.email_triage import EmailTriageDataset
|
||||
|
||||
ds = EmailTriageDataset()
|
||||
assert ds.dataset_id == "email_triage"
|
||||
assert ds.dataset_name == "Email Triage"
|
||||
|
||||
def test_load(self) -> None:
|
||||
from openjarvis.evals.datasets.email_triage import EmailTriageDataset
|
||||
|
||||
ds = EmailTriageDataset()
|
||||
ds.load(max_samples=5, seed=42)
|
||||
assert ds.size() == 5
|
||||
@@ -44,6 +46,7 @@ class TestEmailTriageDataset:
|
||||
|
||||
def test_load_all(self) -> None:
|
||||
from openjarvis.evals.datasets.email_triage import EmailTriageDataset
|
||||
|
||||
ds = EmailTriageDataset()
|
||||
ds.load()
|
||||
assert ds.size() == 30
|
||||
@@ -52,12 +55,14 @@ class TestEmailTriageDataset:
|
||||
class TestMorningBriefDataset:
|
||||
def test_instantiation(self) -> None:
|
||||
from openjarvis.evals.datasets.morning_brief import MorningBriefDataset
|
||||
|
||||
ds = MorningBriefDataset()
|
||||
assert ds.dataset_id == "morning_brief"
|
||||
assert ds.dataset_name == "Morning Brief"
|
||||
|
||||
def test_load(self) -> None:
|
||||
from openjarvis.evals.datasets.morning_brief import MorningBriefDataset
|
||||
|
||||
ds = MorningBriefDataset()
|
||||
ds.load(max_samples=5, seed=42)
|
||||
assert ds.size() == 5
|
||||
@@ -68,6 +73,7 @@ class TestMorningBriefDataset:
|
||||
|
||||
def test_load_all(self) -> None:
|
||||
from openjarvis.evals.datasets.morning_brief import MorningBriefDataset
|
||||
|
||||
ds = MorningBriefDataset()
|
||||
ds.load()
|
||||
assert ds.size() == 15
|
||||
@@ -76,12 +82,14 @@ class TestMorningBriefDataset:
|
||||
class TestResearchMiningDataset:
|
||||
def test_instantiation(self) -> None:
|
||||
from openjarvis.evals.datasets.research_mining import ResearchMiningDataset
|
||||
|
||||
ds = ResearchMiningDataset()
|
||||
assert ds.dataset_id == "research_mining"
|
||||
assert ds.dataset_name == "Research Mining"
|
||||
|
||||
def test_load(self) -> None:
|
||||
from openjarvis.evals.datasets.research_mining import ResearchMiningDataset
|
||||
|
||||
ds = ResearchMiningDataset()
|
||||
ds.load(max_samples=5, seed=42)
|
||||
assert ds.size() == 5
|
||||
@@ -92,6 +100,7 @@ class TestResearchMiningDataset:
|
||||
|
||||
def test_load_all(self) -> None:
|
||||
from openjarvis.evals.datasets.research_mining import ResearchMiningDataset
|
||||
|
||||
ds = ResearchMiningDataset()
|
||||
ds.load()
|
||||
assert ds.size() == 31
|
||||
@@ -100,12 +109,14 @@ class TestResearchMiningDataset:
|
||||
class TestKnowledgeBaseDataset:
|
||||
def test_instantiation(self) -> None:
|
||||
from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset
|
||||
|
||||
ds = KnowledgeBaseDataset()
|
||||
assert ds.dataset_id == "knowledge_base"
|
||||
assert ds.dataset_name == "Knowledge Base"
|
||||
|
||||
def test_load(self) -> None:
|
||||
from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset
|
||||
|
||||
ds = KnowledgeBaseDataset()
|
||||
ds.load(max_samples=5, seed=42)
|
||||
assert ds.size() == 5
|
||||
@@ -116,6 +127,7 @@ class TestKnowledgeBaseDataset:
|
||||
|
||||
def test_load_all(self) -> None:
|
||||
from openjarvis.evals.datasets.knowledge_base import KnowledgeBaseDataset
|
||||
|
||||
ds = KnowledgeBaseDataset()
|
||||
ds.load()
|
||||
assert ds.size() == 30
|
||||
@@ -124,12 +136,14 @@ class TestKnowledgeBaseDataset:
|
||||
class TestCodingTaskDataset:
|
||||
def test_instantiation(self) -> None:
|
||||
from openjarvis.evals.datasets.coding_task import CodingTaskDataset
|
||||
|
||||
ds = CodingTaskDataset()
|
||||
assert ds.dataset_id == "coding_task"
|
||||
assert ds.dataset_name == "Coding Task"
|
||||
|
||||
def test_load(self) -> None:
|
||||
from openjarvis.evals.datasets.coding_task import CodingTaskDataset
|
||||
|
||||
ds = CodingTaskDataset()
|
||||
ds.load(max_samples=5, seed=42)
|
||||
assert ds.size() == 5
|
||||
@@ -141,6 +155,7 @@ class TestCodingTaskDataset:
|
||||
|
||||
def test_load_all(self) -> None:
|
||||
from openjarvis.evals.datasets.coding_task import CodingTaskDataset
|
||||
|
||||
ds = CodingTaskDataset()
|
||||
ds.load()
|
||||
assert ds.size() == 29
|
||||
@@ -157,26 +172,31 @@ class TestScorerInstantiation:
|
||||
|
||||
def test_email_triage_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.email_triage import EmailTriageScorer
|
||||
|
||||
scorer = EmailTriageScorer(self._mock_backend(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == "email_triage"
|
||||
|
||||
def test_morning_brief_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.morning_brief import MorningBriefScorer
|
||||
|
||||
scorer = MorningBriefScorer(self._mock_backend(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == "morning_brief"
|
||||
|
||||
def test_research_mining_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.research_mining import ResearchMiningScorer
|
||||
|
||||
scorer = ResearchMiningScorer(self._mock_backend(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == "research_mining"
|
||||
|
||||
def test_knowledge_base_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.knowledge_base import KnowledgeBaseScorer
|
||||
|
||||
scorer = KnowledgeBaseScorer(self._mock_backend(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == "knowledge_base"
|
||||
|
||||
def test_coding_task_scorer(self) -> None:
|
||||
from openjarvis.evals.scorers.coding_task import CodingTaskScorer
|
||||
|
||||
scorer = CodingTaskScorer(self._mock_backend(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == "coding_task"
|
||||
|
||||
@@ -208,10 +228,7 @@ class TestCodingTaskScoring:
|
||||
),
|
||||
},
|
||||
)
|
||||
answer = (
|
||||
"def is_palindrome(s):\n"
|
||||
" return s == s[::-1]"
|
||||
)
|
||||
answer = "def is_palindrome(s):\n return s == s[::-1]"
|
||||
is_correct, meta = scorer.score(record, answer)
|
||||
assert is_correct is True
|
||||
assert meta["tests_passed"] == 3
|
||||
@@ -228,10 +245,7 @@ class TestCodingTaskScoring:
|
||||
reference="",
|
||||
category="use-case",
|
||||
metadata={
|
||||
"test_cases": (
|
||||
"assert add(1, 2) == 3\n"
|
||||
"assert add(0, 0) == 0"
|
||||
),
|
||||
"test_cases": ("assert add(1, 2) == 3\nassert add(0, 0) == 0"),
|
||||
},
|
||||
)
|
||||
# Wrong implementation
|
||||
@@ -285,27 +299,35 @@ class TestEmailTriageScoring:
|
||||
class TestCLIFactories:
|
||||
"""Test that _build_dataset and _build_scorer work for new benchmarks."""
|
||||
|
||||
@pytest.mark.parametrize("benchmark", [
|
||||
"email_triage",
|
||||
"morning_brief",
|
||||
"research_mining",
|
||||
"knowledge_base",
|
||||
"coding_task",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"benchmark",
|
||||
[
|
||||
"email_triage",
|
||||
"morning_brief",
|
||||
"research_mining",
|
||||
"knowledge_base",
|
||||
"coding_task",
|
||||
],
|
||||
)
|
||||
def test_build_dataset(self, benchmark: str) -> None:
|
||||
from openjarvis.evals.cli import _build_dataset
|
||||
|
||||
ds = _build_dataset(benchmark)
|
||||
assert ds.dataset_id == benchmark
|
||||
|
||||
@pytest.mark.parametrize("benchmark", [
|
||||
"email_triage",
|
||||
"morning_brief",
|
||||
"research_mining",
|
||||
"knowledge_base",
|
||||
"coding_task",
|
||||
])
|
||||
@pytest.mark.parametrize(
|
||||
"benchmark",
|
||||
[
|
||||
"email_triage",
|
||||
"morning_brief",
|
||||
"research_mining",
|
||||
"knowledge_base",
|
||||
"coding_task",
|
||||
],
|
||||
)
|
||||
def test_build_scorer(self, benchmark: str) -> None:
|
||||
from openjarvis.evals.cli import _build_scorer
|
||||
|
||||
scorer = _build_scorer(benchmark, MagicMock(), "gpt-5-mini")
|
||||
assert scorer.scorer_id == benchmark
|
||||
|
||||
@@ -320,6 +342,7 @@ class TestCostCalculator:
|
||||
|
||||
def test_estimate_monthly_cost(self) -> None:
|
||||
from openjarvis.server.cost_calculator import estimate_monthly_cost
|
||||
|
||||
est = estimate_monthly_cost(
|
||||
calls_per_month=1000,
|
||||
avg_input_tokens=500,
|
||||
@@ -332,6 +355,7 @@ class TestCostCalculator:
|
||||
|
||||
def test_estimate_scenario(self) -> None:
|
||||
from openjarvis.server.cost_calculator import estimate_scenario
|
||||
|
||||
estimates = estimate_scenario("daily_briefing")
|
||||
assert len(estimates) == 3 # 3 cloud providers
|
||||
for est in estimates:
|
||||
@@ -339,16 +363,19 @@ class TestCostCalculator:
|
||||
|
||||
def test_estimate_all_scenarios(self) -> None:
|
||||
from openjarvis.server.cost_calculator import estimate_all_scenarios
|
||||
|
||||
all_est = estimate_all_scenarios()
|
||||
assert len(all_est) == 5 # 5 scenarios
|
||||
|
||||
def test_unknown_provider(self) -> None:
|
||||
from openjarvis.server.cost_calculator import estimate_monthly_cost
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown provider"):
|
||||
estimate_monthly_cost(100, 100, 100, "nonexistent")
|
||||
|
||||
def test_unknown_scenario(self) -> None:
|
||||
from openjarvis.server.cost_calculator import estimate_scenario
|
||||
|
||||
with pytest.raises(ValueError, match="Unknown scenario"):
|
||||
estimate_scenario("nonexistent")
|
||||
|
||||
@@ -363,6 +390,7 @@ class TestSavings:
|
||||
|
||||
def test_compute_savings_basic(self) -> None:
|
||||
from openjarvis.server.savings import compute_savings
|
||||
|
||||
summary = compute_savings(1000, 500, total_calls=10)
|
||||
assert summary.total_calls == 10
|
||||
assert summary.total_tokens == 1500
|
||||
@@ -375,15 +403,20 @@ class TestSavings:
|
||||
import time
|
||||
|
||||
from openjarvis.server.savings import compute_savings
|
||||
|
||||
start = time.time() - 3600 # 1 hour ago
|
||||
summary = compute_savings(
|
||||
100000, 50000, total_calls=100, session_start=start,
|
||||
100000,
|
||||
50000,
|
||||
total_calls=100,
|
||||
session_start=start,
|
||||
)
|
||||
assert summary.session_duration_hours > 0
|
||||
assert summary.monthly_projection # not empty
|
||||
|
||||
def test_savings_to_dict(self) -> None:
|
||||
from openjarvis.server.savings import compute_savings, savings_to_dict
|
||||
|
||||
summary = compute_savings(1000, 500, total_calls=5)
|
||||
d = savings_to_dict(summary)
|
||||
assert isinstance(d, dict)
|
||||
|
||||
@@ -28,9 +28,9 @@ class TestAMDDetection:
|
||||
@patch(
|
||||
"openjarvis.core.config._run_cmd",
|
||||
side_effect=[
|
||||
"AMD Instinct MI300X", # --showproductname
|
||||
"AMD Instinct MI300X", # --showproductname
|
||||
"GPU[0] : vram Total Memory (B): 206158430208", # --showmeminfo vram
|
||||
"GPU[0] : Some info", # --showallinfo
|
||||
"GPU[0] : Some info", # --showallinfo
|
||||
],
|
||||
)
|
||||
def test_rocm_smi_parsing(self, mock_run, mock_which):
|
||||
@@ -48,8 +48,8 @@ class TestAMDDetection:
|
||||
"openjarvis.core.config._run_cmd",
|
||||
side_effect=[
|
||||
"AMD Instinct MI250X\nAMD Instinct MI250X", # --showproductname
|
||||
"", # --showmeminfo vram (empty)
|
||||
"", # --showallinfo (empty)
|
||||
"", # --showmeminfo vram (empty)
|
||||
"", # --showallinfo (empty)
|
||||
],
|
||||
)
|
||||
def test_amd_gpu_model(self, mock_run, mock_which):
|
||||
@@ -140,8 +140,10 @@ class TestAMDEngineRecommendation:
|
||||
cpu_count=96,
|
||||
ram_gb=768.0,
|
||||
gpu=GpuInfo(
|
||||
vendor="amd", name="AMD Instinct MI300X",
|
||||
vram_gb=192.0, count=1,
|
||||
vendor="amd",
|
||||
name="AMD Instinct MI300X",
|
||||
vram_gb=192.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "vllm"
|
||||
@@ -163,8 +165,10 @@ class TestAMDEngineRecommendation:
|
||||
cpu_count=128,
|
||||
ram_gb=1024.0,
|
||||
gpu=GpuInfo(
|
||||
vendor="amd", name="AMD Instinct MI300X",
|
||||
vram_gb=192.0, count=4,
|
||||
vendor="amd",
|
||||
name="AMD Instinct MI300X",
|
||||
vram_gb=192.0,
|
||||
count=4,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "vllm"
|
||||
|
||||
@@ -87,11 +87,7 @@ class TestAppleDetection:
|
||||
@patch("openjarvis.core.config.platform.system", return_value="Darwin")
|
||||
@patch(
|
||||
"openjarvis.core.config._run_cmd",
|
||||
return_value=(
|
||||
"Graphics/Displays:\n"
|
||||
" Apple Silicon\n"
|
||||
" Type: GPU\n"
|
||||
),
|
||||
return_value=("Graphics/Displays:\n Apple Silicon\n Type: GPU\n"),
|
||||
)
|
||||
def test_apple_no_chipset_line_falls_back(self, mock_run, mock_system):
|
||||
"""When no 'Chipset Model' line exists, falls back to 'Apple Silicon'."""
|
||||
|
||||
@@ -38,9 +38,9 @@ class TestDetectHardware:
|
||||
@patch(
|
||||
"openjarvis.core.config._run_cmd",
|
||||
side_effect=[
|
||||
"AMD Instinct MI300X", # --showproductname
|
||||
"AMD Instinct MI300X", # --showproductname
|
||||
"GPU[0] : vram Total Memory (B): 206158430208", # --showmeminfo vram
|
||||
"GPU[0] : Some info", # --showallinfo
|
||||
"GPU[0] : Some info", # --showallinfo
|
||||
],
|
||||
)
|
||||
def test_detect_amd_gpu(self, mock_run, mock_which):
|
||||
|
||||
@@ -104,7 +104,8 @@ class TestNVIDIAEngineRecommendation:
|
||||
gpu=GpuInfo(
|
||||
vendor="nvidia",
|
||||
name="NVIDIA A100-SXM4-80GB",
|
||||
vram_gb=80.0, count=1,
|
||||
vram_gb=80.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "vllm"
|
||||
@@ -118,7 +119,8 @@ class TestNVIDIAEngineRecommendation:
|
||||
gpu=GpuInfo(
|
||||
vendor="nvidia",
|
||||
name="NVIDIA H100 80GB HBM3",
|
||||
vram_gb=80.0, count=1,
|
||||
vram_gb=80.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "vllm"
|
||||
@@ -132,7 +134,8 @@ class TestNVIDIAEngineRecommendation:
|
||||
gpu=GpuInfo(
|
||||
vendor="nvidia",
|
||||
name="NVIDIA Tesla V100-SXM2-32GB",
|
||||
vram_gb=32.0, count=1,
|
||||
vram_gb=32.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "ollama"
|
||||
@@ -146,7 +149,8 @@ class TestNVIDIAEngineRecommendation:
|
||||
gpu=GpuInfo(
|
||||
vendor="nvidia",
|
||||
name="NVIDIA GeForce RTX 4090",
|
||||
vram_gb=24.0, count=1,
|
||||
vram_gb=24.0,
|
||||
count=1,
|
||||
),
|
||||
)
|
||||
assert recommend_engine(hw) == "ollama"
|
||||
|
||||
@@ -25,9 +25,7 @@ class TestResearchMonitorE2E:
|
||||
templates = manager.list_templates()
|
||||
assert any(t["id"] == "research_monitor" for t in templates)
|
||||
|
||||
agent = manager.create_from_template(
|
||||
"research_monitor", "My Researcher"
|
||||
)
|
||||
agent = manager.create_from_template("research_monitor", "My Researcher")
|
||||
assert agent["name"] == "My Researcher"
|
||||
assert agent["agent_type"] == "monitor_operative"
|
||||
assert agent["status"] == "idle"
|
||||
@@ -64,7 +62,9 @@ class TestResearchMonitorE2E:
|
||||
|
||||
# Complete task
|
||||
manager.update_task(
|
||||
t1["id"], status="completed", findings=["Paper A", "Paper B"],
|
||||
t1["id"],
|
||||
status="completed",
|
||||
findings=["Paper A", "Paper B"],
|
||||
)
|
||||
task = manager._get_task(t1["id"])
|
||||
assert task["status"] == "completed"
|
||||
|
||||
@@ -138,7 +138,8 @@ class TestOrchestratorWithCalculator:
|
||||
bus = EventBus(record_history=True)
|
||||
agent_cls = AgentRegistry.get("orchestrator")
|
||||
agent = agent_cls(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[CalculatorTool()],
|
||||
bus=bus,
|
||||
)
|
||||
@@ -250,8 +251,7 @@ class TestTelemetryThroughAgent:
|
||||
agent.run("Hello")
|
||||
|
||||
telem_events = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.TELEMETRY_RECORD
|
||||
e for e in bus.history if e.event_type == EventType.TELEMETRY_RECORD
|
||||
]
|
||||
assert len(telem_events) == 1
|
||||
rec = telem_events[0].data["record"]
|
||||
@@ -286,14 +286,8 @@ class TestToolExecutorIntegration:
|
||||
assert think_result.content == "Step 1: solve"
|
||||
|
||||
# Verify events
|
||||
starts = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.TOOL_CALL_START
|
||||
]
|
||||
ends = [
|
||||
e for e in bus.history
|
||||
if e.event_type == EventType.TOOL_CALL_END
|
||||
]
|
||||
starts = [e for e in bus.history if e.event_type == EventType.TOOL_CALL_START]
|
||||
ends = [e for e in bus.history if e.event_type == EventType.TOOL_CALL_END]
|
||||
assert len(starts) == 2
|
||||
assert len(ends) == 2
|
||||
|
||||
@@ -321,7 +315,9 @@ class TestHeuristicRewardWithTelemetry:
|
||||
)
|
||||
rf = HeuristicRewardFunction()
|
||||
score = rf.compute(
|
||||
RoutingContext(query="test"), rec.model_id, "response",
|
||||
RoutingContext(query="test"),
|
||||
rec.model_id,
|
||||
"response",
|
||||
latency_seconds=rec.latency_seconds,
|
||||
cost_usd=rec.cost_usd,
|
||||
prompt_tokens=rec.prompt_tokens,
|
||||
@@ -352,16 +348,30 @@ class TestTelemetryPipeline:
|
||||
|
||||
db = tmp_path / "telemetry.db"
|
||||
store = TelemetryStore(db)
|
||||
store.record(TelemetryRecord(
|
||||
timestamp=time.time(), model_id="m1", engine="ollama",
|
||||
prompt_tokens=10, completion_tokens=5, total_tokens=15,
|
||||
latency_seconds=1.0, cost_usd=0.001,
|
||||
))
|
||||
store.record(TelemetryRecord(
|
||||
timestamp=time.time(), model_id="m2", engine="vllm",
|
||||
prompt_tokens=20, completion_tokens=10, total_tokens=30,
|
||||
latency_seconds=0.5, cost_usd=0.002,
|
||||
))
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id="m1",
|
||||
engine="ollama",
|
||||
prompt_tokens=10,
|
||||
completion_tokens=5,
|
||||
total_tokens=15,
|
||||
latency_seconds=1.0,
|
||||
cost_usd=0.001,
|
||||
)
|
||||
)
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id="m2",
|
||||
engine="vllm",
|
||||
prompt_tokens=20,
|
||||
completion_tokens=10,
|
||||
total_tokens=30,
|
||||
latency_seconds=0.5,
|
||||
cost_usd=0.002,
|
||||
)
|
||||
)
|
||||
store.close()
|
||||
|
||||
agg = TelemetryAggregator(db)
|
||||
@@ -387,8 +397,12 @@ class TestEventBusTelemetryAggregator:
|
||||
|
||||
# Publish a telemetry event
|
||||
rec = TelemetryRecord(
|
||||
timestamp=1000.0, model_id="event-model", engine="test",
|
||||
prompt_tokens=5, completion_tokens=3, total_tokens=8,
|
||||
timestamp=1000.0,
|
||||
model_id="event-model",
|
||||
engine="test",
|
||||
prompt_tokens=5,
|
||||
completion_tokens=3,
|
||||
total_tokens=8,
|
||||
latency_seconds=0.1,
|
||||
)
|
||||
bus.publish(EventType.TELEMETRY_RECORD, {"record": rec})
|
||||
@@ -435,11 +449,18 @@ class TestRewardTelemetryIntegration:
|
||||
|
||||
db = tmp_path / "telemetry.db"
|
||||
store = TelemetryStore(db)
|
||||
store.record(TelemetryRecord(
|
||||
timestamp=time.time(), model_id="scored-model", engine="test",
|
||||
prompt_tokens=50, completion_tokens=25, total_tokens=75,
|
||||
latency_seconds=3.0, cost_usd=0.003,
|
||||
))
|
||||
store.record(
|
||||
TelemetryRecord(
|
||||
timestamp=time.time(),
|
||||
model_id="scored-model",
|
||||
engine="test",
|
||||
prompt_tokens=50,
|
||||
completion_tokens=25,
|
||||
total_tokens=75,
|
||||
latency_seconds=3.0,
|
||||
cost_usd=0.003,
|
||||
)
|
||||
)
|
||||
store.close()
|
||||
|
||||
agg = TelemetryAggregator(db)
|
||||
@@ -448,7 +469,9 @@ class TestRewardTelemetryIntegration:
|
||||
|
||||
rf = HeuristicRewardFunction()
|
||||
score = rf.compute(
|
||||
RoutingContext(query="test"), ms.model_id, "response",
|
||||
RoutingContext(query="test"),
|
||||
ms.model_id,
|
||||
"response",
|
||||
latency_seconds=ms.avg_latency,
|
||||
cost_usd=ms.total_cost,
|
||||
prompt_tokens=ms.prompt_tokens,
|
||||
@@ -581,7 +604,8 @@ class TestFullPipeline:
|
||||
|
||||
def run(self, input, context=None, **kwargs):
|
||||
result = self.engine.generate(
|
||||
[], model=self.model,
|
||||
[],
|
||||
model=self.model,
|
||||
)
|
||||
return AgentResult(content=result["content"], turns=1)
|
||||
|
||||
|
||||
@@ -87,16 +87,15 @@ class TestReActPipeline:
|
||||
"Action: calculator\n"
|
||||
'Action Input: {"expression":"2+2"}'
|
||||
),
|
||||
_simple_response(
|
||||
"Thought: The result is 4.\n"
|
||||
"Final Answer: 2+2 equals 4."
|
||||
),
|
||||
_simple_response("Thought: The result is 4.\nFinal Answer: 2+2 equals 4."),
|
||||
]
|
||||
engine = _make_engine(responses)
|
||||
bus = EventBus(record_history=True)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
tools=[CalculatorTool()], bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[CalculatorTool()],
|
||||
bus=bus,
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
|
||||
@@ -118,13 +117,14 @@ class TestReActPipeline:
|
||||
'Action Input: {"thought":"Step 1: analyze"}'
|
||||
),
|
||||
_simple_response(
|
||||
"Thought: I have my analysis.\n"
|
||||
"Final Answer: The answer is clear."
|
||||
"Thought: I have my analysis.\nFinal Answer: The answer is clear."
|
||||
),
|
||||
]
|
||||
engine = _make_engine(responses)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model", tools=[ThinkTool()],
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[ThinkTool()],
|
||||
)
|
||||
result = agent.run("Analyze this.")
|
||||
assert result.turns == 2
|
||||
@@ -136,10 +136,7 @@ class TestReActPipeline:
|
||||
from openjarvis.agents.native_react import NativeReActAgent
|
||||
|
||||
engine = _make_engine(
|
||||
_simple_response(
|
||||
"Thought: This is simple.\n"
|
||||
"Final Answer: Hello!"
|
||||
)
|
||||
_simple_response("Thought: This is simple.\nFinal Answer: Hello!")
|
||||
)
|
||||
agent = NativeReActAgent(engine, "test-model")
|
||||
result = agent.run("Say hello")
|
||||
@@ -155,14 +152,12 @@ class TestReActPipeline:
|
||||
_register_all()
|
||||
from openjarvis.agents.native_react import NativeReActAgent
|
||||
|
||||
engine = _make_engine(
|
||||
_simple_response(
|
||||
"Thought: done.\nFinal Answer: ok"
|
||||
)
|
||||
)
|
||||
engine = _make_engine(_simple_response("Thought: done.\nFinal Answer: ok"))
|
||||
bus = EventBus(record_history=True)
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model", bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
bus=bus,
|
||||
)
|
||||
agent.run("Test")
|
||||
|
||||
@@ -188,19 +183,18 @@ class TestOpenHandsPipeline:
|
||||
|
||||
if not ToolRegistry.contains("code_interpreter"):
|
||||
ToolRegistry.register_value(
|
||||
"code_interpreter", CodeInterpreterTool,
|
||||
"code_interpreter",
|
||||
CodeInterpreterTool,
|
||||
)
|
||||
|
||||
responses = [
|
||||
_simple_response(
|
||||
"I'll calculate this:\n"
|
||||
"```python\nprint(2 + 2)\n```"
|
||||
),
|
||||
_simple_response("I'll calculate this:\n```python\nprint(2 + 2)\n```"),
|
||||
_simple_response("The result is 4."),
|
||||
]
|
||||
engine = _make_engine(responses)
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[CodeInterpreterTool()],
|
||||
)
|
||||
result = agent.run("What is 2+2?")
|
||||
@@ -216,9 +210,7 @@ class TestOpenHandsPipeline:
|
||||
_register_all()
|
||||
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
|
||||
|
||||
engine = _make_engine(
|
||||
_simple_response("Hello! How can I help?")
|
||||
)
|
||||
engine = _make_engine(_simple_response("Hello! How can I help?"))
|
||||
agent = NativeOpenHandsAgent(engine, "test-model")
|
||||
result = agent.run("Say hello")
|
||||
assert result.content == "Hello! How can I help?"
|
||||
@@ -229,12 +221,12 @@ class TestOpenHandsPipeline:
|
||||
_register_all()
|
||||
from openjarvis.agents.native_openhands import NativeOpenHandsAgent
|
||||
|
||||
engine = _make_engine(
|
||||
_simple_response("Direct answer.")
|
||||
)
|
||||
engine = _make_engine(_simple_response("Direct answer."))
|
||||
bus = EventBus(record_history=True)
|
||||
agent = NativeOpenHandsAgent(
|
||||
engine, "test-model", bus=bus,
|
||||
engine,
|
||||
"test-model",
|
||||
bus=bus,
|
||||
)
|
||||
agent.run("Test")
|
||||
|
||||
@@ -275,14 +267,16 @@ class TestMCPIntegration:
|
||||
|
||||
# Call calculator
|
||||
result = client.call_tool(
|
||||
"calculator", {"expression": "10*5"},
|
||||
"calculator",
|
||||
{"expression": "10*5"},
|
||||
)
|
||||
assert result["content"][0]["text"] == "50.0"
|
||||
assert result["isError"] is False
|
||||
|
||||
# Call think
|
||||
result = client.call_tool(
|
||||
"think", {"thought": "reasoning step"},
|
||||
"think",
|
||||
{"thought": "reasoning step"},
|
||||
)
|
||||
assert result["content"][0]["text"] == "reasoning step"
|
||||
|
||||
@@ -325,7 +319,8 @@ class TestMCPIntegration:
|
||||
|
||||
# 3. Call tool
|
||||
result = client.call_tool(
|
||||
"calculator", {"expression": "7+3"},
|
||||
"calculator",
|
||||
{"expression": "7+3"},
|
||||
)
|
||||
assert result["content"][0]["text"] == "10.0"
|
||||
|
||||
@@ -372,15 +367,13 @@ class TestCrossEngineConsistency:
|
||||
"Action: calculator\n"
|
||||
'Action Input: {"expression":"3*3"}'
|
||||
),
|
||||
_simple_response(
|
||||
"Thought: got 9.\n"
|
||||
"Final Answer: 9"
|
||||
),
|
||||
_simple_response("Thought: got 9.\nFinal Answer: 9"),
|
||||
]
|
||||
engine = _make_engine(responses)
|
||||
engine.engine_id = engine_name
|
||||
agent = NativeReActAgent(
|
||||
engine, "test-model",
|
||||
engine,
|
||||
"test-model",
|
||||
tools=[CalculatorTool()],
|
||||
)
|
||||
result = agent.run("What is 3*3?")
|
||||
@@ -453,13 +446,9 @@ class TestModelCatalogIntegration:
|
||||
BUILTIN_MODELS,
|
||||
)
|
||||
|
||||
local = [
|
||||
s for s in BUILTIN_MODELS if not s.requires_api_key
|
||||
]
|
||||
local = [s for s in BUILTIN_MODELS if not s.requires_api_key]
|
||||
for spec in local:
|
||||
assert len(spec.supported_engines) >= 1, (
|
||||
f"{spec.model_id} has no engines"
|
||||
)
|
||||
assert len(spec.supported_engines) >= 1, f"{spec.model_id} has no engines"
|
||||
|
||||
def test_cloud_models_require_api_key(self):
|
||||
"""All cloud models require an API key."""
|
||||
@@ -478,9 +467,7 @@ class TestModelCatalogIntegration:
|
||||
"gemini-3-flash",
|
||||
]
|
||||
for mid in cloud_ids:
|
||||
matches = [
|
||||
s for s in BUILTIN_MODELS if s.model_id == mid
|
||||
]
|
||||
matches = [s for s in BUILTIN_MODELS if s.model_id == mid]
|
||||
assert len(matches) == 1, f"Missing {mid}"
|
||||
assert matches[0].requires_api_key is True
|
||||
|
||||
@@ -494,7 +481,8 @@ class TestAgentRoutingMatrix:
|
||||
"""Agents run consistently across different configurations."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_key", ["native_react", "native_openhands"],
|
||||
"agent_key",
|
||||
["native_react", "native_openhands"],
|
||||
)
|
||||
def test_agent_returns_valid_result(self, agent_key):
|
||||
_register_all()
|
||||
@@ -515,7 +503,8 @@ class TestAgentRoutingMatrix:
|
||||
assert len(result.content) > 0
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"agent_key", ["native_react", "native_openhands"],
|
||||
"agent_key",
|
||||
["native_react", "native_openhands"],
|
||||
)
|
||||
def test_agent_emits_events(self, agent_key):
|
||||
_register_all()
|
||||
@@ -543,15 +532,16 @@ class TestAgentRoutingMatrix:
|
||||
|
||||
engine = _make_engine(
|
||||
_simple_response(
|
||||
"Thought: I see the system message.\n"
|
||||
"Final Answer: Got context."
|
||||
"Thought: I see the system message.\nFinal Answer: Got context."
|
||||
)
|
||||
)
|
||||
conv = Conversation()
|
||||
conv.add(Message(
|
||||
role=Role.SYSTEM,
|
||||
content="You are helpful.",
|
||||
))
|
||||
conv.add(
|
||||
Message(
|
||||
role=Role.SYSTEM,
|
||||
content="You are helpful.",
|
||||
)
|
||||
)
|
||||
ctx = AgentContext(conversation=conv)
|
||||
agent = NativeReActAgent(engine, "test-model")
|
||||
result = agent.run("Hello", context=ctx)
|
||||
@@ -561,6 +551,4 @@ class TestAgentRoutingMatrix:
|
||||
call_args = engine.generate.call_args
|
||||
msgs = call_args[0][0]
|
||||
# First message is ReAct system prompt, then context
|
||||
assert any(
|
||||
m.content == "You are helpful." for m in msgs
|
||||
)
|
||||
assert any(m.content == "You are helpful." for m in msgs)
|
||||
|
||||
@@ -39,7 +39,8 @@ class TestBackwardCompatShims:
|
||||
ctx = build_routing_context("hello")
|
||||
assert ctx.query == "hello"
|
||||
router = HeuristicRouter(
|
||||
available_models=[], default_model="m",
|
||||
available_models=[],
|
||||
default_model="m",
|
||||
)
|
||||
assert router.select_model(ctx) == "m"
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from openjarvis.intelligence.model_catalog import (
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _get_spec(model_id: str) -> ModelSpec:
|
||||
"""Lookup a spec from the BUILTIN_MODELS list (not registry)."""
|
||||
for spec in BUILTIN_MODELS:
|
||||
@@ -273,10 +274,19 @@ class TestModelDiscovery:
|
||||
def test_cloud_models_require_api_key(self) -> None:
|
||||
"""All cloud models have requires_api_key=True."""
|
||||
cloud_ids = {
|
||||
"gpt-4o", "gpt-4o-mini", "gpt-5-mini", "gpt-5-mini-2025-08-07",
|
||||
"claude-sonnet-4-20250514", "claude-opus-4-20250514",
|
||||
"claude-opus-4-6", "claude-sonnet-4-6", "claude-haiku-4-5",
|
||||
"gemini-2.5-pro", "gemini-2.5-flash", "gemini-3-pro", "gemini-3-flash",
|
||||
"gpt-4o",
|
||||
"gpt-4o-mini",
|
||||
"gpt-5-mini",
|
||||
"gpt-5-mini-2025-08-07",
|
||||
"claude-sonnet-4-20250514",
|
||||
"claude-opus-4-20250514",
|
||||
"claude-opus-4-6",
|
||||
"claude-sonnet-4-6",
|
||||
"claude-haiku-4-5",
|
||||
"gemini-2.5-pro",
|
||||
"gemini-2.5-flash",
|
||||
"gemini-3-pro",
|
||||
"gemini-3-flash",
|
||||
}
|
||||
for spec in BUILTIN_MODELS:
|
||||
if spec.model_id in cloud_ids:
|
||||
@@ -287,9 +297,15 @@ class TestModelDiscovery:
|
||||
def test_moe_models_have_active_params(self) -> None:
|
||||
"""MoE models have active_parameter_count_b set."""
|
||||
moe_ids = {
|
||||
"gpt-oss:120b", "glm-4.7-flash", "trinity-mini",
|
||||
"qwen3.5:3b", "qwen3.5:8b", "qwen3.5:14b",
|
||||
"qwen3.5:35b", "qwen3.5:122b", "qwen3.5:397b",
|
||||
"gpt-oss:120b",
|
||||
"glm-4.7-flash",
|
||||
"trinity-mini",
|
||||
"qwen3.5:3b",
|
||||
"qwen3.5:8b",
|
||||
"qwen3.5:14b",
|
||||
"qwen3.5:35b",
|
||||
"qwen3.5:122b",
|
||||
"qwen3.5:397b",
|
||||
"granite4.0-h-small",
|
||||
}
|
||||
for spec in BUILTIN_MODELS:
|
||||
@@ -308,8 +324,12 @@ class TestModelDiscovery:
|
||||
"""merge_discovered_models works for all new model IDs."""
|
||||
register_builtin_models()
|
||||
new_ids = [
|
||||
"gpt-oss:120b", "glm-4.7-flash", "trinity-mini",
|
||||
"gpt-5-mini", "claude-opus-4-6", "gemini-3-pro",
|
||||
"gpt-oss:120b",
|
||||
"glm-4.7-flash",
|
||||
"trinity-mini",
|
||||
"gpt-5-mini",
|
||||
"claude-opus-4-6",
|
||||
"gemini-3-pro",
|
||||
]
|
||||
# Merging known IDs should not raise
|
||||
merge_discovered_models("vllm", new_ids)
|
||||
|
||||
@@ -19,15 +19,19 @@ def _register_models() -> None:
|
||||
ModelRegistry.register_value(
|
||||
"small",
|
||||
ModelSpec(
|
||||
model_id="small", name="Small",
|
||||
parameter_count_b=3.0, context_length=4096,
|
||||
model_id="small",
|
||||
name="Small",
|
||||
parameter_count_b=3.0,
|
||||
context_length=4096,
|
||||
),
|
||||
)
|
||||
ModelRegistry.register_value(
|
||||
"large",
|
||||
ModelSpec(
|
||||
model_id="large", name="Large",
|
||||
parameter_count_b=70.0, context_length=131072,
|
||||
model_id="large",
|
||||
name="Large",
|
||||
parameter_count_b=70.0,
|
||||
context_length=131072,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user